diff --git a/test/app-harness.ts b/test/app-harness.ts new file mode 100644 index 0000000..9a34cf6 --- /dev/null +++ b/test/app-harness.ts @@ -0,0 +1,302 @@ +/** + * Harness for the app entry (`src/main.ts`). + * + * `main.ts` is a side-effecting module: importing it wires the entire UI to + * whatever `document` is current, so a test cannot simply call into it. Each + * mount therefore rebuilds the DOM and re-imports the module (`vi.resetModules()` + * + dynamic import). The DOM comes from the real `index.html` rather than a + * restated fragment, so a control that `main.ts` looks up by id but `index.html` + * has lost fails loudly here instead of silently disabling a feature. + * + * What is faked, and why: + * - `Worker` — jsdom has none. `FakeWorker` records what `main.ts` posts and + * lets a test answer it, including the one-shot `ready` handshake, so the + * load-token, superseded-reply and watchdog paths can be driven exactly. + * `answer()` runs the real `detectAndParse`, i.e. the worker's own body. + * - `fetch` — bundled samples are read off disk from `public/`. + * - `localStorage` — undefined under this runner. `main.ts` optional-chains it, + * so without a stub the persistence paths would just be skipped. + * - `` — jsdom implements only `open`: no `showModal`, `close`, or + * `returnValue`. `patchDialog` adds the minimum, and `submitDialog` stands in + * for the browser's `form[method=dialog]` behaviour (take `returnValue` from + * the submit button, close, fire `close`). Dialog tests thus cover our own + * handlers, not the browser semantics they sit on. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { vi } from 'vitest'; +import { detectAndParse } from '../src/adapters/detect.js'; +import type { ParseRequest, ParseResponse } from '../src/adapters/parse.worker.js'; +import type { Model, ModelNode } from '../src/model/index.js'; + +const ROOT = process.cwd(); + +/** The real page body, minus the module script (we import that ourselves). */ +const INDEX_BODY = ((): string => { + const html = readFileSync(resolve(ROOT, 'index.html'), 'utf8'); + const body = html.slice(html.indexOf('') + ''.length, html.lastIndexOf('')); + return body.replace(//g, ''); +})(); + +/** Stand-in for the parse worker: records requests, replays replies on demand. */ +export class FakeWorker { + static instances: FakeWorker[] = []; + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: ((e: ErrorEvent) => void) | null = null; + onmessageerror: ((e: MessageEvent) => void) | null = null; + readonly requests: ParseRequest[] = []; + terminated = false; + + constructor(readonly url: URL | string, readonly options?: WorkerOptions) { + FakeWorker.instances.push(this); + } + + postMessage(req: ParseRequest): void { this.requests.push(req); } + terminate(): void { this.terminated = true; } + addEventListener(): void { /* main.ts uses the on* properties */ } + removeEventListener(): void { /* idem */ } + + get lastRequest(): ParseRequest | undefined { return this.requests.at(-1); } + + /** The one-shot handshake the real worker posts as soon as it evaluates. */ + ready(): void { this.deliver({ ready: true }); } + + /** Reply exactly as the real worker would: run the parser, report either the + * model or the error it threw. The reply is delivered OUTSIDE the try, so a + * failure in the app's own render path surfaces as a test error rather than + * being handed back as a bogus parse failure. */ + answer(req: ParseRequest | undefined = this.lastRequest): void { + if (!req) throw new Error('no parse request to answer'); + let res: ParseResponse; + try { + res = { id: req.id, ok: true, model: detectAndParse(req.filename, req.source) }; + } catch (err) { + res = { id: req.id, ok: false, error: (err as Error).message }; + } + this.deliver(res); + } + + /** Reply with a specific model, for cases the bundled samples don't cover. */ + respondModel(model: Model, req: ParseRequest | undefined = this.lastRequest): void { + this.deliver({ id: req!.id, ok: true, model } satisfies ParseResponse); + } + + /** Reply with a parse failure. */ + respondError(error: string, req: ParseRequest | undefined = this.lastRequest): void { + this.deliver({ id: req!.id, ok: false, error } satisfies ParseResponse); + } + + /** A runtime error inside the worker. */ + fail(message: string): void { + this.onerror?.({ message, preventDefault: () => { /* noop */ } } as unknown as ErrorEvent); + } + + /** A reply that could not be structured-cloned. */ + messageError(): void { this.onmessageerror?.({} as MessageEvent); } + + deliver(data: unknown): void { this.onmessage?.({ data } as MessageEvent); } +} + +/** Map-backed Storage, so the pane-width persistence paths actually run. */ +export class FakeStorage implements Storage { + readonly map = new Map(); + get length(): number { return this.map.size; } + getItem(k: string): string | null { return this.map.get(k) ?? null; } + setItem(k: string, v: string): void { this.map.set(k, String(v)); } + removeItem(k: string): void { this.map.delete(k); } + clear(): void { this.map.clear(); } + key(i: number): string | null { return [...this.map.keys()][i] ?? null; } +} + +/** jsdom has no layout, so it implements no scrolling. The tree scrolls the + * selected row into view on every selection, which is on the path of nearly + * every test here. */ +function patchScrollIntoView(): void { + if (typeof Element.prototype.scrollIntoView === 'function') return; + Element.prototype.scrollIntoView = function scrollIntoView(): void { /* no layout */ }; +} + +let dialogPatched = false; +/** jsdom's HTMLDialogElement carries only `open`; add the members main.ts uses. */ +function patchDialog(): void { + if (dialogPatched) return; + dialogPatched = true; + const proto = HTMLDialogElement.prototype as unknown as Record; + if (typeof proto.showModal === 'function') return; // a future jsdom implements it + Object.defineProperties(proto, { + returnValue: { value: '', writable: true, configurable: true }, + showModal: { value(this: HTMLDialogElement) { this.open = true; }, configurable: true }, + show: { value(this: HTMLDialogElement) { this.open = true; }, configurable: true }, + close: { + value(this: HTMLDialogElement, rv?: string) { + if (rv !== undefined) this.returnValue = rv; + this.open = false; + this.dispatchEvent(new Event('close')); + }, + configurable: true, + }, + }); +} + +/** + * Do what a browser does when a `form[method=dialog]` is submitted: adopt the + * submitter's value as `returnValue`, close, and fire `close`. `main.ts` reacts + * to that `close` event, which is the part under test. + */ +export function submitDialog(dialog: HTMLDialogElement, value: string): void { + dialog.returnValue = value; + dialog.close(); +} + +export interface MountOptions { + /** Page URL, for the `?sample=…&node=…` deep-link paths. */ + url?: string; + /** Deliver the worker's ready handshake (default true). False exercises the + * "worker never started" watchdog. */ + ready?: boolean; + /** Seed values for the fake localStorage, e.g. a remembered pane width. */ + storage?: Record; + /** What `window.confirm` returns for the large-file warning (default true). */ + confirm?: boolean; +} + +export interface MountedApp { + worker: FakeWorker; + storage: FakeStorage; + fetchMock: ReturnType; + confirmMock: ReturnType; + /** Element by id, asserting it exists (mirrors main.ts's own lookup). */ + $: (id: string) => T; + layout: HTMLElement; + status: () => string; + level: () => string | undefined; +} + +/** Build the page, install the fakes, and import `main.ts` fresh. */ +export async function mount(opts: MountOptions = {}): Promise { + patchDialog(); + patchScrollIntoView(); + history.replaceState(null, '', opts.url ?? '/'); + document.documentElement.removeAttribute('data-theme'); + document.body.innerHTML = INDEX_BODY; + + const storage = new FakeStorage(); + for (const [k, v] of Object.entries(opts.storage ?? {})) storage.setItem(k, v); + vi.stubGlobal('localStorage', storage); + + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const path = String(input).replace(/^\/+/, ''); + try { + const body = readFileSync(resolve(ROOT, 'public', path), 'utf8'); + return { ok: true, status: 200, text: async () => body } as unknown as Response; + } catch { + return { ok: false, status: 404, text: async () => '' } as unknown as Response; + } + }); + vi.stubGlobal('fetch', fetchMock); + + const confirmMock = vi.fn(() => opts.confirm ?? true); + vi.stubGlobal('confirm', confirmMock); + + FakeWorker.instances.length = 0; + vi.stubGlobal('Worker', FakeWorker); + + vi.resetModules(); + await import('../src/main.js'); + + const worker = FakeWorker.instances.at(-1); + if (!worker) throw new Error('main.ts did not create a parse worker'); + if (opts.ready !== false) worker.ready(); + + const $ = (id: string): T => { + const node = document.getElementById(id); + if (!node) throw new Error(`missing #${id}`); + return node as T; + }; + const statusEl = $('status'); + return { + worker, storage, fetchMock, confirmMock, $, + layout: document.querySelector('.layout')!, + status: () => statusEl.textContent ?? '', + level: () => statusEl.dataset.level, + }; +} + +/** Yield to the macrotask queue, letting fetch/FileReader callbacks run. Not for + * use under fake timers — advance those explicitly instead. */ +export const flush = (): Promise => new Promise((r) => { setTimeout(r, 0); }); + +/** Wait until `n` parse requests have reached the worker. `FileReader` delivers + * over several tasks in jsdom, so a single tick is not enough to see one. */ +export async function waitForRequests(app: MountedApp, n = 1): Promise { + await vi.waitFor(() => { + if (app.worker.requests.length < n) throw new Error(`only ${app.worker.requests.length} of ${n} requests`); + }); +} + +/** Load a bundled sample the way a user does, and let the worker answer it. */ +export async function loadSample(app: MountedApp, value: string): Promise { + const select = app.$('sample-select'); + select.value = value; + select.dispatchEvent(new Event('change')); + await flush(); + app.worker.answer(); +} + +/** + * Display `m` without going through a parser: paste a placeholder, then have the + * worker hand back the model. Lets a test pick the exact graph it needs instead + * of one a bundled sample happens to have. + */ +export async function showModel(app: MountedApp, m: Model = model()): Promise { + const dialog = app.$('paste-dialog'); + app.$('paste-btn').click(); + app.$('paste-text').value = 'placeholder'; + submitDialog(dialog, 'load'); + app.worker.respondModel(m); +} + +/** A File whose reported size is a lie, so the large-file warning can be + * triggered without allocating 50 MB. */ +export function bigFile(name: string, text: string, size: number): File { + const file = new File([text], name); + Object.defineProperty(file, 'size', { value: size }); + return file; +} + +/** Give a file input a FileList-ish; jsdom has no DataTransfer to build one. */ +export function setFiles(input: HTMLInputElement, files: File[]): void { + Object.defineProperty(input, 'files', { + configurable: true, + value: { ...files, length: files.length, item: (i: number) => files[i] ?? null }, + }); +} + +/** A drag/drop event carrying files; jsdom has neither DragEvent nor DataTransfer. */ +export function dragEvent(type: string, files: File[], relatedTarget: unknown = null): Event { + const ev = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(ev, { + dataTransfer: { value: { types: files.length ? ['Files'] : [], files } }, + relatedTarget: { value: relatedTarget }, + }); + return ev; +} + +export function key(el: EventTarget, k: string, init: KeyboardEventInit = {}): void { + el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init })); +} + +/** Minimal hand-built model, for shapes the bundled samples don't have. */ +export function model(over: Partial = {}): Model { + const node = (id: string, type = 'gaussian_dist'): ModelNode => + ({ id, blockName: id, kind: 'distribution', type, raw: { name: id } }); + return { + format: 'hs3', + meta: {}, + nodes: [node('alpha'), node('beta'), node('gamma', 'poisson_dist')], + edges: [{ from: 'alpha', to: 'beta', role: 'input', port: 'mean' }], + diagnostics: [], + roots: [], + ...over, + }; +} diff --git a/test/main-load.test.ts b/test/main-load.test.ts new file mode 100644 index 0000000..454f1e6 --- /dev/null +++ b/test/main-load.test.ts @@ -0,0 +1,363 @@ +/** + * App entry (`src/main.ts`), loading paths: samples, files, drag-and-drop, paste, + * the parse worker's replies, and the shareable URL. + * + * These are the paths that had no coverage at all, and where every bug found by + * hand-driving the built app actually lived: a failed load leaving the URL + * describing a model that is no longer on screen, a worker that never starts, + * out-of-order replies. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + mount, flush, waitForRequests, loadSample, showModel, bigFile, setFiles, dragEvent, submitDialog, +} from './app-harness.js'; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('startup', () => { + it('opens empty, with search disabled and an invitation to load', async () => { + const app = await mount(); + expect(app.status()).toBe('Load a model file, or pick a bundled sample. Press / to search nodes.'); + expect(app.$('format-badge').hidden).toBe(true); + expect(app.$('diag-btn').hidden).toBe(true); + expect(app.$('node-search').disabled).toBe(true); + expect(app.$('tree-pane').textContent).toContain('No model loaded'); + }); + + it('offers a one-click sample, since reading a model is the fastest explanation', async () => { + const app = await mount(); + const cta = app.$('dag-pane').querySelector('button'); + expect(cta?.textContent).toContain('Load sample'); + cta!.click(); + await flush(); + app.worker.answer(); + expect(app.$('format-badge').hidden).toBe(false); + }); + + it('populates the sample dropdown from the format registry, grouped by format', async () => { + const app = await mount(); + const select = app.$('sample-select'); + const groups = [...select.querySelectorAll('optgroup')].map((g) => g.label); + expect(groups).toEqual(['HS3', 'XS3', 'FlatPPL']); + expect(select.options.length).toBeGreaterThan(groups.length); + }); +}); + +describe('loading a bundled sample', () => { + it('fetches it, parses it, and reports what was loaded', async () => { + const app = await mount(); + await loadSample(app, 'hs3-gaussian'); + expect(app.fetchMock).toHaveBeenCalledWith('/samples/hs3_gaussian.hs3'); + expect(app.worker.lastRequest?.filename).toBe('hs3_gaussian.hs3'); + expect(app.$('format-badge').textContent).toBe('HS3'); + expect(app.status()).toMatch(/^HS3: \d+ nodes, \d+ edges/); + expect(app.level()).toBe('info'); + expect(app.$('node-search').disabled).toBe(false); + }); + + it('resets the dropdown, so re-picking the same sample fires change again', async () => { + const app = await mount(); + await loadSample(app, 'hs3-gaussian'); + expect(app.$('sample-select').value).toBe(''); + }); + + it('reports a fetch failure without clearing the screen', async () => { + const app = await mount(); + app.fetchMock.mockResolvedValue({ ok: false, status: 503, text: async () => '' } as unknown as Response); + const select = app.$('sample-select'); + select.value = 'hs3-gaussian'; + select.dispatchEvent(new Event('change')); + await flush(); + expect(app.worker.requests).toHaveLength(0); + expect(app.status()).toContain('HTTP 503'); + expect(app.level()).toBe('error'); + }); +}); + +describe('shareable URL', () => { + it('records the sample and the selected node', async () => { + const app = await mount(); + await loadSample(app, 'hs3-gaussian'); + const url = new URL(location.href); + expect(url.searchParams.get('sample')).toBe('hs3-gaussian'); + expect(url.searchParams.get('node')).toBeTruthy(); + }); + + it('restores a deep link, overriding the default root focus', async () => { + const app = await mount({ url: '/?sample=hs3-gaussian&node=sigma' }); + await flush(); + app.worker.answer(); + expect(app.$('inspector-pane').textContent).toContain('sigma'); + expect(new URL(location.href).searchParams.get('node')).toBe('sigma'); + }); + + it('says so when a linked node is not in the model', async () => { + const app = await mount({ url: '/?sample=hs3-gaussian&node=not_a_node' }); + await flush(); + app.worker.answer(); + expect(app.status()).toContain('Linked node "not_a_node" is not in this model'); + expect(app.level()).toBe('error'); + }); + + it('says so when the linked sample is unknown, instead of failing silently', async () => { + const app = await mount({ url: '/?sample=nope' }); + expect(app.status()).toContain('Unknown sample "nope"'); + expect(app.fetchMock).not.toHaveBeenCalled(); + }); + + it('drops the parameters for a pasted model, which no URL can restore', async () => { + const app = await mount(); + await loadSample(app, 'hs3-gaussian'); + expect(location.search).toContain('sample='); + await showModel(app); + expect(location.search).toBe(''); + }); +}); + +describe('a parse that fails', () => { + it('keeps the model on screen and says the failure changed nothing', async () => { + const app = await mount(); + await loadSample(app, 'hs3-gaussian'); + const before = app.$('tree-pane').innerHTML; + + app.$('paste-btn').click(); + app.$('paste-text').value = 'not a model'; + submitDialog(app.$('paste-dialog'), 'load'); + app.worker.answer(); + + expect(app.status()).toContain('Could not load "pasted model"'); + expect(app.status()).toContain('the loaded model is unchanged'); + expect(app.level()).toBe('error'); + expect(app.$('tree-pane').innerHTML).toBe(before); + }); + + /** + * Regression: the sample was recorded when a load STARTED, so after a failed + * load the next node click rewrote the URL for the failed load — stripping + * `?sample=&node=` while the previous model was still on screen, leaving no + * link to what the user was reading. + */ + it('leaves the previous model still linkable', async () => { + const app = await mount(); + await loadSample(app, 'hs3-gaussian'); + + app.$('paste-btn').click(); + app.$('paste-text').value = 'not a model'; + submitDialog(app.$('paste-dialog'), 'load'); + app.worker.answer(); + + const search = app.$('node-search'); + search.value = 'sigma'; + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + const url = new URL(location.href); + expect(url.searchParams.get('sample')).toBe('hs3-gaussian'); + expect(url.searchParams.get('node')).toBe('sigma'); + }); + + it('falls back to the empty state when there was nothing to keep', async () => { + const app = await mount(); + app.$('paste-btn').click(); + app.$('paste-text').value = 'not a model'; + submitDialog(app.$('paste-dialog'), 'load'); + app.worker.answer(); + expect(app.status()).toContain('Could not load "pasted model"'); + expect(app.status()).not.toContain('unchanged'); + expect(app.$('tree-pane').textContent).toContain('No model loaded'); + }); +}); + +describe('the parse worker', () => { + it('ignores a reply that a newer load has superseded', async () => { + const app = await mount(); + const select = app.$('sample-select'); + select.value = 'hs3-gaussian'; + select.dispatchEvent(new Event('change')); + await flush(); + select.value = 'flatppl-poisson'; + select.dispatchEvent(new Event('change')); + await flush(); + expect(app.worker.requests).toHaveLength(2); + + // The stale reply arrives last, as a slow parse would. + app.worker.answer(app.worker.requests[0]); + expect(app.$('format-badge').hidden).toBe(true); + + app.worker.answer(app.worker.requests[1]); + expect(app.$('format-badge').textContent).toBe('FlatPPL'); + }); + + it('reports a worker that never starts, instead of parsing forever', async () => { + const app = await mount({ ready: false }); + vi.useFakeTimers(); + const select = app.$('sample-select'); + select.value = 'hs3-gaussian'; + select.dispatchEvent(new Event('change')); + await vi.advanceTimersByTimeAsync(1); + expect(app.status()).toContain('Parsing'); + + await vi.advanceTimersByTimeAsync(8000); + expect(app.status()).toContain('The model parser could not run (it did not start)'); + expect(app.level()).toBe('error'); + }); + + it('does not time out a slow parse once the worker has reported ready', async () => { + const app = await mount(); // ready delivered + vi.useFakeTimers(); + const select = app.$('sample-select'); + select.value = 'hs3-gaussian'; + select.dispatchEvent(new Event('change')); + await vi.advanceTimersByTimeAsync(30_000); + expect(app.status()).toContain('Parsing'); + expect(app.status()).not.toContain('could not run'); + }); + + it('reports a runtime error inside the worker', async () => { + const app = await mount(); + app.worker.fail('boom'); + expect(app.status()).toContain('The model parser could not run (boom)'); + expect(app.level()).toBe('error'); + }); + + it('reports a model that could not be transferred', async () => { + const app = await mount(); + app.worker.messageError(); + expect(app.status()).toContain('the parsed model could not be transferred'); + }); + + it('is a module worker, as the source imports require', async () => { + const app = await mount(); + expect(app.worker.options?.type).toBe('module'); + }); +}); + +describe('loading a file', () => { + const HS3 = '{"metadata":{"hs3_version":"0.2"},"distributions":[{"name":"d","type":"gaussian_dist","mean":"m","sigma":1,"x":"x"}]}'; + + it('reads a picked file and parses it', async () => { + const app = await mount(); + const input = app.$('file-input'); + setFiles(input, [new File([HS3], 'picked.hs3')]); + input.dispatchEvent(new Event('change')); + await waitForRequests(app); + expect(app.worker.lastRequest?.filename).toBe('picked.hs3'); + app.worker.answer(); + expect(app.$('format-badge').textContent).toBe('HS3'); + }); + + it('clears the input, so re-picking the same file fires change again', async () => { + const app = await mount(); + const input = app.$('file-input'); + setFiles(input, [new File([HS3], 'picked.hs3')]); + input.dispatchEvent(new Event('change')); + expect(input.value).toBe(''); + }); + + it('warns before a very large file, and loads it if the user agrees', async () => { + const app = await mount({ confirm: true }); + const input = app.$('file-input'); + setFiles(input, [bigFile('huge.hs3', HS3, 60e6)]); + input.dispatchEvent(new Event('change')); + expect(app.confirmMock).toHaveBeenCalledOnce(); + expect(String(app.confirmMock.mock.calls[0]?.[0])).toContain('60 MB'); + await waitForRequests(app); + expect(app.worker.lastRequest?.filename).toBe('huge.hs3'); + }); + + it('backs out without reading when the user declines', async () => { + const app = await mount({ confirm: false }); + const input = app.$('file-input'); + setFiles(input, [bigFile('huge.hs3', HS3, 60e6)]); + input.dispatchEvent(new Event('change')); + await flush(); + expect(app.worker.requests).toHaveLength(0); + expect(app.status()).toContain('Load cancelled'); + }); + + it('does not warn about an ordinary file', async () => { + const app = await mount(); + const input = app.$('file-input'); + setFiles(input, [new File([HS3], 'small.hs3')]); + input.dispatchEvent(new Event('change')); + expect(app.confirmMock).not.toHaveBeenCalled(); + }); +}); + +describe('drag and drop', () => { + it('shows the overlay only while a drag carrying files is over the window', async () => { + const app = await mount(); + const overlay = app.$('drop-overlay'); + expect(overlay.hidden).toBe(true); + + window.dispatchEvent(dragEvent('dragover', [new File(['x'], 'a.hs3')])); + expect(overlay.hidden).toBe(false); + + // Crossing into a child element keeps it up (relatedTarget is set)… + window.dispatchEvent(dragEvent('dragleave', [], document.body)); + expect(overlay.hidden).toBe(false); + // …leaving the window entirely takes it down. + window.dispatchEvent(dragEvent('dragleave', [])); + expect(overlay.hidden).toBe(true); + }); + + it('ignores a drag that carries no files', async () => { + const app = await mount(); + window.dispatchEvent(dragEvent('dragover', [])); + expect(app.$('drop-overlay').hidden).toBe(true); + }); + + it('loads a dropped file and hides the overlay', async () => { + const app = await mount(); + const file = new File(['mu ~ normal(0, 1)\ny = mu\n'], 'dropped.flatppl'); + window.dispatchEvent(dragEvent('dragover', [file])); + window.dispatchEvent(dragEvent('drop', [file])); + expect(app.$('drop-overlay').hidden).toBe(true); + await waitForRequests(app); + expect(app.worker.lastRequest?.filename).toBe('dropped.flatppl'); + app.worker.answer(); + expect(app.$('format-badge').textContent).toBe('FlatPPL'); + }); +}); + +describe('paste to load', () => { + it('opens an empty box with no stale outcome from a previous visit', async () => { + const app = await mount(); + const dialog = app.$('paste-dialog'); + dialog.returnValue = 'load'; // as a previous load would have left it + app.$('paste-text').value = 'old text'; + app.$('paste-btn').click(); + expect(dialog.open).toBe(true); + expect(app.$('paste-text').value).toBe(''); + expect(dialog.returnValue).toBe(''); + }); + + it('parses pasted text, with no extension to go on', async () => { + const app = await mount(); + app.$('paste-btn').click(); + app.$('paste-text').value = 'mu ~ normal(0, 1)\ny = mu\n'; + submitDialog(app.$('paste-dialog'), 'load'); + expect(app.worker.lastRequest?.filename).toBe('pasted model'); + app.worker.answer(); + expect(app.$('format-badge').textContent).toBe('FlatPPL'); + }); + + it('loads nothing when cancelled', async () => { + const app = await mount(); + app.$('paste-btn').click(); + app.$('paste-text').value = 'mu ~ normal(0, 1)'; + submitDialog(app.$('paste-dialog'), 'cancel'); + expect(app.worker.requests).toHaveLength(0); + }); + + it('says the box was empty rather than reporting a parse failure', async () => { + const app = await mount(); + app.$('paste-btn').click(); + app.$('paste-text').value = ' \n '; + submitDialog(app.$('paste-dialog'), 'load'); + expect(app.worker.requests).toHaveLength(0); + expect(app.status()).toContain('paste box was empty'); + }); +}); diff --git a/test/main-ui.test.ts b/test/main-ui.test.ts new file mode 100644 index 0000000..9d2f949 --- /dev/null +++ b/test/main-ui.test.ts @@ -0,0 +1,375 @@ +/** + * App entry (`src/main.ts`), interaction: node search, keyboard shortcuts, + * resizable panes, the responsive pane tabs, and the model/diagnostics dialog. + * + * Most cases drive a hand-built model rather than a bundled sample, so the graph + * under test is exactly the one the case needs (three nodes, two of them sharing + * a type, one diagnostic of each level). + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { mount, showModel, model, key, type MountedApp } from './app-harness.js'; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +const search = (app: MountedApp): HTMLInputElement => app.$('node-search'); + +/** Submit the search box the way a keyboard does. */ +function submitSearch(app: MountedApp, query: string): void { + const box = search(app); + box.value = query; + key(box, 'Enter'); +} + +describe('node search', () => { + it('focuses the node whose id was typed', async () => { + const app = await mount(); + await showModel(app); + submitSearch(app, 'beta'); + expect(app.status()).toBe('Focused "beta"'); + expect(app.$('inspector-pane').textContent).toContain('beta'); + }); + + /** + * Regression: stepping through matches hung off `change`, which a browser does + * not fire when the value has not been edited — so a second Enter did nothing + * while the status bar promised it would step. Only the first match of a common + * substring was ever reachable. + */ + it('steps to the next match on each Enter, and wraps', async () => { + const app = await mount(); + await showModel(app); + submitSearch(app, 'a'); // matches alpha, beta, gamma + expect(app.status()).toContain('match 1 of 3'); + expect(app.status()).toContain('press Enter again for the next'); + + key(search(app), 'Enter'); + expect(app.status()).toContain('Focused "beta" — match 2 of 3'); + key(search(app), 'Enter'); + expect(app.status()).toContain('Focused "gamma" — match 3 of 3'); + key(search(app), 'Enter'); + expect(app.status()).toContain('Focused "alpha" — match 1 of 3'); + }); + + it('re-selects rather than steps when the value is merely committed', async () => { + const app = await mount(); + await showModel(app); + submitSearch(app, 'a'); + expect(app.status()).toContain('match 1 of 3'); + // `change` fires on blur and on picking a suggestion, and also alongside the + // Enter above — it must not double-step. + search(app).dispatchEvent(new Event('change')); + expect(app.status()).toContain('match 1 of 3'); + }); + + it('matches on type, so a whole kind of node can be found at once', async () => { + const app = await mount(); + await showModel(app); + submitSearch(app, 'poisson'); + expect(app.status()).toBe('Focused "gamma"'); + }); + + it('reports a query that matches nothing', async () => { + const app = await mount(); + await showModel(app); + submitSearch(app, 'zzz'); + expect(app.status()).toBe('Error: No node matching "zzz"'); + expect(app.level()).toBe('error'); + }); + + it('ignores an empty query', async () => { + const app = await mount(); + await showModel(app); + const before = app.status(); + submitSearch(app, ' '); + expect(app.status()).toBe(before); + }); + + it('surfaces the inspector, so a selection is visible on a narrow screen', async () => { + const app = await mount(); + await showModel(app); + app.layout.dataset.activePane = 'tree'; + submitSearch(app, 'beta'); + expect(app.layout.dataset.activePane).toBe('inspector'); + }); + + it('starts a fresh match list for a new query', async () => { + const app = await mount(); + await showModel(app); + submitSearch(app, 'a'); + key(search(app), 'Enter'); // now on match 2 + submitSearch(app, 'poisson'); + expect(app.status()).toBe('Focused "gamma"'); + }); + + it('resets the box and the match list when a new model is loaded', async () => { + const app = await mount(); + await showModel(app); + submitSearch(app, 'a'); + await showModel(app, model({ nodes: [], edges: [] })); + expect(search(app).value).toBe(''); + expect(search(app).disabled).toBe(true); // nothing to search in + }); +}); + +describe('search suggestions', () => { + it('offers every node of a small model', async () => { + const app = await mount(); + await showModel(app); + const options = [...app.$('node-search-list').querySelectorAll('option')].map((o) => o.value); + expect(options).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('narrows to what matches, after the keystroke settles', async () => { + const app = await mount(); + await showModel(app); + vi.useFakeTimers(); + const box = search(app); + box.value = 'poiss'; + box.dispatchEvent(new Event('input')); + // Debounced: matching scans every node, so it must not run per keystroke. + expect([...app.$('node-search-list').querySelectorAll('option')]).toHaveLength(3); + vi.advanceTimersByTime(200); + const options = [...app.$('node-search-list').querySelectorAll('option')].map((o) => o.value); + expect(options).toEqual(['gamma']); + }); + + it('restores the full list when the box is emptied', async () => { + const app = await mount(); + await showModel(app); + vi.useFakeTimers(); + const box = search(app); + box.value = 'poiss'; + box.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(200); + box.value = ''; + box.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(200); + expect([...app.$('node-search-list').querySelectorAll('option')]).toHaveLength(3); + }); +}); + +describe('keyboard shortcuts', () => { + it('jumps to the search box on "/"', async () => { + const app = await mount(); + await showModel(app); + key(document.body, '/'); + expect(document.activeElement).toBe(search(app)); + }); + + it('does nothing on "/" while nothing is loaded', async () => { + const app = await mount(); + key(document.body, '/'); + expect(document.activeElement).not.toBe(search(app)); + }); + + it('never hijacks a "/" typed into a field', async () => { + const app = await mount(); + await showModel(app); + const text = app.$('paste-text'); + text.focus(); + key(text, '/'); + expect(document.activeElement).toBe(text); + }); + + it('leaves "/" alone when it is part of a shortcut', async () => { + const app = await mount(); + await showModel(app); + key(document.body, '/', { metaKey: true }); + expect(document.activeElement).not.toBe(search(app)); + }); + + it('clears and leaves the search box on Escape', async () => { + const app = await mount(); + await showModel(app); + const box = search(app); + box.focus(); + box.value = 'beta'; + key(box, 'Escape'); + expect(box.value).toBe(''); + expect(document.activeElement).not.toBe(box); + }); +}); + +describe('resizable panes', () => { + const width = (app: MountedApp, v: string): string => app.layout.style.getPropertyValue(v); + const splitter = (edge: string): HTMLElement => + document.querySelector(`.splitter[data-edge="${edge}"]`)!; + + it('widens the tree pane with the arrow keys, and remembers it', async () => { + const app = await mount(); + key(splitter('tree'), 'ArrowRight'); + expect(width(app, '--tree-w')).toBe('336px'); + expect(splitter('tree').getAttribute('aria-valuenow')).toBe('336'); + expect(app.storage.getItem('mv-tree-w')).toBe('336'); + }); + + it('takes a bigger step with Shift', async () => { + const app = await mount(); + key(splitter('tree'), 'ArrowRight', { shiftKey: true }); + expect(width(app, '--tree-w')).toBe('368px'); + }); + + it('grows the inspector towards the left, since it is measured from the right', async () => { + const app = await mount(); + key(splitter('inspector'), 'ArrowLeft'); + expect(width(app, '--insp-w')).toBe('396px'); + key(splitter('inspector'), 'ArrowRight'); + expect(width(app, '--insp-w')).toBe('380px'); + }); + + it('restores a remembered width on load', async () => { + const app = await mount({ storage: { 'mv-tree-w': '480' } }); + expect(width(app, '--tree-w')).toBe('480px'); + expect(splitter('tree').getAttribute('aria-valuenow')).toBe('480'); + }); + + it('clamps a remembered width that is out of range', async () => { + const wide = await mount({ storage: { 'mv-tree-w': '5000' } }); + expect(width(wide, '--tree-w')).toBe('720px'); + const narrow = await mount({ storage: { 'mv-insp-w': '10' } }); + expect(width(narrow, '--insp-w')).toBe('180px'); + }); + + it('resets to the default on Home', async () => { + const app = await mount({ storage: { 'mv-tree-w': '480' } }); + key(splitter('tree'), 'Home'); + expect(width(app, '--tree-w')).toBe('320px'); + }); + + it('follows the pointer between press and release, and not outside it', async () => { + const app = await mount(); + const sep = splitter('tree'); + sep.dispatchEvent(new PointerEvent('pointermove', { clientX: 500, bubbles: true })); + expect(width(app, '--tree-w')).toBe(''); // no drag in progress + + sep.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, bubbles: true, cancelable: true })); + expect(document.body.style.userSelect).toBe('none'); + sep.dispatchEvent(new PointerEvent('pointermove', { clientX: 500, bubbles: true })); + expect(width(app, '--tree-w')).toBe('500px'); + + sep.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, bubbles: true })); + expect(document.body.style.userSelect).toBe(''); + sep.dispatchEvent(new PointerEvent('pointermove', { clientX: 300, bubbles: true })); + expect(width(app, '--tree-w')).toBe('500px'); + }); +}); + +describe('pane tabs', () => { + it('switches the visible pane and keeps aria-selected in step', async () => { + const app = await mount(); + const tabs = [...document.querySelectorAll('.pane-tab')]; + const graph = tabs.find((t) => t.dataset.pane === 'dag')!; + graph.click(); + expect(app.layout.dataset.activePane).toBe('dag'); + expect(tabs.map((t) => t.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false']); + }); +}); + +describe('the model dialog', () => { + const withDiagnostics = () => model({ + meta: { hs3_version: '0.2' }, + diagnostics: [ + { level: 'warn', msg: 'beta looks odd', nodeId: 'beta' }, + { level: 'info', msg: 'just so you know' }, + { level: 'error', msg: 'the model as a whole is wrong' }, + ], + }); + + it('opens from the format badge, which says what it does', async () => { + const app = await mount(); + await showModel(app); + const badge = app.$('format-badge'); + expect(badge.getAttribute('aria-label')).toContain('open info, metadata, and diagnostics'); + badge.click(); + expect(app.$('model-dialog').open).toBe(true); + }); + + it('summarises the model and shows its metadata', async () => { + const app = await mount(); + await showModel(app, withDiagnostics()); + app.$('format-badge').click(); + const body = app.$('model-body'); + expect([...body.querySelectorAll('h3')].map((h) => h.textContent)) + .toEqual(['Summary', 'Metadata', 'Diagnostics']); + expect(body.querySelector('.model-summary')?.textContent).toContain('HS3'); + expect(body.querySelector('.model-summary')?.textContent).toContain('3'); // nodes + expect(body.querySelector('pre')?.textContent).toContain('hs3_version'); + }); + + it('says so when there is no metadata to read', async () => { + const app = await mount(); + await showModel(app); + app.$('format-badge').click(); + expect(app.$('model-body').textContent).toContain('This model carries no metadata'); + expect(app.$('model-body').textContent).toContain('No diagnostics — the model parsed cleanly'); + }); + + it('lists diagnostics worst first', async () => { + const app = await mount(); + await showModel(app, withDiagnostics()); + app.$('format-badge').click(); + const levels = [...app.$('model-body').querySelectorAll('.diag-level')].map((s) => s.textContent); + expect(levels).toEqual(['error', 'warn', 'info']); + }); + + it('makes a node diagnostic a link to the node, and a model-level one plain text', async () => { + const app = await mount(); + await showModel(app, withDiagnostics()); + app.$('format-badge').click(); + const items = [...app.$('model-body').querySelectorAll('.diag-list li')]; + // Worst first: [error (model-level), warn (on beta), info (model-level)]. + expect(items[0]!.querySelector('button')).toBeNull(); + expect(items[2]!.querySelector('button')).toBeNull(); + const xref = items[1]!.querySelector('button.xref')!; + expect(xref.textContent).toBe('beta looks odd'); + + xref.click(); + expect(app.$('model-dialog').open).toBe(false); + expect(app.$('inspector-pane').textContent).toContain('beta'); + }); + + it('advertises the diagnostic count next to the badge', async () => { + const app = await mount(); + await showModel(app, withDiagnostics()); + const btn = app.$('diag-btn'); + expect(btn.hidden).toBe(false); + expect(btn.textContent).toBe('⚠ 3'); + expect(btn.dataset.level).toBe('error'); + expect(btn.getAttribute('aria-label')).toContain('3 diagnostics (1 error(s), 1 warning(s))'); + btn.click(); + expect(app.$('model-dialog').open).toBe(true); + }); + + it('counts errors and warnings in the status bar', async () => { + const app = await mount(); + await showModel(app, withDiagnostics()); + expect(app.status()).toContain('1 error(s), 1 warning(s)'); + expect(app.level()).toBe('error'); + }); + + it('hides the count when a model parsed cleanly', async () => { + const app = await mount(); + await showModel(app); + expect(app.$('diag-btn').hidden).toBe(true); + expect(app.level()).toBe('info'); + }); + + it('stays shut while nothing is loaded', async () => { + const app = await mount(); + app.$('format-badge').click(); + expect(app.$('model-dialog').open).toBe(false); + }); +}); + +describe('a model with no nodes', () => { + it('is shown, but with search disabled and nothing to inspect', async () => { + const app = await mount(); + await showModel(app, model({ nodes: [], edges: [] })); + expect(app.$('format-badge').hidden).toBe(false); + expect(search(app).disabled).toBe(true); + expect(app.$('inspector-pane').textContent).toContain('Select a node to inspect'); + }); +});