From d11cf6ccef9a370bb74b74e9725fdd6b6cf3737c Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 19 May 2026 01:17:08 +0200 Subject: [PATCH] feat(fase-17): ChangelogModal component + unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangelogModal renders the repo-root CHANGELOG.md (bundled at build time via Vite's ?raw query, 5 levels up from the component file) in an overlay+panel modal. Markdown is parsed inline (`#`/`##`/`###` headers, `-`/`*` lists, `**bold**`) with HTML-escape on every input line — input is static repo content so the surface is closed. Esc and overlay-click both call `onClose`. The keydown listener is attached only while `open` is true via a $effect. Three new Paraglide messages (en/es): `settings_about_view_changelog`, `changelog_modal_title`, `changelog_modal_close`. Unit tests (CHM-U-01..04) follow the Spinner.test.ts pattern (Svelte 5 mount/unmount + flushSync for the Escape spec). Co-Authored-By: Claude Opus 4.7 --- apps/web/messages/en.json | 3 + apps/web/messages/es.json | 3 + .../src/lib/components/ChangelogModal.svelte | 174 ++++++++++++++++++ .../src/lib/components/ChangelogModal.test.ts | 77 ++++++++ 4 files changed, 257 insertions(+) create mode 100644 apps/web/src/lib/components/ChangelogModal.svelte create mode 100644 apps/web/src/lib/components/ChangelogModal.test.ts 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); + }); +});