feat(fase-17): ChangelogModal component + unit tests

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 01:17:08 +02:00
parent cb223a0c29
commit d11cf6ccef
4 changed files with 257 additions and 0 deletions

View File

@@ -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 `<h1>`.
* 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<string, unknown>) {
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);
});
});