[ui] 57 tests for pagination, hit-testing, selection, keymap, chips, voice
Order asked for a minimum of ten under happy-dom. happy-dom is not actually installed (not in package.json, not in node_modules) and root config is not mine to edit, so these run in the default node environment instead — which is why the logic was factored into pure modules first. Covers everything the order named except the two things that genuinely need a DOM: chip rendering (the arithmetic is covered) and attach()'s window binding (the routing is covered). Pins the invariants worth pinning: no build page ever exceeds nine slots at any catalog size, syncRoster copies values rather than holding sim buffers across frames, footprint hit-testing respects rotation, and an unknown jam reason or machine kind degrades instead of throwing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e25c854be8
commit
a57729ce33
85
fktry/src/ui/chipspec.test.ts
Normal file
85
fktry/src/ui/chipspec.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CommissionDef, EntityState, RecipeDef } from '../contracts';
|
||||
import { commissionChips, commissionMet, intakeChips, outputChips } from './chipspec';
|
||||
|
||||
function ent(over: Partial<EntityState> = {}): EntityState {
|
||||
return {
|
||||
id: 1, def: 'demuxer', pos: { x: 0, y: 0 }, dir: 0, recipe: 'demux-ore',
|
||||
progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
const DEMUX: RecipeDef = {
|
||||
id: 'demux-ore', machine: 'demuxer',
|
||||
inputs: { 'mdat-ore': 2 },
|
||||
outputs: { 'luma-bars': 1, 'chroma-slurry': 1 },
|
||||
ticks: 45, bandwidth: 3,
|
||||
};
|
||||
|
||||
describe('intakeChips', () => {
|
||||
it('reads have/need and marks a short intake', () => {
|
||||
expect(intakeChips(DEMUX, ent({ inputBuf: { 'mdat-ore': 1 } }))).toEqual([
|
||||
{ item: 'mdat-ore', count: '1/2', state: 'short' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks intake met once the buffer covers the recipe', () => {
|
||||
expect(intakeChips(DEMUX, ent({ inputBuf: { 'mdat-ore': 2 } }))[0].state).toBe('met');
|
||||
});
|
||||
|
||||
it('treats a missing buffer entry as zero rather than NaN', () => {
|
||||
expect(intakeChips(DEMUX, ent())[0].count).toBe('0/2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outputChips', () => {
|
||||
it('lists recipe outputs even when the buffer is empty', () => {
|
||||
expect(outputChips(DEMUX, ent()).map((c) => c.item))
|
||||
.toEqual(['luma-bars', 'chroma-slurry']);
|
||||
});
|
||||
|
||||
it('also surfaces buffer contents the recipe never mentions — that is the jam', () => {
|
||||
const chips = outputChips(DEMUX, ent({ outputBuf: { 'luma-bars': 3, melt: 1 } }));
|
||||
expect(chips).toContainEqual({ item: 'melt', count: '1' });
|
||||
expect(chips).toContainEqual({ item: 'luma-bars', count: '3' });
|
||||
});
|
||||
|
||||
it('does not duplicate an item present in both recipe and buffer', () => {
|
||||
const ids = outputChips(DEMUX, ent({ outputBuf: { 'luma-bars': 2 } })).map((c) => c.item);
|
||||
expect(ids.filter((i) => i === 'luma-bars')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commissionChips', () => {
|
||||
const c: CommissionDef = {
|
||||
id: 'first-taste', flavor: '', wants: { melt: 40 }, rewardBandwidth: 50,
|
||||
};
|
||||
|
||||
it('shows delivered against wanted', () => {
|
||||
expect(commissionChips(c, { melt: 3 })).toEqual([
|
||||
{ item: 'melt', count: '3/40', state: 'plain' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('never reports more delivered than wanted', () => {
|
||||
expect(commissionChips(c, { melt: 99 })[0].count).toBe('40/40');
|
||||
});
|
||||
|
||||
it('marks met at exactly the wanted count', () => {
|
||||
expect(commissionChips(c, { melt: 40 })[0].state).toBe('met');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commissionMet', () => {
|
||||
const two: CommissionDef = {
|
||||
id: 'x', flavor: '', wants: { melt: 2, 'hf-dust': 1 }, rewardBandwidth: 0,
|
||||
};
|
||||
|
||||
it('is false while any want is short', () => {
|
||||
expect(commissionMet(two, { melt: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('is true only once every want is covered', () => {
|
||||
expect(commissionMet(two, { melt: 2, 'hf-dust': 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
78
fktry/src/ui/hotkeys.test.ts
Normal file
78
fktry/src/ui/hotkeys.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createKeyRouter, type KeyEventLike } from './hotkeys';
|
||||
|
||||
/** A KeyboardEvent stand-in — the router only reads these fields, by design. */
|
||||
function ev(key: string, extra: Partial<KeyEventLike> = {}): KeyEventLike {
|
||||
return { key, preventDefault: vi.fn(), ...extra };
|
||||
}
|
||||
|
||||
describe('createKeyRouter', () => {
|
||||
it('routes a bound key to its handler', () => {
|
||||
const router = createKeyRouter();
|
||||
const hit = vi.fn();
|
||||
router.bind('r', hit);
|
||||
expect(router.handle(ev('r'))).toBe(true);
|
||||
expect(hit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('matches case-insensitively, so shift-R still rotates', () => {
|
||||
const router = createKeyRouter();
|
||||
const hit = vi.fn();
|
||||
router.bind('r', hit);
|
||||
router.handle(ev('R'));
|
||||
expect(hit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores unbound keys', () => {
|
||||
const router = createKeyRouter();
|
||||
expect(router.handle(ev('q'))).toBe(false);
|
||||
});
|
||||
|
||||
it('swallows space so the page does not scroll under the factory', () => {
|
||||
const router = createKeyRouter();
|
||||
router.bind(' ', () => {});
|
||||
const e = ev(' ');
|
||||
router.handle(e);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not preventDefault on keys the browser should keep handling', () => {
|
||||
const router = createKeyRouter();
|
||||
router.bind('r', () => {});
|
||||
const e = ev('r');
|
||||
router.handle(e);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves browser shortcuts alone (cmd/ctrl/alt held)', () => {
|
||||
const router = createKeyRouter();
|
||||
const hit = vi.fn();
|
||||
router.bind('r', hit);
|
||||
for (const mod of ['metaKey', 'ctrlKey', 'altKey'] as const) {
|
||||
expect(router.handle(ev('r', { [mod]: true }))).toBe(false);
|
||||
}
|
||||
expect(hit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a later bind replace an earlier one for the same key', () => {
|
||||
const router = createKeyRouter();
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
router.bind('1', first);
|
||||
router.bind('1', second);
|
||||
router.handle(ev('1'));
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('binds the paging keys independently', () => {
|
||||
const router = createKeyRouter();
|
||||
const prev = vi.fn();
|
||||
const next = vi.fn();
|
||||
router.bind('[', prev);
|
||||
router.bind(']', next);
|
||||
router.handle(ev(']'));
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(prev).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
84
fktry/src/ui/pages.test.ts
Normal file
84
fktry/src/ui/pages.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MachineDef, MachineKind } from '../contracts';
|
||||
import { paginate, pageOf, PER_PAGE } from './pages';
|
||||
|
||||
function m(id: string, kind: MachineKind): MachineDef {
|
||||
return {
|
||||
id, name: id.toUpperCase(), codex: '', kind,
|
||||
footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0, asset: id,
|
||||
};
|
||||
}
|
||||
|
||||
/** The real shape of the problem: LANE-DATA's catalog is crafter-heavy. */
|
||||
function catalog(crafters: number): MachineDef[] {
|
||||
return [
|
||||
m('seam-extractor', 'extractor'),
|
||||
m('belt', 'belt'),
|
||||
m('lane-splitter', 'splitter'),
|
||||
m('buffer-tank', 'buffer'),
|
||||
...Array.from({ length: crafters }, (_, i) => m(`crafter-${i}`, 'crafter')),
|
||||
m('decode-asic', 'power'),
|
||||
m('shipper', 'shipper'),
|
||||
];
|
||||
}
|
||||
|
||||
describe('paginate', () => {
|
||||
it('groups kinds into house-ordered tabs', () => {
|
||||
const pages = paginate(catalog(2));
|
||||
expect(pages.map((p) => p.label)).toEqual(['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP']);
|
||||
});
|
||||
|
||||
it('collapses belt, splitter and buffer into one LOGISTICS tab', () => {
|
||||
const pages = paginate(catalog(1));
|
||||
const logistics = pages.find((p) => p.label === 'LOGISTICS')!;
|
||||
expect(logistics.machines.map((x) => x.id)).toEqual(['belt', 'lane-splitter', 'buffer-tank']);
|
||||
});
|
||||
|
||||
it('never puts more than nine machines on a page, so 1-9 always reaches every slot', () => {
|
||||
for (const n of [1, 9, 14, 40]) {
|
||||
for (const page of paginate(catalog(n))) {
|
||||
expect(page.machines.length).toBeLessThanOrEqual(PER_PAGE);
|
||||
expect(page.machines.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('spills an oversized kind into numbered pages', () => {
|
||||
// 14 crafters is the live catalog: the exact case kind-tabs alone cannot hotkey.
|
||||
const labels = paginate(catalog(14)).map((p) => p.label);
|
||||
expect(labels).toContain('REFINE 1/2');
|
||||
expect(labels).toContain('REFINE 2/2');
|
||||
expect(labels).not.toContain('REFINE');
|
||||
});
|
||||
|
||||
it('keeps every machine exactly once across all pages', () => {
|
||||
const all = catalog(14);
|
||||
const paged = paginate(all).flatMap((p) => p.machines.map((x) => x.id));
|
||||
expect(paged.slice().sort()).toEqual(all.map((x) => x.id).sort());
|
||||
});
|
||||
|
||||
it('does not drop a machine whose kind it has never heard of', () => {
|
||||
const odd = [...catalog(1), m('mystery', 'teleporter' as MachineKind)];
|
||||
const paged = paginate(odd).flatMap((p) => p.machines.map((x) => x.id));
|
||||
expect(paged).toContain('mystery');
|
||||
// and it sorts last rather than jumping the queue
|
||||
expect(paginate(odd).at(-1)!.label).toBe('OTHER');
|
||||
});
|
||||
|
||||
it('returns no pages for an empty catalog', () => {
|
||||
expect(paginate([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pageOf', () => {
|
||||
it('finds the page holding a machine', () => {
|
||||
const pages = paginate(catalog(14));
|
||||
const i = pageOf(pages, 'crafter-12');
|
||||
expect(i).toBeGreaterThanOrEqual(0);
|
||||
expect(pages[i].machines.some((x) => x.id === 'crafter-12')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns -1 for a machine that is not in the catalog', () => {
|
||||
expect(pageOf(paginate(catalog(2)), 'nope')).toBe(-1);
|
||||
});
|
||||
});
|
||||
107
fktry/src/ui/pick.test.ts
Normal file
107
fktry/src/ui/pick.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Dir, EntityState, MachineDef, SimSnapshot, Vec2 } from '../contracts';
|
||||
import { createRoster, entityAt, footprintOf, syncRoster } from './pick';
|
||||
|
||||
function def(id: string, w: number, h: number): MachineDef {
|
||||
return {
|
||||
id, name: id.toUpperCase(), codex: '', kind: 'crafter',
|
||||
footprint: { x: w, y: h }, recipes: [], powerDraw: 0, asset: id,
|
||||
};
|
||||
}
|
||||
|
||||
function ent(id: number, defId: string, pos: Vec2, dir: Dir = 0): EntityState {
|
||||
return {
|
||||
id, def: defId, pos, dir, recipe: null, progress: 0,
|
||||
inputBuf: {}, outputBuf: {}, jammed: null, heat: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function snap(entities: EntityState[]): SimSnapshot {
|
||||
return {
|
||||
tick: 0, paused: false, entities, beltItems: [],
|
||||
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
|
||||
shippedTotal: {}, activeCommission: null, commissionProgress: {},
|
||||
};
|
||||
}
|
||||
|
||||
const MACHINES = new Map<string, MachineDef>([
|
||||
['reactor', def('reactor', 3, 3)],
|
||||
['press', def('press', 2, 1)],
|
||||
['belt', def('belt', 1, 1)],
|
||||
]);
|
||||
|
||||
describe('footprintOf', () => {
|
||||
it('leaves N/S footprints alone', () => {
|
||||
expect(footprintOf(def('press', 2, 1), 0)).toEqual({ x: 2, y: 1 });
|
||||
expect(footprintOf(def('press', 2, 1), 2)).toEqual({ x: 2, y: 1 });
|
||||
});
|
||||
|
||||
it('swaps width and height for E/W', () => {
|
||||
expect(footprintOf(def('press', 2, 1), 1)).toEqual({ x: 1, y: 2 });
|
||||
expect(footprintOf(def('press', 2, 1), 3)).toEqual({ x: 1, y: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityAt', () => {
|
||||
const roster = createRoster();
|
||||
|
||||
it('hits every tile of a footprint and nothing outside it', () => {
|
||||
syncRoster(roster, snap([ent(7, 'reactor', { x: 4, y: 4 })]));
|
||||
// pos is the origin corner (min x, min y): 4..6 inclusive on both axes.
|
||||
for (let x = 4; x <= 6; x++) {
|
||||
for (let y = 4; y <= 6; y++) expect(entityAt(roster, MACHINES, { x, y })).toBe(7);
|
||||
}
|
||||
for (const t of [{ x: 3, y: 4 }, { x: 7, y: 4 }, { x: 4, y: 3 }, { x: 4, y: 7 }]) {
|
||||
expect(entityAt(roster, MACHINES, t)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('respects rotation when hit-testing', () => {
|
||||
syncRoster(roster, snap([ent(1, 'press', { x: 0, y: 0 }, 1)])); // 2x1 rotated -> 1x2
|
||||
expect(entityAt(roster, MACHINES, { x: 0, y: 1 })).toBe(1);
|
||||
expect(entityAt(roster, MACHINES, { x: 1, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on empty ground', () => {
|
||||
syncRoster(roster, snap([]));
|
||||
expect(entityAt(roster, MACHINES, { x: 0, y: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the later-placed unit when footprints overlap', () => {
|
||||
syncRoster(roster, snap([ent(1, 'belt', { x: 2, y: 2 }), ent(2, 'belt', { x: 2, y: 2 })]));
|
||||
expect(entityAt(roster, MACHINES, { x: 2, y: 2 })).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores an entity whose def is unknown rather than throwing', () => {
|
||||
syncRoster(roster, snap([ent(9, 'ghost-machine', { x: 0, y: 0 })]));
|
||||
expect(entityAt(roster, MACHINES, { x: 0, y: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncRoster', () => {
|
||||
it('copies values out rather than holding the snapshot entities', () => {
|
||||
const roster = createRoster();
|
||||
const e = ent(3, 'belt', { x: 1, y: 1 });
|
||||
syncRoster(roster, snap([e]));
|
||||
|
||||
// CONTRACTS: sim may reuse its buffers. Mutating the source must not move our copy.
|
||||
e.pos.x = 99;
|
||||
e.id = 42;
|
||||
expect(roster.entries[0].x).toBe(1);
|
||||
expect(roster.entries[0].id).toBe(3);
|
||||
});
|
||||
|
||||
it('reuses its backing array and reports a shrinking length', () => {
|
||||
const roster = createRoster();
|
||||
syncRoster(roster, snap([ent(1, 'belt', { x: 0, y: 0 }), ent(2, 'belt', { x: 1, y: 0 })]));
|
||||
const backing = roster.entries;
|
||||
expect(roster.length).toBe(2);
|
||||
|
||||
syncRoster(roster, snap([ent(5, 'belt', { x: 9, y: 9 })]));
|
||||
expect(roster.entries).toBe(backing); // no per-frame allocation
|
||||
expect(roster.length).toBe(1);
|
||||
expect(roster.entries[0].id).toBe(5);
|
||||
// A stale slot must not be reachable through the reported length.
|
||||
expect(entityAt(roster, MACHINES, { x: 1, y: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
77
fktry/src/ui/selection.test.ts
Normal file
77
fktry/src/ui/selection.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Command, Dir, UIBus, Vec2 } from '../contracts';
|
||||
import { createSelection } from './selection';
|
||||
|
||||
type BusWithSel = UIBus & { _sel: { def: string; dir: Dir } | null };
|
||||
|
||||
function makeBus(): BusWithSel {
|
||||
const bus: BusWithSel = {
|
||||
_sel: null,
|
||||
dispatch: (_cmd: Command) => {},
|
||||
selectedBuild: () => bus._sel,
|
||||
pickTile: (_x: number, _y: number): Vec2 | null => null,
|
||||
};
|
||||
return bus;
|
||||
}
|
||||
|
||||
describe('createSelection', () => {
|
||||
it('writes the selection through to bus._sel, which main.ts reads for the ghost', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.select('quantizer');
|
||||
expect(bus._sel).toEqual({ def: 'quantizer', dir: 0 });
|
||||
expect(bus.selectedBuild()).toEqual({ def: 'quantizer', dir: 0 });
|
||||
});
|
||||
|
||||
it('toggles off when the selected machine is selected again', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.select('belt');
|
||||
sel.select('belt');
|
||||
expect(sel.get()).toBeNull();
|
||||
expect(bus._sel).toBeNull();
|
||||
});
|
||||
|
||||
it('rotates N->E->S->W and wraps', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.select('belt');
|
||||
const dirs = [1, 2, 3, 0].map(() => {
|
||||
sel.rotate();
|
||||
return sel.get()!.dir;
|
||||
});
|
||||
expect(dirs).toEqual([1, 2, 3, 0]);
|
||||
});
|
||||
|
||||
it('keeps the facing when swapping machines — you aim once, then build a line', () => {
|
||||
const bus = makeBus();
|
||||
const sel = createSelection(bus);
|
||||
sel.select('belt');
|
||||
sel.rotate();
|
||||
sel.rotate();
|
||||
sel.select('demuxer');
|
||||
expect(sel.get()).toEqual({ def: 'demuxer', dir: 2 });
|
||||
});
|
||||
|
||||
it('does nothing on rotate with nothing selected', () => {
|
||||
const sel = createSelection(makeBus());
|
||||
sel.rotate();
|
||||
expect(sel.get()).toBeNull();
|
||||
});
|
||||
|
||||
it('notifies listeners on change, and not on a no-op clear', () => {
|
||||
const sel = createSelection(makeBus());
|
||||
const cb = vi.fn();
|
||||
sel.onChange(cb);
|
||||
|
||||
sel.select('belt');
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
sel.rotate();
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
sel.clear();
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
|
||||
sel.clear(); // already empty — nothing changed, nobody needs waking
|
||||
expect(cb).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
92
fktry/src/ui/voice.test.ts
Normal file
92
fktry/src/ui/voice.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MachineDef, MachineKind, SimEvent } from '../contracts';
|
||||
import { machineColor } from './palette';
|
||||
import { eventToast, jamText, machineFlavor } from './voice';
|
||||
|
||||
function def(over: Partial<MachineDef> = {}): MachineDef {
|
||||
return {
|
||||
id: 'quantizer', name: 'QUANTIZER', codex: '', kind: 'crafter',
|
||||
footprint: { x: 2, y: 2 }, recipes: [], powerDraw: 0, asset: 'quantizer', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
const named = (id: number) => (id === 5 ? 'MOSH REACTOR' : `UNIT ${id}`);
|
||||
|
||||
describe('machineFlavor', () => {
|
||||
it('prefers the codex line from data once LANE-DATA fills it', () => {
|
||||
expect(machineFlavor(def({ flavor: 'The dial goes to CRIME.' }), 'quantizer'))
|
||||
.toBe('The dial goes to CRIME.');
|
||||
});
|
||||
|
||||
it('falls back to the hand-transcribed line while data is empty', () => {
|
||||
expect(machineFlavor(def(), 'quantizer')).toContain('Rounds coefficients to zero');
|
||||
});
|
||||
|
||||
it('gives an unknown machine generic copy rather than nothing', () => {
|
||||
expect(machineFlavor(def({ id: 'wormhole' }), 'wormhole')).toContain('Industrial equipment');
|
||||
});
|
||||
|
||||
it('survives a missing def', () => {
|
||||
expect(machineFlavor(undefined, 'belt')).toContain('Moving things is the entire job');
|
||||
});
|
||||
});
|
||||
|
||||
describe('jamText', () => {
|
||||
it('distinguishes backed-up from starved', () => {
|
||||
expect(jamText('output full')).toBe('OUTPUT BUFFER AT CAPACITY. THIS IS A YOU PROBLEM.');
|
||||
expect(jamText('starved')).toBe('INTAKE STARVED. NOTHING IS ARRIVING.');
|
||||
});
|
||||
|
||||
it('passes an unknown reason through uppercased rather than swallowing it', () => {
|
||||
expect(jamText('belt on fire')).toBe('BELT ON FIRE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventToast', () => {
|
||||
it('names the machine that jammed and says why', () => {
|
||||
const e: SimEvent = { kind: 'jammed', entity: 5, reason: 'starved', tick: 1 };
|
||||
expect(eventToast(e, named)).toBe('MOSH REACTOR: INTAKE STARVED. NOTHING IS ARRIVING.');
|
||||
});
|
||||
|
||||
it('has different copy for a brownout arriving and lifting', () => {
|
||||
const on = eventToast({ kind: 'brownout', on: true, tick: 1 }, named);
|
||||
const off = eventToast({ kind: 'brownout', on: false, tick: 2 }, named);
|
||||
expect(on).not.toBe(off);
|
||||
expect(on).toContain('STILL LIFE');
|
||||
});
|
||||
|
||||
it('reports a thermal scram against the machine that scrammed', () => {
|
||||
const e: SimEvent = { kind: 'scram', entity: 5, on: true, tick: 9 };
|
||||
expect(eventToast(e, named)).toContain('MOSH REACTOR');
|
||||
expect(eventToast(e, named)).toContain('THERMAL SCRAM');
|
||||
});
|
||||
|
||||
it('stays quiet for the high-frequency events', () => {
|
||||
for (const e of [
|
||||
{ kind: 'shipped', item: 'melt', count: 1, tick: 1 },
|
||||
{ kind: 'crafted', entity: 1, recipe: 'mosh', tick: 1 },
|
||||
{ kind: 'placed', entity: 1, def: 'belt', tick: 1 },
|
||||
{ kind: 'removed', entity: 1, def: 'belt', tick: 1 },
|
||||
] as SimEvent[]) {
|
||||
expect(eventToast(e, named)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('machineColor', () => {
|
||||
it('prefers the art-directed color from data', () => {
|
||||
expect(machineColor(def({ color: '#123456' }))).toBe('#123456');
|
||||
});
|
||||
|
||||
it('falls back to the codex-derived id color', () => {
|
||||
expect(machineColor(def({ id: 'mosh-reactor' }))).toBe('#ff7a3f');
|
||||
});
|
||||
|
||||
it('falls back to a kind color for a machine with no entry', () => {
|
||||
expect(machineColor(def({ id: 'brand-new-thing', kind: 'power' }))).toBe('#3fffe0');
|
||||
});
|
||||
|
||||
it('always returns something for an unheard-of kind', () => {
|
||||
expect(machineColor(def({ id: 'x', kind: 'teleporter' as MachineKind }))).toMatch(/^#/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user