diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a01d1ce..f9f11fb 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -180,6 +180,9 @@ "sync_conflicts_back": "Back to settings", "settings_about": "About", "settings_about_version": "v{version} · {commit} · {date}", + "settings_about_view_changelog": "View changelog", + "changelog_modal_title": "Changelog", + "changelog_modal_close": "Close", "sync_offline": "You're offline — changes will sync when you reconnect", "sync_syncing": "Syncing…", "undo": "Undo", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 625bfb9..d279f27 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -180,6 +180,9 @@ "sync_conflicts_back": "Volver a ajustes", "settings_about": "Acerca de", "settings_about_version": "v{version} · {commit} · {date}", + "settings_about_view_changelog": "Ver historial", + "changelog_modal_title": "Historial de cambios", + "changelog_modal_close": "Cerrar", "sync_offline": "Sin conexión — los cambios se sincronizarán al reconectar", "sync_syncing": "Sincronizando…", "undo": "Deshacer", diff --git a/apps/web/src/lib/components/ChangelogModal.svelte b/apps/web/src/lib/components/ChangelogModal.svelte new file mode 100644 index 0000000..59608e2 --- /dev/null +++ b/apps/web/src/lib/components/ChangelogModal.svelte @@ -0,0 +1,174 @@ + + + +{#if open} + +{/if} diff --git a/apps/web/src/lib/components/ChangelogModal.test.ts b/apps/web/src/lib/components/ChangelogModal.test.ts new file mode 100644 index 0000000..225e324 --- /dev/null +++ b/apps/web/src/lib/components/ChangelogModal.test.ts @@ -0,0 +1,77 @@ +/** + * Fase 17.4.1 — ChangelogModal unit tests. + * + * Mirrors the Spinner.test.ts pattern (Svelte 5 `mount`/`unmount`, no + * @testing-library dependency). The modal imports CHANGELOG.md via Vite's + * `?raw` query — vitest runs against the same Vite config (see + * apps/web/vitest.config.ts) so the import resolves to the file contents at + * test time. + * + * Specs: + * CHM-U-01 — `open={false}` renders nothing. + * CHM-U-02 — `open={true}` renders the CHANGELOG `

`. + * CHM-U-03 — click on the overlay fires `onClose`. + * CHM-U-04 — pressing Escape fires `onClose`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import ChangelogModal from './ChangelogModal.svelte'; + +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(ChangelogModal, { target: host, props }); + return host; +} + +beforeEach(() => { + host = document.createElement('div'); +}); + +afterEach(() => { + if (instance) unmount(instance); + if (host?.parentNode) host.parentNode.removeChild(host); + instance = null; +}); + +describe('ChangelogModal component', () => { + it('CHM-U-01: open=false renders nothing visible', () => { + const root = render({ open: false, onClose: () => {} }); + expect(root.querySelector('[data-testid="changelog-modal"]')).toBeNull(); + }); + + it('CHM-U-02: open=true renders the CHANGELOG h1 heading', () => { + const root = render({ open: true, onClose: () => {} }); + const modal = root.querySelector('[data-testid="changelog-modal"]'); + expect(modal).not.toBeNull(); + const h1 = modal?.querySelector('h1'); + expect(h1).not.toBeNull(); + expect(h1?.textContent?.trim()).toBe('Changelog'); + }); + + it('CHM-U-03: click on overlay fires onClose', () => { + const onClose = vi.fn(); + const root = render({ open: true, onClose }); + const overlay = root.querySelector('[data-testid="changelog-modal-overlay"]') as + | HTMLElement + | null; + expect(overlay).not.toBeNull(); + overlay?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('CHM-U-04: Escape key fires onClose', () => { + const onClose = vi.fn(); + render({ open: true, onClose }); + // $effect runs in a microtask after mount. flushSync drains pending + // reactive effects so the keydown listener is attached before we + // dispatch the event. + flushSync(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +});