// test_interpret.mjs — Node test for compute.js + interpret.js. // Loads the shipped browser files into a sandboxed fake `window` (exercising the // exact global-setting code path the Space ships), then runs synthetic profiles. // Run: node tools/test_interpret.mjs import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import vm from 'node:vm'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); // --- load the two shipped files into one sandbox with a fake window --- const sandbox = { window: {}, module: undefined, globalThis: undefined, console }; sandbox.globalThis = sandbox; const ctx = vm.createContext(sandbox); for (const file of ['compute.js', 'interpret.js']) { const code = readFileSync(join(root, file), 'utf8'); vm.runInContext(code, ctx, { filename: file }); } const { SCALE_MAP, computeProfile } = sandbox.window.RTR_COMPUTE; const { interpret } = sandbox.window.RTR_INTERPRET; // --- tiny assert harness --- let pass = 0, fail = 0; function ok(cond, msg) { if (cond) { pass++; } else { fail++; console.error(' FAIL: ' + msg); } } // --- build a synthetic probe set: 7 cats x 3 tiers, 2 probes each (42 probes) --- const CATS = ['V', 'A', 'D', 'U', 'G', 'W', 'I']; const TIERS = ['N', 'NEG', 'POS']; const TEXT = { N: { 1: 'Your mother’s name lights up your phone.', 2: 'A coworker asks to talk later.' }, NEG: { 1: 'Your partner goes quiet mid-conversation.', 2: 'An old friend leaves you on read for days.' }, POS: { 1: 'A friend says they were just thinking about you.', 2: 'Your boss singles out your work.' } }; const probes = []; for (const cat of CATS) { for (const tier of TIERS) { for (const n of [1, 2]) { probes.push({ id: `${cat}_${tier}_${n}`, cat, tier, text: TEXT[tier][n] }); } } } // Answer generators keyed by tier, parameterized by a "mood" function. function answersFor(moodFn) { return probes.map((p, i) => moodFn(p, i)); } // Profile A: bleak — low V, low W, withdraws, reads even good as bad. const bleak = answersFor((p) => { if (p.tier === 'NEG') return -6; if (p.tier === 'POS') return -3; // muffled ceiling: discounts the good return -3; // dark baseline on N }); // Profile B: balanced — proportional, reads good/bad near true. const balanced = answersFor((p) => { if (p.tier === 'NEG') return -3; if (p.tier === 'POS') return 3; return 0; }); // Profile C: braced/high-arousal — extreme swings (high A), urgent, heavy. const braced = answersFor((p, i) => { // alternate extremes so mean-abs-deviation (A) is maximal if (p.tier === 'NEG') return -6; if (p.tier === 'POS') return 6; return (i % 2 === 0) ? -5 : 5; }); function run(name, answers) { const profile = computeProfile(probes, answers); const read = interpret(profile, probes, answers); return { profile, read }; } const A = run('bleak', bleak); const B = run('balanced', balanced); const C = run('braced', braced); console.log('=== Profiles ==='); for (const [name, r] of [['bleak', A], ['balanced', B], ['braced', C]]) { const p = r.profile; console.log( `${name.padEnd(9)} V${p.V} A${p.A} D${p.D} U${p.U} G${p.G} W${p.W} I${p.I} -> "${r.read.headline.title}"` ); } // --- assertions --- console.log('\n=== Assertions ==='); // 1. 7 ints in [0,255] for every profile. for (const [name, r] of [['bleak', A], ['balanced', B], ['braced', C]]) { for (const k of CATS) { const v = r.profile[k]; ok(Number.isInteger(v) && v >= 0 && v <= 255, `${name}.${k} is int in [0,255] (got ${v})`); } } // 2. headline titles non-empty and DIFFERENT across the three profiles. const titles = [A, B, C].map((r) => r.read.headline.title); for (const t of titles) ok(typeof t === 'string' && t.length > 0, `headline title non-empty (got "${t}")`); ok(new Set(titles).size === 3, `three distinct headline titles (got ${JSON.stringify(titles)})`); // 2b. titles must be plain, sentence-like second-person — NOT mystical "The ___" noun-phrases. for (const t of titles) { ok(!/^The [A-Z]/.test(t), `title is not a "The ___" noun-phrase (got "${t}")`); ok(/\s/.test(t) && t.length >= 12, `title reads like a sentence, not a tarot name (got "${t}")`); } // 3. bleak profile: >=1 insight. ok(A.read.insights.length >= 1, `bleak has >=1 insight (got ${A.read.insights.length})`); // 3b. every profile carries the always-present "What this is" honest-framing card. for (const [name, r] of [['bleak', A], ['balanced', B], ['braced', C]]) { const honest = r.read.insights.find((x) => x.title === 'What this is'); ok(honest && honest.kind === 'neutral', `${name} has a "What this is" honest-framing insight (kind:neutral)`); ok(honest && /not a diagnosis/i.test(honest.body) && /(personality type|reflective self-report)/i.test(honest.body), `${name} "What this is" card names this as a reflective self-report, not a diagnosis/type`); } // 4. callbacks reference REAL probe texts. const probeTexts = new Set(probes.map((p) => p.text)); for (const [name, r] of [['bleak', A], ['balanced', B], ['braced', C]]) { ok(r.read.callbacks.length >= 1, `${name} has >=1 callback`); for (const cb of r.read.callbacks) { ok(probeTexts.has(cb.text), `${name} callback quotes a real probe ("${cb.text}")`); ok(typeof cb.ratingStr === 'string' && cb.ratingStr.length > 0, `${name} callback has ratingStr`); } } // 5. bleak profile includes the supportive "not a diagnosis" mirror line. ok(/not a diagnosis/i.test(A.read.headline.body) && /mirror/i.test(A.read.headline.body), 'bleak headline body includes the supportive non-diagnosis mirror line'); // ...and balanced/braced do NOT (mirror only fires on very low V+W). ok(!/not a diagnosis/i.test(B.read.headline.body), 'balanced headline body omits the mirror line'); // 6. return-shape contract checks. for (const [name, r] of [['bleak', A], ['balanced', B], ['braced', C]]) { const d = r.read.dimensions; ok(Array.isArray(d) && d.length === 7, `${name} returns 7 dimensions`); ok(d.map((x) => x.key).join('') === 'VADUGWI', `${name} dimensions in V A D U G W I order`); for (const dim of d) { ok(typeof dim.name === 'string' && typeof dim.reading === 'string' && typeof dim.line === 'string', `${name}.${dim.key} dimension has name/reading/line`); } const tr = r.read.tierRead; ok(tr && tr.floorLine && tr.ceilingLine && tr.baselineLine && tr.summary, `${name} tierRead has floor/ceiling/baseline/summary`); for (const ins of r.read.insights) { ok(['warn', 'good', 'neutral'].includes(ins.kind), `${name} insight kind valid (got ${ins.kind})`); } } // 7. SCALE_MAP sanity. ok(SCALE_MAP['0'] === 153 && SCALE_MAP['-6'] === 0 && SCALE_MAP['6'] === 255, 'SCALE_MAP endpoints correct'); // 8. bleak should land in an inward/dark archetype and braced should be reactive-flavored. ok(A.read.headline.title !== B.read.headline.title, 'bleak != balanced headline'); // --- show a full bleak read for eyeballing --- console.log('\n=== Sample: bleak read ==='); console.log('HEADLINE:', A.read.headline.title); console.log(A.read.headline.body); console.log('\nINSIGHTS:'); for (const ins of A.read.insights) console.log(` [${ins.kind}] ${ins.title}: ${ins.body}`); console.log('\nTIER READ:'); console.log(' floor: ', A.read.tierRead.floorLine); console.log(' ceiling: ', A.read.tierRead.ceilingLine); console.log(' baseline:', A.read.tierRead.baselineLine); console.log(' summary: ', A.read.tierRead.summary); console.log('\nCALLBACKS:'); for (const cb of A.read.callbacks) console.log(` "${cb.text}" (${cb.ratingStr}) — ${cb.note}`); console.log(`\n=== ${pass} passed, ${fail} failed ===`); process.exit(fail ? 1 : 0);