diff --git a/src/lib/mathUtils.js b/src/lib/mathUtils.js index 6eb5391..26e3592 100644 --- a/src/lib/mathUtils.js +++ b/src/lib/mathUtils.js @@ -14,7 +14,7 @@ export function toDirection(orientation) { direction += 360; } - return direction; + return direction === 0 ? 0 : direction; } /** diff --git a/src/tests/lib/formatters.test.js b/src/tests/lib/formatters.test.js index 1b3f39f..0e51b90 100644 --- a/src/tests/lib/formatters.test.js +++ b/src/tests/lib/formatters.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { convertUnixToTime } from '$lib/formatters'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { convertUnixToTime, formatLastUpdated, formatTime } from '$lib/formatters'; describe('convertUnixToTime', () => { it('returns a blank string when its input is null', () => { @@ -14,3 +14,108 @@ describe('convertUnixToTime', () => { expect(convertUnixToTime(1727442050)).toBe('01:00 PM'); }); }); + +describe('formatLastUpdated', () => { + const translations = { + min: 'min', + sec: 'sec', + ago: 'ago' + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-16T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('formats time less than a minute ago', () => { + const timestamp = new Date('2024-01-16T11:59:30Z'); + const result = formatLastUpdated(timestamp, translations); + expect(result).toBe('30 sec ago'); + }); + + it('formats time more than a minute ago', () => { + const timestamp = new Date('2024-01-16T11:58:30Z'); + const result = formatLastUpdated(timestamp, translations); + expect(result).toBe('1 min 30 sec ago'); + }); + + it('formats time multiple minutes ago', () => { + const timestamp = new Date('2024-01-16T11:57:15Z'); + const result = formatLastUpdated(timestamp, translations); + expect(result).toBe('2 min 45 sec ago'); + }); + + it('handles just-now timestamps', () => { + const timestamp = new Date('2024-01-16T11:59:59Z'); + const result = formatLastUpdated(timestamp, translations); + expect(result).toBe('1 sec ago'); + }); + + it('works with different translation objects', () => { + const spanishTranslations = { + min: 'min', + sec: 'seg', + ago: 'atrás' + }; + const timestamp = new Date('2024-01-16T11:58:30Z'); + const result = formatLastUpdated(timestamp, spanishTranslations); + expect(result).toBe('1 min 30 seg atrás'); + }); + + it('handles zero seconds case', () => { + const timestamp = new Date('2024-01-16T12:00:00Z'); + const result = formatLastUpdated(timestamp, translations); + expect(result).toBe('0 sec ago'); + }); +}); + +describe('formatTime', () => { + beforeEach(() => { + // Set timezone to UTC to avoid local timezone issues + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-16T12:00:00Z')); + // Mock toLocaleTimeString to ensure consistent output + vi.spyOn(Date.prototype, 'toLocaleTimeString').mockImplementation(function () { + const hours = this.getUTCHours(); + const minutes = this.getUTCMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + const hour12 = hours % 12 || 12; + const minutesPadded = minutes.toString().padStart(2, '0'); + return `${hour12}:${minutesPadded} ${ampm}`; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('handles morning times', () => { + const result = formatTime('2024-01-16T09:15:00Z'); + expect(result).toBe('9:15 AM'); + }); + + it('handles afternoon times', () => { + const result = formatTime('2024-01-16T14:45:00Z'); + expect(result).toBe('2:45 PM'); + }); + + it('handles midnight', () => { + const result = formatTime('2024-01-16T00:00:00Z'); + expect(result).toBe('12:00 AM'); + }); + + it('handles noon', () => { + const result = formatTime('2024-01-16T12:00:00Z'); + expect(result).toBe('12:00 PM'); + }); + + it('pads minutes with leading zeros', () => { + const result = formatTime('2024-01-16T09:05:00Z'); + expect(result).toBe('9:05 AM'); + }); +}); diff --git a/src/tests/lib/keybinding.test.js b/src/tests/lib/keybinding.test.js new file mode 100644 index 0000000..618bae7 --- /dev/null +++ b/src/tests/lib/keybinding.test.js @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { keybinding } from '$lib/keybinding'; + +describe('keybinding action', () => { + let node; + let mockWindow; + let currentHandler; + + beforeEach(() => { + node = { + click: vi.fn() + }; + + mockWindow = { + addEventListener: vi.fn((event, handler) => { + if (event === 'keydown') { + currentHandler = handler; + } + }), + removeEventListener: vi.fn((event, handler) => { + if (event === 'keydown' && handler === currentHandler) { + currentHandler = null; + } + }) + }; + + // Set up mock window + const originalWindow = global.window; + global.window = mockWindow; + + beforeEach.cleanup = () => { + global.window = originalWindow; + }; + }); + + afterEach(() => { + beforeEach.cleanup?.(); + vi.clearAllMocks(); + currentHandler = null; + }); + + const triggerKeydown = (keyParams) => { + const event = { + code: keyParams.code || '', + altKey: keyParams.alt || false, + shiftKey: keyParams.shift || false, + ctrlKey: keyParams.control || false, + metaKey: keyParams.control || false, + preventDefault: vi.fn() + }; + + if (currentHandler) { + currentHandler(event); + } + return event; + }; + + it('adds keydown event listener on initialization', () => { + keybinding(node, { code: 'KeyA' }); + expect(mockWindow.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); + + it('removes existing listener before adding new one on update', () => { + let params = { code: 'KeyA' }; + const action = keybinding(node, params); + + // Clear the initial setup calls + mockWindow.addEventListener.mockClear(); + mockWindow.removeEventListener.mockClear(); + + // Update the params object directly + params.code = 'KeyB'; + action.update(); + + expect(mockWindow.removeEventListener).toHaveBeenCalledTimes(1); + expect(mockWindow.addEventListener).toHaveBeenCalledTimes(1); + }); + + it('removes listener on destroy', () => { + const action = keybinding(node, { code: 'KeyA' }); + action.destroy(); + + expect(mockWindow.removeEventListener).toHaveBeenCalled(); + }); + + it('only responds to matching key code', () => { + keybinding(node, { code: 'KeyA' }); + + triggerKeydown({ code: 'KeyB' }); + expect(node.click).not.toHaveBeenCalled(); + + triggerKeydown({ code: 'KeyA' }); + expect(node.click).toHaveBeenCalledTimes(1); + }); + + it('responds to updated key code', () => { + // Create params object that we'll modify + let params = { code: 'KeyA' }; + const action = keybinding(node, params); + + // Verify initial binding works + triggerKeydown({ code: 'KeyA' }); + expect(node.click).toHaveBeenCalledTimes(1); + + // Update params and trigger update + node.click.mockClear(); + params.code = 'KeyB'; + action.update(); + + // Verify new binding works + triggerKeydown({ code: 'KeyB' }); + expect(node.click).toHaveBeenCalledTimes(1); + + // Verify old binding doesn't work + node.click.mockClear(); + triggerKeydown({ code: 'KeyA' }); + expect(node.click).not.toHaveBeenCalled(); + }); + + it('handles modifier keys correctly', () => { + keybinding(node, { + code: 'KeyA', + alt: true, + shift: true, + control: true + }); + + triggerKeydown({ code: 'KeyA' }); + expect(node.click).not.toHaveBeenCalled(); + + triggerKeydown({ code: 'KeyA', alt: true }); + expect(node.click).not.toHaveBeenCalled(); + + triggerKeydown({ code: 'KeyA', alt: true, shift: true, control: true }); + expect(node.click).toHaveBeenCalledTimes(1); + }); + + it('handles meta key as control', () => { + keybinding(node, { + code: 'KeyA', + control: true + }); + + const event = triggerKeydown({ code: 'KeyA', control: true }); + expect(node.click).toHaveBeenCalledTimes(1); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('triggers callback instead of click when provided', () => { + const callback = vi.fn(); + keybinding(node, { code: 'KeyA', callback }); + + triggerKeydown({ code: 'KeyA' }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(node.click).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/lib/mathUtils.test.js b/src/tests/lib/mathUtils.test.js new file mode 100644 index 0000000..f8f4c43 --- /dev/null +++ b/src/tests/lib/mathUtils.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { toDirection, calculateMidpoint } from '$lib/mathUtils'; + +describe('toDirection', () => { + it('converts east orientation (0°) to 90° direction', () => { + expect(toDirection(0)).toBe(90); + }); + + it('converts north orientation (90°) to 0° direction', () => { + expect(toDirection(90)).toBe(0); + }); + + it('converts west orientation (180°) to 270° direction', () => { + expect(toDirection(180)).toBe(270); + }); + + it('converts south orientation (270°) to 180° direction', () => { + expect(toDirection(270)).toBe(180); + }); + + it('handles negative orientations', () => { + expect(toDirection(-90)).toBe(180); // -90° orientation should be 180° direction + expect(toDirection(-180)).toBe(270); // -180° orientation should be 270° direction + expect(toDirection(-270)).toBe(0); // -270° orientation should be 0° direction + }); + + it('handles orientations > 360°', () => { + expect(toDirection(450)).toBe(0); // 450° orientation (90° + 360°) should be 0° direction + expect(toDirection(720)).toBe(90); // 720° orientation (0° + 2*360°) should be 90° direction + }); + + it('converts arbitrary angles correctly', () => { + expect(toDirection(45)).toBe(45); // 45° orientation to 45° direction + expect(toDirection(135)).toBe(315); // 135° orientation to 315° direction + expect(toDirection(225)).toBe(225); // 225° orientation to 225° direction + expect(toDirection(315)).toBe(135); // 315° orientation to 135° direction + }); +}); + +describe('calculateMidpoint', () => { + it('calculates midpoint for two stops', () => { + const stops = [ + { lat: 47.6062, lon: -122.3321 }, + { lat: 47.6092, lon: -122.3331 } + ]; + + const result = calculateMidpoint(stops); + + expect(result.lat).toBeCloseTo(47.6077); + expect(result.lng).toBeCloseTo(-122.3326); + }); + + it('calculates midpoint for multiple stops', () => { + const stops = [ + { lat: 47.6062, lon: -122.3321 }, + { lat: 47.6092, lon: -122.3331 }, + { lat: 47.6082, lon: -122.3341 } + ]; + + const result = calculateMidpoint(stops); + + expect(result.lat).toBeCloseTo(47.6079); + expect(result.lng).toBeCloseTo(-122.3331); + }); + + it('returns same point for single stop', () => { + const stops = [{ lat: 47.6062, lon: -122.3321 }]; + + const result = calculateMidpoint(stops); + + expect(result.lat).toBe(47.6062); + expect(result.lng).toBe(-122.3321); + }); + + it('handles positive and negative coordinates', () => { + const stops = [ + { lat: -33.8688, lon: 151.2093 }, // Sydney + { lat: 40.7128, lon: -74.006 } // New York + ]; + + const result = calculateMidpoint(stops); + + expect(result.lat).toBeCloseTo(3.422); + expect(result.lng).toBeCloseTo(38.6017); + }); + + it('handles coordinates around the same area', () => { + const stops = [ + { lat: 47.6062, lon: -122.3321 }, + { lat: 47.6065, lon: -122.3324 }, + { lat: 47.6068, lon: -122.3327 } + ]; + + const result = calculateMidpoint(stops); + + expect(result.lat).toBeCloseTo(47.6065); + expect(result.lng).toBeCloseTo(-122.3324); + }); +});