From 58ad8f695963ad0d58ab2e50d78d4b57acdaab17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:48:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test:=20add=20comprehensive=20test=20suite?= =?UTF-8?q?=20with=20Vitest=20=E2=80=93=20123=20tests=20across=2011=20file?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/FirmwareDroid/FMD-WebClient/sessions/7967cd53-e804-4b79-8192-8b1cdf51a6db Co-authored-by: 7homasSutter <9306853+7homasSutter@users.noreply.github.com> --- package.json | 4 +- src/hooks/use-mobile.test.ts | 65 +++++ src/lib/utils.test.ts | 36 +++ src/services/adb-streamer/credentials.test.ts | 79 ++++++ src/services/adb-streamer/http-client.test.ts | 71 +++++ src/services/adb-streamer/storage.test.ts | 119 ++++++++ src/services/adb-streamer/utils/file.test.js | 39 +++ .../utils/mapClientToDevicePosition.test.js | 105 +++++++ src/services/adb-streamer/utils/rules.test.js | 98 +++++++ src/stores/adb.test.ts | 268 ++++++++++++++++++ src/stores/file.test.ts | 102 +++++++ src/stores/toast.test.ts | 136 +++++++++ src/test/setup.ts | 1 + src/vite-env.d.ts | 2 +- vite.config.ts | 12 + 15 files changed, 1135 insertions(+), 2 deletions(-) create mode 100644 src/hooks/use-mobile.test.ts create mode 100644 src/lib/utils.test.ts create mode 100644 src/services/adb-streamer/credentials.test.ts create mode 100644 src/services/adb-streamer/http-client.test.ts create mode 100644 src/services/adb-streamer/storage.test.ts create mode 100644 src/services/adb-streamer/utils/file.test.js create mode 100644 src/services/adb-streamer/utils/mapClientToDevicePosition.test.js create mode 100644 src/services/adb-streamer/utils/rules.test.js create mode 100644 src/stores/adb.test.ts create mode 100644 src/stores/file.test.ts create mode 100644 src/stores/toast.test.ts create mode 100644 src/test/setup.ts diff --git a/package.json b/package.json index e0bc5246..6c56539d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "version": "0.2.0", "type": "module", "scripts": { - "dev": "vite", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "build": "tsc -b && vite build", "lint": "eslint firmware-droid-client", "preview": "vite preview", diff --git a/src/hooks/use-mobile.test.ts b/src/hooks/use-mobile.test.ts new file mode 100644 index 00000000..09c91895 --- /dev/null +++ b/src/hooks/use-mobile.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useIsMobile } from '@/hooks/use-mobile'; + +const MOBILE_BREAKPOINT = 768; + +describe('useIsMobile', () => { + let addEventListenerSpy: ReturnType; + let removeEventListenerSpy: ReturnType; + let mediaQueryListeners: Array<(e: Partial) => void>; + + beforeEach(() => { + mediaQueryListeners = []; + addEventListenerSpy = vi.fn((_event: string, handler: (e: Partial) => void) => { + mediaQueryListeners.push(handler); + }); + removeEventListenerSpy = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, + }), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false when window width is >= breakpoint', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, value: MOBILE_BREAKPOINT }); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + }); + + it('returns true when window width is below the breakpoint', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, value: MOBILE_BREAKPOINT - 1 }); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(true); + }); + + it('updates when a media-query change event fires', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, value: MOBILE_BREAKPOINT }); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + + // Simulate window becoming mobile-sized + act(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, value: 375 }); + mediaQueryListeners.forEach((handler) => handler({})); + }); + + expect(result.current).toBe(true); + }); + + it('removes the event listener on unmount', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, value: 1024 }); + const { unmount } = renderHook(() => useIsMobile()); + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('change', expect.any(Function)); + }); +}); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 00000000..166c3c8e --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { cn } from '@/lib/utils'; + +describe('cn', () => { + it('returns an empty string when called with no arguments', () => { + expect(cn()).toBe(''); + }); + + it('returns a single class unchanged', () => { + expect(cn('foo')).toBe('foo'); + }); + + it('merges multiple classes', () => { + expect(cn('foo', 'bar')).toBe('foo bar'); + }); + + it('deduplicates conflicting Tailwind classes (last wins)', () => { + expect(cn('p-2', 'p-4')).toBe('p-4'); + }); + + it('ignores falsy values', () => { + expect(cn('foo', undefined, null, false, 'bar')).toBe('foo bar'); + }); + + it('supports conditional object syntax', () => { + expect(cn({ 'text-red-500': true, 'text-blue-500': false })).toBe('text-red-500'); + }); + + it('supports array syntax', () => { + expect(cn(['foo', 'bar'])).toBe('foo bar'); + }); + + it('merges tailwind background colors correctly (last wins)', () => { + expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500'); + }); +}); diff --git a/src/services/adb-streamer/credentials.test.ts b/src/services/adb-streamer/credentials.test.ts new file mode 100644 index 00000000..a6508828 --- /dev/null +++ b/src/services/adb-streamer/credentials.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + setCredentials, + getCredentials, + getAuthToken, + clearCredentials, +} from '@/services/adb-streamer/credentials'; + +// Clear module-level in-memory state and session storage before each test +beforeEach(() => { + clearCredentials(); + sessionStorage.clear(); + localStorage.clear(); +}); + +describe('setCredentials', () => { + it('returns a Base64 encoded token for provided username/password', () => { + const token = setCredentials('user', 'pass'); + expect(token).toBe(btoa('user:pass')); + }); + + it('stores token in session storage by default', () => { + setCredentials('alice', 'secret'); + // After setting, getAuthToken should return the in-memory token + expect(getAuthToken()).toBe(btoa('alice:secret')); + }); + + it('calls clearCredentials when both username and password are absent', () => { + setCredentials('user', 'pass'); + const result = setCredentials(undefined, undefined); + expect(result).toBeUndefined(); + expect(getCredentials()).toBeNull(); + }); + + it('returns undefined when both username and password are empty strings (treated as absent)', () => { + const result = setCredentials('', ''); + expect(result).toBeUndefined(); + }); +}); + +describe('getCredentials', () => { + it('returns null when nothing has been stored', () => { + expect(getCredentials()).toBeNull(); + }); + + it('returns in-memory credentials after setCredentials', () => { + setCredentials('bob', 'pw123'); + const creds = getCredentials(); + expect(creds).not.toBeNull(); + expect(creds!.username).toBe('bob'); + expect(creds!.token).toBe(btoa('bob:pw123')); + }); +}); + +describe('getAuthToken', () => { + it('returns undefined when no credentials are set', () => { + expect(getAuthToken()).toBeUndefined(); + }); + + it('returns the token from in-memory storage', () => { + setCredentials('carol', 'p@ssw0rd'); + expect(getAuthToken()).toBe(btoa('carol:p@ssw0rd')); + }); +}); + +describe('clearCredentials', () => { + it('clears in-memory credentials', () => { + setCredentials('dave', 'xyz'); + clearCredentials(); + expect(getCredentials()).toBeNull(); + }); + + it('clears session storage entries', () => { + setCredentials('eve', 'abc'); + clearCredentials(); + // After clearing, getCredentials should return null even if session is checked + expect(getCredentials()).toBeNull(); + }); +}); diff --git a/src/services/adb-streamer/http-client.test.ts b/src/services/adb-streamer/http-client.test.ts new file mode 100644 index 00000000..97cfc5b1 --- /dev/null +++ b/src/services/adb-streamer/http-client.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { setBackendBaseUrl, setAuthToken, apiClient } from '@/services/adb-streamer/http-client'; + +const originalBaseURL = apiClient.defaults.baseURL; + +beforeEach(() => { + // restore base URL and remove auth header between tests + apiClient.defaults.baseURL = originalBaseURL; + delete (apiClient.defaults.headers as Record)['Authorization']; +}); + +describe('setBackendBaseUrl', () => { + it('sets the base URL with /api suffix', () => { + setBackendBaseUrl('http://example.com'); + expect(apiClient.defaults.baseURL).toBe('http://example.com/api'); + }); + + it('handles a URL with trailing slash', () => { + setBackendBaseUrl('http://example.com/'); + expect(apiClient.defaults.baseURL).toBe('http://example.com/api'); + }); + + it('handles https scheme', () => { + setBackendBaseUrl('https://secure.host:8443'); + expect(apiClient.defaults.baseURL).toBe('https://secure.host:8443/api'); + }); + + it('converts ws:// to http://', () => { + setBackendBaseUrl('ws://ws-host:1234'); + expect(apiClient.defaults.baseURL).toBe('http://ws-host:1234/api'); + }); + + it('converts wss:// to https://', () => { + setBackendBaseUrl('wss://ws-host:1234'); + expect(apiClient.defaults.baseURL).toBe('https://ws-host:1234/api'); + }); + + it('sets Authorization header when credentials are embedded in URL', () => { + setBackendBaseUrl('http://user:pass@example.com'); + const auth = (apiClient.defaults.headers as Record)['Authorization'] as string; + expect(auth).toBe(`Basic ${btoa('user:pass')}`); + }); + + it('does not set Authorization header when no credentials in URL', () => { + setBackendBaseUrl('http://example.com'); + expect((apiClient.defaults.headers as Record)['Authorization']).toBeUndefined(); + }); + + it('throws on an invalid URL', () => { + expect(() => setBackendBaseUrl('not-a-url')).toThrow('Invalid URL'); + }); + + it('is a no-op for empty string', () => { + const before = apiClient.defaults.baseURL; + setBackendBaseUrl(''); + expect(apiClient.defaults.baseURL).toBe(before); + }); +}); + +describe('setAuthToken', () => { + it('sets Authorization header when token is provided', () => { + setAuthToken('Bearer mytoken'); + expect((apiClient.defaults.headers as Record)['Authorization']).toBe('Bearer mytoken'); + }); + + it('removes Authorization header when token is undefined', () => { + (apiClient.defaults.headers as Record)['Authorization'] = 'Bearer old'; + setAuthToken(undefined); + expect((apiClient.defaults.headers as Record)['Authorization']).toBeUndefined(); + }); +}); diff --git a/src/services/adb-streamer/storage.test.ts b/src/services/adb-streamer/storage.test.ts new file mode 100644 index 00000000..2236b72a --- /dev/null +++ b/src/services/adb-streamer/storage.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Storage } from '@/services/adb-streamer/storage'; + +describe('Storage (in-memory fallback, no window)', () => { + let store: Storage; + + beforeEach(() => { + store = new Storage(); + // Clear jsdom storage between tests (Storage constructor uses real + // localStorage/sessionStorage when window is defined) + localStorage.clear(); + sessionStorage.clear(); + }); + + describe('local storage', () => { + it('setLocal / getLocal round-trips a value', () => { + store.setLocal('myKey', 'myValue'); + expect(store.getLocal('myKey')).toBe('myValue'); + }); + + it('getLocal returns null for unknown key', () => { + expect(store.getLocal('missing')).toBeNull(); + }); + + it('removeLocal deletes the key', () => { + store.setLocal('k', 'v'); + store.removeLocal('k'); + expect(store.getLocal('k')).toBeNull(); + }); + }); + + describe('session storage', () => { + it('setSession / getSession round-trips a value', () => { + store.setSession('sk', 'sv'); + expect(store.getSession('sk')).toBe('sv'); + }); + + it('getSession returns null for unknown key', () => { + expect(store.getSession('unknown')).toBeNull(); + }); + + it('removeSession deletes the key', () => { + store.setSession('sk', 'sv'); + store.removeSession('sk'); + expect(store.getSession('sk')).toBeNull(); + }); + }); + + describe('get() priority: cookie > session > local', () => { + it('returns session value when no cookie is set', () => { + store.setSession('key', 'session-val'); + store.setLocal('key', 'local-val'); + expect(store.get('key')).toBe('session-val'); + }); + + it('returns local value when neither cookie nor session has the key', () => { + store.setLocal('key', 'local-only'); + expect(store.get('key')).toBe('local-only'); + }); + + it('returns null when no storage has the key', () => { + expect(store.get('no-such-key')).toBeNull(); + }); + }); + + describe('cookie operations (no document)', () => { + it('getCookie returns null when no cookie source is available', () => { + // In the in-memory fallback, cookieSource is undefined so no cookies + expect(store.getCookie('anything')).toBeNull(); + }); + + it('setCookie is a no-op when no cookie source is available', () => { + // Should not throw + expect(() => store.setCookie('key', 'val', 7)).not.toThrow(); + }); + }); +}); + +describe('Storage with real localStorage/sessionStorage (jsdom)', () => { + let store: Storage; + + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + store = new Storage(); + }); + + it('setLocal persists to window.localStorage', () => { + store.setLocal('lkey', 'lval'); + expect(localStorage.getItem('lkey')).toBe('lval'); + }); + + it('getLocal reads from window.localStorage', () => { + localStorage.setItem('lkey2', 'lval2'); + expect(store.getLocal('lkey2')).toBe('lval2'); + }); + + it('removeLocal removes from window.localStorage', () => { + localStorage.setItem('rmKey', 'rmVal'); + store.removeLocal('rmKey'); + expect(localStorage.getItem('rmKey')).toBeNull(); + }); + + it('setSession persists to window.sessionStorage', () => { + store.setSession('skey', 'sval'); + expect(sessionStorage.getItem('skey')).toBe('sval'); + }); + + it('getSession reads from window.sessionStorage', () => { + sessionStorage.setItem('skey2', 'sval2'); + expect(store.getSession('skey2')).toBe('sval2'); + }); + + it('removeSession removes from window.sessionStorage', () => { + sessionStorage.setItem('rmS', 'v'); + store.removeSession('rmS'); + expect(sessionStorage.getItem('rmS')).toBeNull(); + }); +}); diff --git a/src/services/adb-streamer/utils/file.test.js b/src/services/adb-streamer/utils/file.test.js new file mode 100644 index 00000000..e553355d --- /dev/null +++ b/src/services/adb-streamer/utils/file.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { getFileSizeInMb, getFileUrl } from '@/services/adb-streamer/utils/file.js'; + +describe('getFileSizeInMb', () => { + it('returns 0 when file has no size', () => { + expect(getFileSizeInMb({ size: 0 })).toBe(0); + expect(getFileSizeInMb({})).toBe(0); + }); + + it('returns an integer when the result is a whole number', () => { + const oneMb = 1 * 1024 ** 2; + const result = getFileSizeInMb({ size: oneMb }); + expect(result).toBe(1); + expect(Number.isInteger(result)).toBe(true); + }); + + it('returns a string with two decimal places for fractional sizes', () => { + const bytes = 1.5 * 1024 ** 2; // 1.5 MB + const result = getFileSizeInMb({ size: bytes }); + expect(result).toBe('1.50'); + }); + + it('handles files larger than 1 GB', () => { + const twoGb = 2 * 1024 ** 3; + const result = getFileSizeInMb({ size: twoGb }); + // 2 GB = 2048 MB (exact integer) + expect(result).toBe(2048); + }); +}); + +describe('getFileUrl', () => { + it('builds a proper file URL', () => { + expect(getFileUrl('https://api.example.com', '42')).toBe('https://api.example.com/api/file/42'); + }); + + it('works with numeric file IDs', () => { + expect(getFileUrl('https://host', 123)).toBe('https://host/api/file/123'); + }); +}); diff --git a/src/services/adb-streamer/utils/mapClientToDevicePosition.test.js b/src/services/adb-streamer/utils/mapClientToDevicePosition.test.js new file mode 100644 index 00000000..c49d1e27 --- /dev/null +++ b/src/services/adb-streamer/utils/mapClientToDevicePosition.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { mapClientToDevicePosition } from '@/services/adb-streamer/utils/mapClientToDevicePosition.js'; + +const makeRect = (x = 0, y = 0, width = 100, height = 200) => ({ x, y, width, height }); + +describe('mapClientToDevicePosition', () => { + it('maps a centred pointer correctly with rotation 0', () => { + const result = mapClientToDevicePosition({ + clientX: 50, + clientY: 100, + clientRect: makeRect(0, 0, 100, 200), + rotation: 0, + width: 1080, + height: 1920, + }); + expect(result.x).toBeCloseTo(540); + expect(result.y).toBeCloseTo(960); + }); + + it('maps top-left corner to (0, 0) with rotation 0', () => { + const result = mapClientToDevicePosition({ + clientX: 0, + clientY: 0, + clientRect: makeRect(0, 0, 100, 200), + rotation: 0, + width: 1080, + height: 1920, + }); + expect(result.x).toBe(0); + expect(result.y).toBe(0); + }); + + it('maps bottom-right corner to (width, height) with rotation 0', () => { + const result = mapClientToDevicePosition({ + clientX: 100, + clientY: 200, + clientRect: makeRect(0, 0, 100, 200), + rotation: 0, + width: 1080, + height: 1920, + }); + expect(result.x).toBe(1080); + expect(result.y).toBe(1920); + }); + + it('clamps pointer outside the client rect', () => { + const result = mapClientToDevicePosition({ + clientX: -50, // outside left edge + clientY: 300, // outside bottom edge + clientRect: makeRect(0, 0, 100, 200), + rotation: 0, + width: 1080, + height: 1920, + }); + expect(result.x).toBe(0); + expect(result.y).toBe(1920); + }); + + it('swaps x/y axes for rotation 1 (90°)', () => { + // With rotation 1, axes are swapped and the new Y is flipped. + // viewX = 25/100 = 0.25, viewY = 50/200 = 0.25 + // After swap: viewX = 0.25, viewY = 0.25 + // case 1: viewY = 1 - 0.25 = 0.75 + // result: x = 0.25 * 1080 = 270, y = 0.75 * 1920 = 1440 + const result = mapClientToDevicePosition({ + clientX: 25, + clientY: 50, + clientRect: makeRect(0, 0, 100, 200), + rotation: 1, + width: 1080, + height: 1920, + }); + expect(result.x).toBeCloseTo(270); + expect(result.y).toBeCloseTo(1440); + }); + + it('inverts both axes for rotation 2 (180°)', () => { + const result = mapClientToDevicePosition({ + clientX: 25, + clientY: 50, + clientRect: makeRect(0, 0, 100, 200), + rotation: 2, + width: 1080, + height: 1920, + }); + // viewX=0.25→1-0.25=0.75; viewY=0.25→1-0.25=0.75 + expect(result.x).toBeCloseTo(0.75 * 1080); + expect(result.y).toBeCloseTo(0.75 * 1920); + }); + + it('handles a non-zero clientRect offset', () => { + // rect starts at (10, 20) + const result = mapClientToDevicePosition({ + clientX: 60, + clientY: 120, + clientRect: makeRect(10, 20, 100, 200), + rotation: 0, + width: 1000, + height: 2000, + }); + // (60-10)/100 = 0.5, (120-20)/200 = 0.5 + expect(result.x).toBeCloseTo(500); + expect(result.y).toBeCloseTo(1000); + }); +}); diff --git a/src/services/adb-streamer/utils/rules.test.js b/src/services/adb-streamer/utils/rules.test.js new file mode 100644 index 00000000..7a4926fd --- /dev/null +++ b/src/services/adb-streamer/utils/rules.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { rules } from '@/services/adb-streamer/utils/rules.js'; + +describe('rules.required', () => { + it('returns a passing result for a non-empty value', () => { + const [validate] = rules.required('Field'); + expect(validate('hello')).toBe(true); + }); + + it('returns an error message for an empty value', () => { + const [validate] = rules.required('Username'); + expect(validate('')).toBe('Username is required'); + }); + + it('returns an error message for undefined', () => { + const [validate] = rules.required('Email'); + expect(validate(undefined)).toBe('Email is required'); + }); +}); + +describe('rules.name', () => { + const [required, minChars, maxChars, allowedChars] = rules.name('Name'); + + it('passes for a valid name', () => { + expect(required('valid-name')).toBe(true); + expect(minChars('valid-name')).toBe(true); + expect(maxChars('valid')).toBe(true); + expect(allowedChars('valid-name')).toBe(true); + }); + + it('required: fails for empty value', () => { + expect(required('')).toContain('required'); + }); + + it('minChars: fails when fewer than 4 characters', () => { + expect(minChars('ab')).toContain('greater than or equal to'); + }); + + it('maxChars: fails when more than 20 characters', () => { + expect(maxChars('a'.repeat(21))).toContain('less than'); + }); + + it('allowedChars: fails for uppercase letters', () => { + expect(allowedChars('Invalid')).toContain('Unallowed characters'); + }); + + it('allowedChars: fails for special characters', () => { + expect(allowedChars('hello world')).toContain('Unallowed characters'); + }); + + it('allowedChars: passes for lowercase-alphanumeric with hyphens', () => { + expect(allowedChars('my-app-123')).toBe(true); + }); +}); + +describe('rules.email', () => { + const [required, emailFormat] = rules.email; + + it('required: fails for empty value', () => { + expect(required('')).toContain('required'); + }); + + it('emailFormat: passes for a valid email', () => { + expect(emailFormat('user@example.com')).toBe(true); + }); + + it('emailFormat: fails for a string without @', () => { + expect(emailFormat('notanemail')).toContain('Invalid email address'); + }); +}); + +describe('rules.password', () => { + const [required, minChars] = rules.password; + + it('required: fails for empty value', () => { + expect(required('')).toContain('required'); + }); + + it('minChars: fails for fewer than 4 characters', () => { + expect(minChars('abc')).toContain('greater than or equal to'); + }); + + it('minChars: passes for 4+ characters', () => { + expect(minChars('abcd')).toBe(true); + }); +}); + +describe('rules.nekoPassword', () => { + const [required, minChars] = rules.nekoPassword; + + it('required: fails for empty value', () => { + expect(required('')).toContain('required'); + }); + + it('minChars: passes for 4+ characters', () => { + expect(minChars('abcd')).toBe(true); + }); +}); diff --git a/src/stores/adb.test.ts b/src/stores/adb.test.ts new file mode 100644 index 00000000..dbc4eef1 --- /dev/null +++ b/src/stores/adb.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useAdbStore } from '@/stores/adb'; + +// Mock the adbService so the store can be tested without HTTP calls +vi.mock('@/services/adb-streamer/adb/adb-service.ts', () => ({ + adbService: { + metainfo: vi.fn(), + }, +})); + +import { adbService } from '@/services/adb-streamer/adb/adb-service.ts'; + +const mockedMetainfo = vi.mocked(adbService.metainfo); + +const INITIAL_STATE = { + features: [], + devices: [], + device: null, + display: null, + audioEncoder: 'raw', + videoEncoder: null, +}; + +beforeEach(() => { + useAdbStore.setState(INITIAL_STATE); + vi.clearAllMocks(); +}); + +describe('useAdbStore – initial state', () => { + it('has correct defaults', () => { + const state = useAdbStore.getState(); + expect(state.features).toEqual([]); + expect(state.devices).toEqual([]); + expect(state.device).toBeNull(); + expect(state.display).toBeNull(); + expect(state.audioEncoder).toBe('raw'); + expect(state.videoEncoder).toBeNull(); + }); +}); + +describe('useAdbStore.metainfo', () => { + it('populates devices, features, device and display from API response', async () => { + mockedMetainfo.mockResolvedValue({ + data: { + features: ['feature-a', 'feature-b'], + devices: [ + { + name: 'device-1', + serial: 'SERIAL1', + displays: [{ id: 0, resolution: '1080x1920' }], + encoders: [], + }, + ], + }, + } as any); + + await useAdbStore.getState().metainfo(); + const state = useAdbStore.getState(); + + expect(state.features).toEqual(['feature-a', 'feature-b']); + expect(state.devices).toHaveLength(1); + expect(state.device).toBe('device-1'); + expect(state.display).toBe('0'); + }); + + it('falls back to serial when name is absent', async () => { + mockedMetainfo.mockResolvedValue({ + data: { + features: [], + devices: [ + { + serial: 'SERIAL-ONLY', + displays: [], + encoders: [], + }, + ], + }, + } as any); + + await useAdbStore.getState().metainfo(); + expect(useAdbStore.getState().device).toBe('SERIAL-ONLY'); + }); + + it('handles empty device list gracefully', async () => { + mockedMetainfo.mockResolvedValue({ data: { features: [], devices: [] } } as any); + + await useAdbStore.getState().metainfo(); + const state = useAdbStore.getState(); + expect(state.device).toBeNull(); + expect(state.display).toBeNull(); + }); + + it('handles null/undefined API response gracefully', async () => { + mockedMetainfo.mockResolvedValue(null as any); + + await useAdbStore.getState().metainfo(); + const state = useAdbStore.getState(); + expect(state.features).toEqual([]); + expect(state.devices).toEqual([]); + }); +}); + +describe('useAdbStore.deviceObj', () => { + it('returns undefined when devices list is empty', () => { + expect(useAdbStore.getState().deviceObj()).toBeUndefined(); + }); + + it('finds device by name', () => { + useAdbStore.setState({ + devices: [{ name: 'dev-a', displays: [], encoders: [] }], + device: 'dev-a', + }); + expect(useAdbStore.getState().deviceObj()?.name).toBe('dev-a'); + }); + + it('finds device by serial as fallback', () => { + useAdbStore.setState({ + devices: [{ serial: 'S123', displays: [], encoders: [] }], + device: 'S123', + }); + expect(useAdbStore.getState().deviceObj()?.serial).toBe('S123'); + }); +}); + +describe('useAdbStore.displayObj', () => { + it('returns undefined when no device is selected', () => { + expect(useAdbStore.getState().displayObj()).toBeUndefined(); + }); + + it('returns the correct display when device and display are set', () => { + useAdbStore.setState({ + devices: [ + { + name: 'dev-a', + displays: [ + { id: 0, resolution: '1080x1920' }, + { id: 1, resolution: '720x1280' }, + ], + encoders: [], + }, + ], + device: 'dev-a', + display: '1', + }); + const disp = useAdbStore.getState().displayObj(); + expect(disp?.resolution).toBe('720x1280'); + }); + + it('coerces numeric display ids (server sends number)', () => { + useAdbStore.setState({ + devices: [ + { + name: 'dev-a', + displays: [{ id: 2, resolution: '800x600' }], + encoders: [], + }, + ], + device: 'dev-a', + display: '2', + }); + expect(useAdbStore.getState().displayObj()?.resolution).toBe('800x600'); + }); +}); + +describe('useAdbStore.displaySize', () => { + it('returns {width:0, height:0} when no display selected', () => { + expect(useAdbStore.getState().displaySize()).toEqual({ width: 0, height: 0 }); + }); + + it('parses resolution string correctly', () => { + useAdbStore.setState({ + devices: [ + { + name: 'dev-a', + displays: [{ id: 0, resolution: '1920x1080' }], + encoders: [], + }, + ], + device: 'dev-a', + display: '0', + }); + expect(useAdbStore.getState().displaySize()).toEqual({ width: 1920, height: 1080 }); + }); +}); + +describe('useAdbStore.audioEncoders', () => { + it('always includes off and raw encoders', () => { + const encoders = useAdbStore.getState().audioEncoders(); + const ids = encoders.map((e) => e.id); + expect(ids).toContain('off'); + expect(ids).toContain('raw'); + }); + + it('includes device audio encoders when a device is selected', () => { + useAdbStore.setState({ + devices: [ + { + name: 'dev-a', + displays: [], + encoders: [{ type: 'audio', id: 'aac1', codec: 'aac', name: 'aac-enc' }], + }, + ], + device: 'dev-a', + }); + const encoders = useAdbStore.getState().audioEncoders(); + const ids = encoders.map((e) => e.id); + expect(ids).toContain('aac-enc'); // name is used as id + }); +}); + +describe('useAdbStore.videoEncoders', () => { + it('always includes off encoder', () => { + const encoders = useAdbStore.getState().videoEncoders(); + expect(encoders[0].id).toBe('off'); + }); + + it('creates TinyH264 and WebCodecs variants for h264 encoders', () => { + useAdbStore.setState({ + devices: [ + { + name: 'dev-a', + displays: [], + encoders: [{ type: 'video', id: 'v1', codec: 'h264', name: 'h264-enc' }], + }, + ], + device: 'dev-a', + videoEncoder: null, + }); + const encoders = useAdbStore.getState().videoEncoders(); + const ids = encoders.map((e) => e.id); + expect(ids).toContain('TinyH264@h264-enc'); + expect(ids).toContain('WebCodecs@h264-enc'); + }); + + it('uses WebCodecs only for non-h264 codecs', () => { + useAdbStore.setState({ + devices: [ + { + name: 'dev-a', + displays: [], + encoders: [{ type: 'video', id: 'v1', codec: 'av1', name: 'av1-enc' }], + }, + ], + device: 'dev-a', + videoEncoder: null, + }); + const encoders = useAdbStore.getState().videoEncoders(); + const ids = encoders.map((e) => e.id); + expect(ids).toContain('WebCodecs@av1-enc'); + expect(ids).not.toContain('TinyH264@av1-enc'); + }); + + it('auto-selects TinyH264 as default video encoder when videoEncoder is null', () => { + useAdbStore.setState({ + devices: [ + { + name: 'dev-a', + displays: [], + encoders: [{ type: 'video', id: 'v1', codec: 'h264', name: 'h264-enc' }], + }, + ], + device: 'dev-a', + videoEncoder: null, + }); + useAdbStore.getState().videoEncoders(); // trigger side effect + expect(useAdbStore.getState().videoEncoder).toBe('TinyH264@h264-enc'); + }); +}); diff --git a/src/stores/file.test.ts b/src/stores/file.test.ts new file mode 100644 index 00000000..d7f03b9b --- /dev/null +++ b/src/stores/file.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useFileStore } from '@/stores/file'; + +vi.mock('@/services/adb-streamer/file/file-service.ts', () => ({ + fileUploadService: { + upload: vi.fn(), + getUploads: vi.fn(), + getApps: vi.fn(), + }, +})); + +import { fileUploadService } from '@/services/adb-streamer/file/file-service.ts'; + +const mockedUpload = vi.mocked(fileUploadService.upload); +const mockedGetUploads = vi.mocked(fileUploadService.getUploads); +const mockedGetApps = vi.mocked(fileUploadService.getApps); + +beforeEach(() => { + useFileStore.setState({ files: [], apps: [] }); + vi.clearAllMocks(); +}); + +describe('useFileStore – initial state', () => { + it('starts with empty files and apps', () => { + const { files, apps } = useFileStore.getState(); + expect(files).toEqual([]); + expect(apps).toEqual([]); + }); +}); + +describe('useFileStore.setFiles', () => { + it('updates the files list', () => { + useFileStore.getState().setFiles(['a.apk', 'b.apk']); + expect(useFileStore.getState().files).toEqual(['a.apk', 'b.apk']); + }); +}); + +describe('useFileStore.setApps', () => { + it('updates the apps list', () => { + useFileStore.getState().setApps(['app1', 'app2']); + expect(useFileStore.getState().apps).toEqual(['app1', 'app2']); + }); +}); + +describe('useFileStore.upload', () => { + it('stores returned file list and returns the result', async () => { + mockedUpload.mockResolvedValue({ data: ['uploaded.apk'] } as any); + + const fakeFile = new File(['content'], 'test.apk'); + const fileList = [fakeFile] as unknown as FileList; + const result = await useFileStore.getState().upload(fileList); + + expect(mockedUpload).toHaveBeenCalledWith(fileList, {}); + expect(useFileStore.getState().files).toEqual(['uploaded.apk']); + expect(result.data).toEqual(['uploaded.apk']); + }); + + it('does not update files when API returns no data', async () => { + mockedUpload.mockResolvedValue({ data: null } as any); + + const fileList = [] as unknown as FileList; + await useFileStore.getState().upload(fileList); + + expect(useFileStore.getState().files).toEqual([]); + }); +}); + +describe('useFileStore.getUplaods', () => { + it('updates files from API response', async () => { + mockedGetUploads.mockResolvedValue({ data: ['remote.apk'] } as any); + + await useFileStore.getState().getUplaods(); + + expect(useFileStore.getState().files).toEqual(['remote.apk']); + }); + + it('does not update files when API returns no data', async () => { + mockedGetUploads.mockResolvedValue({ data: null } as any); + + await useFileStore.getState().getUplaods(); + + expect(useFileStore.getState().files).toEqual([]); + }); +}); + +describe('useFileStore.getApps', () => { + it('updates apps from API response', async () => { + mockedGetApps.mockResolvedValue({ data: ['com.example.app'] } as any); + + await useFileStore.getState().getApps(); + + expect(useFileStore.getState().apps).toEqual(['com.example.app']); + }); + + it('does not update apps when API returns no data', async () => { + mockedGetApps.mockResolvedValue({ data: null } as any); + + await useFileStore.getState().getApps(); + + expect(useFileStore.getState().apps).toEqual([]); + }); +}); diff --git a/src/stores/toast.test.ts b/src/stores/toast.test.ts new file mode 100644 index 00000000..b8d4f00f --- /dev/null +++ b/src/stores/toast.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useToastStore, ToastType } from '@/stores/toast'; + +// Ensure Date.now() produces distinct IDs even within the same millisecond +let nowCounter = 0; +vi.spyOn(Date, 'now').mockImplementation(() => ++nowCounter * 1000); + +// Reset store state before each test +beforeEach(() => { + useToastStore.setState({ toasts: [] }); +}); + +describe('ToastType constant', () => { + it('has correct values', () => { + expect(ToastType.info).toBe('info'); + expect(ToastType.success).toBe('success'); + expect(ToastType.warning).toBe('warning'); + expect(ToastType.error).toBe('error'); + }); +}); + +describe('useToastStore.add', () => { + it('adds a toast with default info type', () => { + const { add } = useToastStore.getState(); + const toast = add({ message: 'Hello', timeout: 3000 }); + + expect(toast.type).toBe(ToastType.info); + expect(toast.messages).toEqual(['Hello']); + expect(toast.timeout).toBe(3000); + expect(toast.active).toBe(true); + expect(typeof toast._id).toBe('number'); + + const { toasts } = useToastStore.getState(); + expect(toasts).toHaveLength(1); + expect(toasts[0]).toEqual(toast); + }); + + it('adds multiple toasts', () => { + const { add } = useToastStore.getState(); + add({ message: 'First', timeout: 1000 }); + add({ message: 'Second', timeout: 2000 }); + + const { toasts } = useToastStore.getState(); + expect(toasts).toHaveLength(2); + expect(toasts[0].messages[0]).toBe('First'); + expect(toasts[1].messages[0]).toBe('Second'); + }); + + it('respects the provided type', () => { + const { add } = useToastStore.getState(); + const toast = add({ message: 'err', type: ToastType.error, timeout: 5000 }); + expect(toast.type).toBe(ToastType.error); + }); +}); + +describe('useToastStore.update', () => { + it('appends a message to the matched toast', () => { + const { add, update } = useToastStore.getState(); + const toast = add({ message: 'first', timeout: 1000 }); + update(toast._id, 'second'); + + const { toasts } = useToastStore.getState(); + expect(toasts[0].messages).toEqual(['first', 'second']); + }); + + it('does not affect other toasts', () => { + const { add, update } = useToastStore.getState(); + const t1 = add({ message: 'a', timeout: 1000 }); + const t2 = add({ message: 'b', timeout: 1000 }); + update(t1._id, 'updated'); + + const { toasts } = useToastStore.getState(); + expect(toasts.find((t) => t._id === t1._id)!.messages).toEqual(['a', 'updated']); + expect(toasts.find((t) => t._id === t2._id)!.messages).toEqual(['b']); + }); +}); + +describe('useToastStore.remove', () => { + it('removes the specified toast', () => { + const { add, remove } = useToastStore.getState(); + const toast = add({ message: 'bye', timeout: 1000 }); + remove(toast); + + const { toasts } = useToastStore.getState(); + expect(toasts).toHaveLength(0); + }); + + it('leaves other toasts intact', () => { + const { add, remove } = useToastStore.getState(); + const t1 = add({ message: 'keep', timeout: 1000 }); + const t2 = add({ message: 'remove', timeout: 1000 }); + remove(t2); + + const { toasts } = useToastStore.getState(); + expect(toasts).toHaveLength(1); + expect(toasts[0]._id).toBe(t1._id); + }); +}); + +describe('useToastStore convenience methods', () => { + it('success() creates a success toast', () => { + const toast = useToastStore.getState().success('All good'); + expect(toast.type).toBe(ToastType.success); + expect(toast.messages[0]).toBe('All good'); + expect(toast.timeout).toBe(5000); + }); + + it('success() accepts a custom duration', () => { + const toast = useToastStore.getState().success('ok', 1000); + expect(toast.timeout).toBe(1000); + }); + + it('info() creates an info toast', () => { + const toast = useToastStore.getState().info('FYI'); + expect(toast.type).toBe(ToastType.info); + expect(toast.messages[0]).toBe('FYI'); + }); + + it('warning() creates a warning toast', () => { + const toast = useToastStore.getState().warning('Careful'); + expect(toast.type).toBe(ToastType.warning); + expect(toast.messages[0]).toBe('Careful'); + }); + + it('error() creates an error toast with a custom message', () => { + const toast = useToastStore.getState().error('Oops'); + expect(toast.type).toBe(ToastType.error); + expect(toast.messages[0]).toBe('Oops'); + }); + + it('error() uses the default message when none provided', () => { + const toast = useToastStore.getState().error(); + expect(toast.type).toBe(ToastType.error); + expect(toast.messages[0]).toBe('Something went wrong. Please try again later'); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe2..9896c472 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/vite.config.ts b/vite.config.ts index 2374726d..ba12ab56 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,18 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx,js,jsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.{ts,tsx,js,jsx}'], + exclude: ['src/__generated__/**', 'src/test/**', 'src/main.tsx', 'src/vite-env.d.ts'], + }, + }, build: { outDir: 'build', }, From f275122d3287be2e3d1152201e4ab3ca150ba4d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:53:45 +0000 Subject: [PATCH 2/2] fix: restore vite/client type reference and clarify getUplaods describe label Agent-Logs-Url: https://github.com/FirmwareDroid/FMD-WebClient/sessions/7967cd53-e804-4b79-8192-8b1cdf51a6db Co-authored-by: 7homasSutter <9306853+7homasSutter@users.noreply.github.com> --- src/stores/file.test.ts | 2 +- src/vite-env.d.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/stores/file.test.ts b/src/stores/file.test.ts index d7f03b9b..45efb1fe 100644 --- a/src/stores/file.test.ts +++ b/src/stores/file.test.ts @@ -65,7 +65,7 @@ describe('useFileStore.upload', () => { }); }); -describe('useFileStore.getUplaods', () => { +describe('useFileStore.getUplaods (getUploads)', () => { it('updates files from API response', async () => { mockedGetUploads.mockResolvedValue({ data: ['remote.apk'] } as any); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 9896c472..97ce4524 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,2 @@ +/// ///