orbit/dev/comms.test.mjs

169 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Comms model test (dev tool, run with Node — no browser):
*
* node dev/comms.test.mjs
*
* The pure rules of data/comms.json (js/comms/CommsModel.js) — the comm
* roster's contract:
* - the shipped config is VALID (enabled, OAC registered, the
* oac-intro comm wired: ack + newGame + the real assets);
* - the reserved triggers are RESERVED (no comm uses them yet);
* - normalizeComm drops what can't be shown (bad type / no trigger /
* no text for a box / no speech for an audio comm / unknown sender),
* and keeps the rest (once defaults true, delayMs clamped);
* - commsForTrigger returns the due comms in order, filtering the
* played `once` ones (and re-arming `once: false` ones);
* - asset keys are stable (per character for the clip, per file for
* the speech) and resolve to the shipped files.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFileSync } from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// Silence the model's drop warnings during the negative tests (they are
// the EXPECTED behavior; the checks below verify the drops).
const origWarn = console.warn;
let warnOn = true;
console.warn = (...a) => {
if (warnOn) origWarn(...a);
};
const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href);
const cfg = JSON.parse(readFileSync(join(root, 'data/comms.json'), 'utf8'));
config.init({ comms: cfg });
const {
commsEnabled,
listComms,
commsForTrigger,
normalizeComm,
commAssets,
charEntry,
videoKeyFor,
audioKeyFor,
COMM_TYPES,
} = await import(pathToFileURL(join(root, 'js/comms/CommsModel.js')).href);
// ----------------------------------------------------------------------
// the shipped config (the contract the hub + box build on)
// ----------------------------------------------------------------------
check('comms.json is enabled', cfg.enabled === true);
check('COMM_TYPES = ack | visual | audio', JSON.stringify(COMM_TYPES) === JSON.stringify(['ack', 'visual', 'audio']));
const oac = charEntry(cfg, 'oac');
check('OAC is registered (label + role + clip)', !!oac && oac.label === 'O.A.C.' && !!oac.role && oac.video === 'assets/videos/characters/oac.mp4');
const roster = listComms(cfg);
check('the roster holds exactly the shipped comms', roster.length === 1);
const intro = roster[0];
check(
'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)',
!!intro &&
intro.id === 'oac-intro' &&
intro.from === 'oac' &&
intro.type === 'ack' &&
intro.trigger === 'newGame' &&
intro.once === true,
);
check('oac-intro: the welcome text (the brief, verbatim)', intro?.text.includes('Welcome to your ship') && intro?.text.includes('O.A.C.'));
check('oac-intro: the parked speech clip', intro?.audio === 'assets/speech/oac/intro-01.mp3');
check('oac-intro: its delay beats the opening toast', (intro?.delayMs ?? 0) >= 1000);
check(
'the reserved triggers are reserved (no comm uses them yet)',
['researchComplete', 'researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
(t) => !roster.some((c) => c.trigger === t),
),
);
// ----------------------------------------------------------------------
// the deferred-load keys (stable + deduping)
// ----------------------------------------------------------------------
check('video key is per character', videoKeyFor('oac') === 'comms_vid_oac');
check('audio key is per file (dedupes shared clips)', audioKeyFor('assets/speech/oac/intro-01.mp3') === 'comms_au_intro-01' && audioKeyFor('assets/speech/oac/intro-01.mp3') === audioKeyFor('./assets/speech/oac/intro-01.mp3'));
const assets = commAssets(cfg, intro);
check('oac-intro assets: the clip + the speech, keyed', assets.video?.key === 'comms_vid_oac' && assets.video.url === 'assets/videos/characters/oac.mp4' && assets.audio?.key === 'comms_au_intro-01');
// ----------------------------------------------------------------------
// normalizeComm — the drops (what can't be shown doesn't ship)
// ----------------------------------------------------------------------
const base = { id: 't', from: 'oac', type: 'ack', trigger: 'newGame', text: 'hi', audio: 'assets/speech/oac/intro-01.mp3' };
// The drop checks below EXPECT the warnings — mute them (the checks
// verify the drops themselves).
warnOn = false;
check('a clean comm normalizes (defaults: once=true, delayMs=0)', (() => {
const c = normalizeComm({ ...base, id: 't2' });
return !!c && c.once === true && c.delayMs === 0 && c.id === 't2';
})());
check('drop: unknown type', normalizeComm({ ...base, id: 'b1', type: 'hologram' }) === null);
check('drop: no trigger', normalizeComm({ ...base, id: 'b2', trigger: ' ' }) === null);
check('drop: ack/visual without text', normalizeComm({ ...base, id: 'b3', text: '' }) === null);
check('drop: audio comm without speech', normalizeComm({ ...base, id: 'b4', type: 'audio', audio: '' }) === null);
check('keep: an audio comm with no text (voice only)', (() => {
const c = normalizeComm({ ...base, id: 'b5', type: 'audio', text: '' });
return !!c && c.text === '' && c.type === 'audio';
})());
check('keep: once:false survives (the authors repeat)', normalizeComm({ ...base, id: 'b6', once: false })?.once === false);
check('delayMs clamps to >= 0', normalizeComm({ ...base, id: 'b7', delayMs: -50 })?.delayMs === 0);
warnOn = true;
// unknown senders are dropped by the ROSTER (listComms), not normalize
const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] };
const ghostList = listComms(withGhost);
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 1 && ghostList[0].id === 'oac-intro');
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
check('drop: duplicate ids (first wins)', dupList.length === 1 && dupList[0].id === 'oac-intro');
warnOn = true;
// ----------------------------------------------------------------------
// commsForTrigger — the due set (order + the played ledger)
// ----------------------------------------------------------------------
check('newGame arms the welcome on a fresh run', commsForTrigger(cfg, 'newGame').length === 1 && commsForTrigger(cfg, 'newGame')[0].id === 'oac-intro');
check('a played `once` comm does not re-arm', commsForTrigger(cfg, 'newGame', new Set(['oac-intro'])).length === 0);
check('an unknown trigger arms nothing', commsForTrigger(cfg, 'launchParty').length === 0);
check('a blank trigger arms nothing', commsForTrigger(cfg, ' ').length === 0);
// an `once: false` comm re-arms on every trigger (the repeat comm)
const repeatCfg = {
...cfg,
comms: [{ id: 'chatter', from: 'oac', type: 'audio', trigger: 'landing', audio: 'assets/speech/oac/intro-01.mp3', once: false }],
};
check('once:false re-arms after it played', commsForTrigger(repeatCfg, 'landing', new Set(['chatter'])).length === 1);
// a future roster: several comms on one trigger keep config order
const multiCfg = {
...cfg,
comms: [
{ id: 'a', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/research-complete.mp3' },
{ id: 'b', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/quest-new.mp3' },
{ id: 'c', from: 'oac', type: 'audio', trigger: 'questNew', audio: 'assets/speech/oac/quest-new.mp3' },
],
};
check('a triggers comms arm in config order', JSON.stringify(commsForTrigger(multiCfg, 'researchComplete').map((c) => c.id)) === JSON.stringify(['a', 'b']));
// disabled master: the model still describes the roster (the hub is the
// one that honors `enabled`) — keep the two honest.
check('a config without the section reads as enabled (pre-comms saves)', commsEnabled({}) === true && commsEnabled({ enabled: false }) === false);
// ----------------------------------------------------------------------
if (failures > 0) {
console.error(`\n${failures} comms-model check(s) failed`);
process.exit(1);
}
console.log('\nAll comms-model checks passed.');