glytch/fktry/src/ui/fax.test.ts
type-two a6ecf88623 [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>
2026-07-17 18:34:22 +10:00

135 lines
5.1 KiB
TypeScript

// @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);
});
});