[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 <noreply@anthropic.com>
This commit is contained in:
parent
ea17e95318
commit
a6ecf88623
77
fktry/src/ui/chips.test.ts
Normal file
77
fktry/src/ui/chips.test.ts
Normal file
@ -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<string, ItemDef>([
|
||||
['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<typeof createChipRow>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
134
fktry/src/ui/fax.test.ts
Normal file
134
fktry/src/ui/fax.test.ts
Normal file
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
83
fktry/src/ui/hotkeys.dom.test.ts
Normal file
83
fktry/src/ui/hotkeys.dom.test.ts
Normal file
@ -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 <select> is exactly why this guard exists.
|
||||
const editable = document.createElement('div');
|
||||
editable.setAttribute('contenteditable', 'true');
|
||||
expect(isTypingTarget(editable)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for null and non-elements', () => {
|
||||
expect(isTypingTarget(null)).toBe(false);
|
||||
expect(isTypingTarget('r')).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user