Files
retire-planner/tests/ui.smoke.js
T

111 lines
5.5 KiB
JavaScript

// Headless smoke test for site/app.js — tiny DOM shim, drives the full flow.
// Run: node tests/ui.smoke.js (exits 0 + prints SMOKE-OK)
'use strict';
const fs = require('fs'), path = require('path');
const SITE = path.join(__dirname, '..', 'site');
const registry = {};
function makeEl(id) {
const el = {
id, _html: '', _l: {}, style: {}, dataset: {}, hidden: false,
value: '', textContent: '', disabled: false,
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); },
addEventListener(t, fn) { this._l[t] = fn; },
querySelector() { return makeEl(id + '>q'); },
querySelectorAll() { return []; },
contains() { return false; },
appendChild() { },
remove() { },
classList: { add() { }, remove() { }, toggle() { }, contains() { return false; } },
};
Object.defineProperty(el, 'onclick', { set(f) { this._l.click = f; }, get() { return this._l.click; } });
Object.defineProperty(el, 'oninput', { set(f) { this._l.input = f; }, get() { return this._l.input; } });
return el;
}
function q(sel) {
const id = sel.replace(/^#/, '');
if (sel === '.panel-h') return null;
return registry[id] || (registry[id] = makeEl(id));
}
global.document = {
querySelector: q,
querySelectorAll: () => [],
addEventListener() { },
createElement: (t) => makeEl('el-' + t),
getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3)
};
const store = {};
global.localStorage = { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
global.location = { hash: '', pathname: '/index.html', origin: 'http://x' };
global.history = { replaceState() { } };
Object.defineProperty(global, 'navigator', { value: {}, configurable: true });
global.window = { scrollTo() { }, print() { }, prompt() { } }; // prompt stub: Fix 7 share fallback
global.confirm = () => true;
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
// load engine + canada + app in order (classic scripts sharing globals)
const engP = path.join(SITE, 'engine', 'engine.js'), canP = path.join(SITE, 'engine', 'canada.js'), appP = path.join(SITE, 'app.js');
global.window.RetireEngine = require(engP);
global.window.RetireCanada = require(canP);
eval(fs.readFileSync(appP, 'utf8'));
// ---- drive the UI ----
let fails = 0;
function ok(name, cond, extra) {
console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra !== undefined ? ' [' + String(extra).slice(0, 80) + ']' : ''));
if (!cond) fails++;
}
const click = id => { const el = q('#' + id); if (!el._l.click) throw new Error('no click handler on #' + id); el._l.click(); };
ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click);
click('btn-begin');
ok('quiz screen shown, step 1 = province', /Where do you live/.test(q('#step-body')._html));
// province grid: app attaches onclick to opts via querySelectorAll (shim returns [])
// → set province through state by faking a click on the first opt is impossible;
// simulate by driving Next: province unset → Next disabled.
ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true);
// emulate the option click path: app.js assigns onclick to elements from querySelectorAll;
// give it one opt for the current render:
const opt = makeEl('opt'); opt.dataset.v = 'ON'; q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
click('btn-begin'); // re-render step with patched querySelectorAll
opt._l.click();
ok('province ON selectable', registry.__probe !== 'x' && !/Pick a province/.test(q('#step-body')._html));
// walk all steps
let guard = 0;
while (!q('#btn-next').disabled && guard++ < 40) {
const h = q('#step-body')._html;
if (/runs out|gap|vision/i.test(h) && /See my plan/.test(q('#btn-next').textContent)) break;
click('btn-next');
if (/Retirement Vision|vision/i.test(q('#scr-results')._html)) break;
if (q('#scr-intro').hidden === false) break;
// results render flips hidden
if (q('#scr-results').hidden === false) break;
}
ok('reached results screen', q('#scr-results').hidden === false, 'steps=' + guard);
ok('verdict rendered', /money|sustain|runs out/i.test(q('#verdict')._html));
ok('gap cards rendered', /gcard/.test(q('#gap-cards')._html));
ok('G1 balance chart is inline SVG', /<svg/.test(q('#g1')._html));
ok('G2 income chart is inline SVG', /<svg/.test(q('#g2')._html));
ok('G3 cash chart is inline SVG', /<svg/.test(q('#g3')._html));
ok('year table has rows', (q('#yr-table')._html.match(/<tr/g) || []).length > 20);
ok('stamp has 2026 + CPP max', /tax rates 2026/.test(q('#stamp').textContent) && /1,507\.65/.test(q('#stamp').textContent), q('#stamp').textContent.slice(0, 60));
ok('vision card rendered', /Retirement Vision/.test(q('#vision-card')._html));
ok('privacy line present in HTML', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')));
// G4 async (setTimeout 30ms)
setTimeout(() => {
ok('G4 scenario bars rendered', /<svg/.test(q('#g4')._html), (q('#g4')._html || '').slice(0, 40));
// what-if slider re-render
const wi = q('#wi-spend'); wi.value = '95000';
wi._l.input && wi._l.input.call(wi);
setTimeout(() => {
ok('what-if slider updates verdict (spending 95k)', /95/.test(q('#verdict')._html + q('#wi-spend-o').textContent), q('#wi-spend-o').textContent);
ok('localStorage saved', !!store['retire-planner-v1']);
console.log(fails ? '\nSMOKE-FAIL: ' + fails : '\nSMOKE-OK all ui smoke checks passed');
process.exit(fails ? 1 : 0);
}, 500);
}, 250);