diff --git a/apps/web/src/lib/components/Spinner.svelte b/apps/web/src/lib/components/Spinner.svelte new file mode 100644 index 0000000..cee78eb --- /dev/null +++ b/apps/web/src/lib/components/Spinner.svelte @@ -0,0 +1,34 @@ + + +
+ + + + {m.loading()} +
diff --git a/apps/web/src/lib/components/Spinner.test.ts b/apps/web/src/lib/components/Spinner.test.ts new file mode 100644 index 0000000..3e101f9 --- /dev/null +++ b/apps/web/src/lib/components/Spinner.test.ts @@ -0,0 +1,79 @@ +/** + * Fase 16 — Spinner component unit tests. + * + * Vitest + jsdom + Svelte 5's `mount()` API. There is no @testing-library/svelte + * in the repo (yet), but Svelte 5 exposes `mount`/`unmount` directly so we can + * inspect the rendered DOM without an extra dependency. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mount, unmount } from 'svelte'; +import Spinner from './Spinner.svelte'; +import * as m from '$lib/paraglide/messages'; + +let host: HTMLElement; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let instance: any; + +function render(props: Record = {}) { + host = document.createElement('div'); + document.body.appendChild(host); + instance = mount(Spinner, { target: host, props }); + return host; +} + +beforeEach(() => { + host = document.createElement('div'); +}); + +afterEach(() => { + if (instance) unmount(instance); + if (host?.parentNode) host.parentNode.removeChild(host); +}); + +describe('Spinner component', () => { + it('SP-U-01: default size renders a 24x24 SVG', () => { + const root = render(); + const svg = root.querySelector('svg[data-testid="spinner"]'); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('width')).toBe('24'); + expect(svg?.getAttribute('height')).toBe('24'); + }); + + it("SP-U-02: size='sm' renders 16x16 and size='lg' renders 40x40", () => { + const smRoot = render({ size: 'sm' }); + const smSvg = smRoot.querySelector('svg[data-testid="spinner"]'); + expect(smSvg?.getAttribute('width')).toBe('16'); + expect(smSvg?.getAttribute('height')).toBe('16'); + unmount(instance); + instance = null; + smRoot.remove(); + + const lgRoot = render({ size: 'lg' }); + const lgSvg = lgRoot.querySelector('svg[data-testid="spinner"]'); + expect(lgSvg?.getAttribute('width')).toBe('40'); + expect(lgSvg?.getAttribute('height')).toBe('40'); + }); + + it('SP-U-03: outer wrapper exposes role="status" and aria-live="polite"', () => { + const root = render(); + const wrapper = root.querySelector('[role="status"]'); + expect(wrapper).not.toBeNull(); + expect(wrapper?.getAttribute('aria-live')).toBe('polite'); + }); + + it("SP-U-04: includes an sr-only label sourced from Paraglide m.loading()", () => { + const root = render(); + const srOnly = root.querySelector('.sr-only'); + expect(srOnly).not.toBeNull(); + // Paraglide's English default is "Loading…" — we don't hard-code it here, + // we compare against the message itself so an i18n switch doesn't break. + expect(srOnly?.textContent).toBe(m.loading()); + }); + + it('SP-U-05: forwards the `class` prop onto the wrapper element', () => { + const root = render({ class: 'text-amber-500 ml-2' }); + const wrapper = root.querySelector('[role="status"]'); + expect(wrapper?.className).toContain('text-amber-500'); + expect(wrapper?.className).toContain('ml-2'); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index a6c1733..9760ef3 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -16,9 +16,20 @@ export default defineConfig({ globals: true, include: ['src/**/*.test.ts'], exclude: ['tests/**', 'node_modules/**', '.svelte-kit/**'], - setupFiles: ['./vitest.setup.ts'] + setupFiles: ['./vitest.setup.ts'], + // Force Svelte to load its browser entry (index-client.js) instead of + // the server entry — `mount()` only exists on the client. Without this + // any test that mounts a Svelte 5 component throws + // `lifecycle_function_unavailable`. jsdom + the `browser` condition is + // the canonical Svelte 5 unit-test setup. + server: { + deps: { + inline: [/^svelte/] + } + } }, resolve: { + conditions: ['browser'], alias: { $lib: new URL('./src/lib', import.meta.url).pathname }