From a6ecf886233d11229a3350655430d29ffb9d6a0d Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 18:34:22 +1000 Subject: [PATCH] [ui] DOM tests under happy-dom: chips, window binding, fax stamp The three that genuinely needed a real DOM and were blocked last round. happy-dom is actually installed now. Chips: node reuse across updates, state classes clearing, spec ordering, and a dangling item id rendering drab instead of taking the panel down. Hotkeys: attach/detach really binding and unbinding the window, space preventing page scroll, and no hotkey firing while typing in a field. Fax: the stamp lands and clears on its own, and two commissions completing back to back don't let the first timer clear the second early. Co-Authored-By: Claude Opus 4.8 --- fktry/src/ui/chips.test.ts | 77 ++++++++++++++++++ fktry/src/ui/fax.test.ts | 134 +++++++++++++++++++++++++++++++ fktry/src/ui/hotkeys.dom.test.ts | 83 +++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 fktry/src/ui/chips.test.ts create mode 100644 fktry/src/ui/fax.test.ts create mode 100644 fktry/src/ui/hotkeys.dom.test.ts diff --git a/fktry/src/ui/chips.test.ts b/fktry/src/ui/chips.test.ts new file mode 100644 index 0000000..ba89ecc --- /dev/null +++ b/fktry/src/ui/chips.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest'; +import type { ItemDef } from '../contracts'; +import { createChipRow } from './chips'; + +const ITEMS = new Map([ + ['melt', { id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff7a3f' }], + ['mdat-ore', { id: 'mdat-ore', name: 'MDAT ORE', codex: '', tier: 0, color: '#1a1a22' }], + ['hf-dust', { id: 'hf-dust', name: 'HF DUST', codex: '', tier: 1, color: '#e8e0ff' }], +]); + +// Array.from, not spread: the root tsconfig's lib list has DOM but not DOM.Iterable, +// so a NodeList isn't iterable in types. Root config isn't this lane's to edit. +const texts = (row: HTMLElement) => + Array.from(row.querySelectorAll('.fk-chip')).map((c) => c.textContent); +const ids = (row: HTMLElement) => + Array.from(row.querySelectorAll('.fk-chip')).map((c) => c.getAttribute('title')); + +describe('createChipRow', () => { + let row: ReturnType; + beforeEach(() => { + row = createChipRow(ITEMS); + }); + + it('renders a chip per spec with the item display name and count', () => { + row.update([{ item: 'melt', count: '3/40' }]); + expect(texts(row.el)).toEqual(['MELT3/40']); + }); + + it('paints the swatch with the item colour from data', () => { + row.update([{ item: 'melt', count: '1' }]); + const sw = row.el.querySelector('.fk-chip-sw') as HTMLElement; + expect(sw.style.background).toBe('#ff7a3f'); + }); + + it('reuses the same node across updates instead of rebuilding the row', () => { + row.update([{ item: 'melt', count: '1' }]); + const first = row.el.querySelector('.fk-chip'); + row.update([{ item: 'melt', count: '2' }]); + expect(row.el.querySelector('.fk-chip')).toBe(first); // same node, new count + expect(texts(row.el)).toEqual(['MELT2']); + }); + + it('applies state classes and clears them again', () => { + row.update([{ item: 'melt', count: '0/1', state: 'short' }]); + expect(row.el.querySelector('.fk-chip')!.className).toBe('fk-chip is-short'); + row.update([{ item: 'melt', count: '1/1', state: 'met' }]); + expect(row.el.querySelector('.fk-chip')!.className).toBe('fk-chip is-met'); + row.update([{ item: 'melt', count: '9' }]); + expect(row.el.querySelector('.fk-chip')!.className).toBe('fk-chip'); + }); + + it('removes chips that are no longer in the spec', () => { + row.update([{ item: 'melt', count: '1' }, { item: 'hf-dust', count: '2' }]); + expect(ids(row.el)).toEqual(['melt', 'hf-dust']); + row.update([{ item: 'hf-dust', count: '3' }]); + expect(ids(row.el)).toEqual(['hf-dust']); + }); + + it('keeps DOM order matching spec order', () => { + row.update([{ item: 'melt', count: '1' }, { item: 'hf-dust', count: '1' }]); + row.update([{ item: 'hf-dust', count: '1' }, { item: 'melt', count: '1' }]); + expect(ids(row.el)).toEqual(['hf-dust', 'melt']); + }); + + it('renders an unknown item as a drab chip rather than crashing', () => { + // A dangling id from DATA must never take the panel down. + row.update([{ item: 'not-an-item', count: '1' }]); + expect(texts(row.el)).toEqual(['???1']); + }); + + it('renders nothing for an empty spec', () => { + row.update([{ item: 'melt', count: '1' }]); + row.update([]); + expect(row.el.querySelectorAll('.fk-chip')).toHaveLength(0); + }); +}); diff --git a/fktry/src/ui/fax.test.ts b/fktry/src/ui/fax.test.ts new file mode 100644 index 0000000..e35fafa --- /dev/null +++ b/fktry/src/ui/fax.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GameData, SimSnapshot } from '../contracts'; +import { createFax } from './fax'; + +const DATA: GameData = { + items: [ + { id: 'melt', name: 'MELT', codex: '', tier: 2, color: '#ff7a3f' }, + { id: 'anchor-slab', name: 'I-FRAME', codex: '', tier: 1, color: '#fff2a0' }, + { id: 'hf-dust', name: 'HF DUST', codex: '', tier: 1, color: '#e8e0ff' }, + ], + machines: [], + recipes: [], + tech: [], + commissions: [ + { id: 'first-taste', flavor: 'One (1) unit of MELT.', wants: { melt: 1 }, rewardBandwidth: 50 }, + { id: 'dust-allowance', flavor: 'Dust. Weekly.', wants: { 'hf-dust': 5 }, rewardBandwidth: 20, repeat: true }, + { id: 'patio-resurfacing', flavor: 'Bricks.', wants: { melt: 9 }, rewardBandwidth: 30 }, + ], +}; + +function snap(over: Partial = {}): SimSnapshot { + return { + tick: 0, paused: false, entities: [], beltItems: [], + bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false }, + shippedTotal: {}, activeCommission: 'first-taste', commissionProgress: {}, ...over, + }; +} + +const q = (el: HTMLElement, s: string) => el.querySelector(s) as HTMLElement; +const on = (el: HTMLElement, s: string) => q(el, s).classList.contains('is-on'); + +describe('fax stamp lifecycle', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('is not stamped until a commission completes', () => { + const fax = createFax(DATA); + expect(on(fax.el, '.fk-fax-stamp')).toBe(false); + }); + + it('stamps on demand and clears itself after the animation', () => { + const fax = createFax(DATA); + fax.stamp(); + expect(on(fax.el, '.fk-fax-stamp')).toBe(true); + + vi.advanceTimersByTime(1799); + expect(on(fax.el, '.fk-fax-stamp')).toBe(true); // still landed + vi.advanceTimersByTime(1); + expect(on(fax.el, '.fk-fax-stamp')).toBe(false); + }); + + it('restarts cleanly when two commissions complete back to back', () => { + const fax = createFax(DATA); + fax.stamp(); + vi.advanceTimersByTime(1000); + fax.stamp(); // second order lands mid-animation + vi.advanceTimersByTime(1000); + // The first stamp's timer must not clear the second one early. + expect(on(fax.el, '.fk-fax-stamp')).toBe(true); + vi.advanceTimersByTime(800); + expect(on(fax.el, '.fk-fax-stamp')).toBe(false); + }); +}); + +describe('fax panel', () => { + it('stays shut when there is no active commission', () => { + const fax = createFax(DATA); + fax.update(snap({ activeCommission: null })); + expect(fax.el.classList.contains('is-open')).toBe(false); + }); + + it('shows the active commission flavor, wants and reward', () => { + const fax = createFax(DATA); + fax.update(snap({ commissionProgress: { melt: 0 } })); + expect(fax.el.classList.contains('is-open')).toBe(true); + expect(q(fax.el, '.fk-fax-flavor').textContent).toBe('One (1) unit of MELT.'); + expect(q(fax.el, '.fk-chip').textContent).toBe('MELT0/1'); + expect(fax.el.textContent).toContain('+50 BANDWIDTH'); + }); + + it('stamps STANDING ORDER only for a repeat commission', () => { + const fax = createFax(DATA); + fax.update(snap()); + expect(on(fax.el, '.fk-fax-standing')).toBe(false); + fax.update(snap({ activeCommission: 'dust-allowance' })); + expect(on(fax.el, '.fk-fax-standing')).toBe(true); + }); + + it('shows SANITISING only once anchor-slabs have actually shipped', () => { + const fax = createFax(DATA); + fax.update(snap()); + expect(on(fax.el, '.fk-san')).toBe(false); + fax.update(snap({ shippedTotal: { 'anchor-slab': 4 } })); + expect(on(fax.el, '.fk-san')).toBe(true); + expect(q(fax.el, '.fk-san .fk-chip').textContent).toBe('I-FRAME4'); + }); +}); + +describe('NEXT IN TRAY', () => { + it('reads snap.commissionQueue as truth: active first, next is queue[1]', () => { + const fax = createFax(DATA); + fax.update(snap({ + activeCommission: 'first-taste', + commissionQueue: ['first-taste', 'patio-resurfacing', 'dust-allowance'], + })); + expect(on(fax.el, '.fk-fax-next')).toBe(true); + expect(q(fax.el, '.fk-fax-next-name').textContent).toBe('PATIO RESURFACING'); + }); + + it('follows the queue after a standing order is re-queued to the back', () => { + // The exact case the data-order fallback gets wrong. + const fax = createFax(DATA); + fax.update(snap({ + activeCommission: 'patio-resurfacing', + commissionQueue: ['patio-resurfacing', 'dust-allowance'], + })); + expect(q(fax.el, '.fk-fax-next-name').textContent).toBe('DUST ALLOWANCE'); + }); + + it('hides the peek when the queue holds nothing but the active order', () => { + const fax = createFax(DATA); + fax.update(snap({ activeCommission: 'first-taste', commissionQueue: ['first-taste'] })); + expect(on(fax.el, '.fk-fax-next')).toBe(false); + }); + + it('shows no peek at all rather than guessing when the queue is absent', () => { + // The round-2 fallback inferred "next" from data order; it was wrong as soon as a + // standing order was re-queued to the back. A silent fax beats a lying one. + const fax = createFax(DATA); + fax.update(snap({ activeCommission: 'first-taste' })); + expect(on(fax.el, '.fk-fax-next')).toBe(false); + }); +}); diff --git a/fktry/src/ui/hotkeys.dom.test.ts b/fktry/src/ui/hotkeys.dom.test.ts new file mode 100644 index 0000000..7af10bd --- /dev/null +++ b/fktry/src/ui/hotkeys.dom.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest'; +import { createHotkeys, isTypingTarget } from './hotkeys'; + +/** The half of hotkeys.ts that needs a real window: attach/detach and typing guards. */ +describe('createHotkeys attach/detach', () => { + it('routes real window keydown events once attached', () => { + const keys = createHotkeys(); + const hit = vi.fn(); + keys.bind('r', hit); + keys.attach(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' })); + expect(hit).toHaveBeenCalledTimes(1); + keys.detach(); + }); + + it('stops routing after detach — no listener left on the window', () => { + const keys = createHotkeys(); + const hit = vi.fn(); + keys.bind('r', hit); + keys.attach(); + keys.detach(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' })); + expect(hit).not.toHaveBeenCalled(); + }); + + it('ignores keys bound before attach until attach is called', () => { + const keys = createHotkeys(); + const hit = vi.fn(); + keys.bind('x', hit); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + expect(hit).not.toHaveBeenCalled(); + keys.attach(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + expect(hit).toHaveBeenCalledTimes(1); + keys.detach(); + }); + + it('calls preventDefault on space so the page never scrolls under the factory', () => { + const keys = createHotkeys(); + keys.bind(' ', () => {}); + keys.attach(); + const ev = new KeyboardEvent('keydown', { key: ' ', cancelable: true }); + window.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + keys.detach(); + }); + + it('does not fire a hotkey while the player is typing in a field', () => { + const keys = createHotkeys(); + const hit = vi.fn(); + keys.bind('r', hit); + keys.attach(); + + const input = document.createElement('input'); + document.body.append(input); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true })); + expect(hit).not.toHaveBeenCalled(); + + input.remove(); + keys.detach(); + }); +}); + +describe('isTypingTarget', () => { + it('is true for form fields and contenteditable, false for the world', () => { + for (const tag of ['input', 'textarea', 'select']) { + expect(isTypingTarget(document.createElement(tag))).toBe(true); + } + const div = document.createElement('div'); + expect(isTypingTarget(div)).toBe(false); + + // The inspector's recipe