169 lines
8.1 KiB
JavaScript
169 lines
8.1 KiB
JavaScript
/**
|
||
* 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 author’s 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 trigger’s 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.');
|