Files

427 lines
24 KiB
JavaScript
Raw Permalink 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.
// Headless smoke test for site/i18n.js + site/app.js — tiny DOM shim, drives the full flow
// in BOTH languages (EN run on defaults, FR run with lang:'fr' persisted).
// 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 LS = 'retire-planner-v1';
/* normalize narrow-nbsp/nbsp → space (fr-CA Intl + frNorm typography) for regex checks */
const N = s => String(s == null ? '' : s).replace(/[\u202f\u00a0]/g, ' ');
let 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);
/* shim ext (PS-AUTO-SERVICE): a real DOM creates a fresh <input> with the attribute value
on every innerHTML write; the registry reuses one #f1 element, so its stale .value let a
slider's initial oninput() wipe fields (retireAge → 0) and poisoned later slider steps.
Sync #f1.value from the rendered value="…" attribute, exactly like the browser. */
if (id === 'step-body' && typeof registry !== 'undefined' && registry.f1) {
const m = /<input[^>]*id="f1"[^>]*value="([^"]*)"/.exec(this._html);
registry.f1.value = m ? m[1] : '';
}
},
addEventListener(t, fn) { this._l[t] = fn; },
setAttribute() { }, getAttribute() { return null; },
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));
}
let store = {};
let createdEls = []; // shim ext: createElement tracking (validate() appends its .err div this way)
function buildShim() {
registry = {}; store = {}; createdEls = [];
global.document = {
title: '',
querySelector: q,
querySelectorAll: () => [],
addEventListener() { },
createElement: (t) => { const e = makeEl('el-' + t); createdEls.push(e); return e; },
getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3) + i18n lang buttons
documentElement: { style: { setProperty() { } }, setAttribute() { }, lang: 'en' }, // i18n applyI18n + app syncSheetH
};
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: { language: 'en-CA' }, 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({}) });
}
function load(lang, psState, patch) {
buildShim();
global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js'));
global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js'));
global.window.RetirePS = require(path.join(SITE, 'engine', 'pspension.js'));
// persisted state in the target language: fresh answers, dropped at the quiz start
store[LS] = psState ? JSON.stringify(psState) : JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang });
if (patch) patch(global.window); /* fixture hook: wrap engine before app.js eval (e.g. mixed-row †) */
eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8'));
eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8'));
}
let fails = 0, total = 0;
function ok(name, cond, extra) {
total++;
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(); };
/* drive the full quiz → results flow for one language, then run per-language checks. */
function run(lang, M, done) {
console.log('\n--- ' + lang.toUpperCase() + ' ---');
load(lang);
ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click);
click('btn-begin');
ok('step 1 title', M.title.test(q('#step-body')._html), N(q('#step-body')._html).slice(0, 60));
ok('step count', /1 \/ 15/.test(q('#step-count').textContent), q('#step-count').textContent);
ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true);
ok('validation message', createdEls.some(e => M.needProvince.test(N(e.textContent))));
// emulate the option click path (shim querySelectorAll patched to hand back one opt)
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', q('#btn-next').disabled === false);
// walk all steps (original gating: disabled reflects step validity)
let guard = 0, sawCpp = false, sawSeePlan = false;
while (guard++ < 40 && !q('#btn-next').disabled) {
const h = N(q('#step-body')._html);
if (M.cppStep.test(h)) sawCpp = true;
if (M.seePlan.test(q('#btn-next').textContent)) { sawSeePlan = true; break; }
click('btn-next');
if (q('#scr-results').hidden === false) break;
}
ok('reached last step (See my plan / Voir mon plan)', sawSeePlan, 'steps=' + guard);
ok('CPP start step seen', sawCpp);
click('btn-next');
ok('reached results screen', q('#scr-results').hidden === false);
const verdict = N(q('#verdict')._html);
ok('verdict rendered', M.verdict.test(verdict), verdict.slice(0, 70));
ok('gap cards rendered', /gcard/.test(q('#gap-cards')._html));
ok('G1 chart is inline SVG', /<svg/.test(q('#g1')._html));
ok('G1 title localized', M.g1Title.test(N(q('#g1')._html)), N(q('#g1')._html).slice(0, 50));
ok('G2 chart is inline SVG', /<svg/.test(q('#g2')._html));
ok('G2 legend localized', M.g2Legend.test(q('#g2')._html));
ok('G3 chart is inline SVG', /<svg/.test(q('#g3')._html));
ok('year table has rows', (q('#yr-table')._html.match(/<tr/g) || []).length > 20);
ok('year table header localized', M.tblHeader.test(q('#yr-table')._html));
ok('year summary localized', M.yrSummary.test(N(q('#yr-summary')._html)), N(q('#yr-summary')._html));
const stamp = N(q('#stamp').textContent);
ok('stamp has 2026 + CPP max (locale format)', M.stamp2026.test(stamp) && M.stampCpp.test(stamp), stamp.slice(0, 80));
ok('vision card rendered', M.vision.test(q('#vision-card')._html));
ok('document.title localized', M.docTitle.test(N(document.title)), document.title.slice(0, 60));
// G4 async (setTimeout 30ms) + live panel (120ms)
setTimeout(() => {
ok('G4 scenario bars rendered', /<svg/.test(q('#g4')._html) && M.g4Scen.test(q('#g4')._html), N(q('#g4')._html).slice(0, 60));
ok('live panel chips localized', M.panel.test(N(q('#panel-chips')._html)), N(q('#panel-chips')._html).slice(0, 70));
// 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 spending label (95k)', /95/.test(N(q('#wi-spend-o').textContent)), N(q('#wi-spend-o').textContent));
ok('wi label localized', M.wiCtrb.test(N(q('#wi-ctrb-o').textContent)), N(q('#wi-ctrb-o').textContent));
ok('localStorage saved with lang', JSON.parse(store[LS]).lang === lang);
done();
}, 500);
}, 250);
}
const EN = {
title: /Where do you live/,
docTitle: /Retire Planner/,
needProvince: /Pick a province/,
seePlan: /See my plan/,
cppStep: /Start CPP/,
verdict: /money|sustain|runs out/,
g1Title: /Will it last/,
g2Legend: /From savings/,
tblHeader: /Withdraw/,
yrSummary: /Year-by-year detail \(\d+ years\)/,
stamp2026: /tax rates 2026/, stampCpp: /1,507\.65/,
vision: /Retirement Vision/,
g4Scen: /Current plan/,
panel: /Savings at retirement/,
wiCtrb: /\/mo/,
retireStep: /Retirement age/,
fireHint: /Early retirement \(FIRE\) supported/,
mixedNote: /One partner is retired/,
};
const FR = {
title: /Où habitez-vous/,
docTitle: /Planificateur de retraite/,
needProvince: /Choisissez une province/,
seePlan: /Voir mon plan/,
cppStep: /Débuter la RPC/,
verdict: /manquez d.argent|dépenser en toute sûreté|tient jusqu/,
g1Title: /épargne tiendra-t-elle/,
g2Legend: /Épargne/,
tblHeader: /Retrait/,
yrSummary: /Détail année par année \(\d+ ans\)/,
stamp2026: /barèmes 2026/, stampCpp: /1 507,65/,
vision: /Vision de retraite/,
g4Scen: /Plan actuel/,
panel: /Épargne à la retraite/,
wiCtrb: /\/mois/,
retireStep: /Âge de la retraite/,
fireHint: /retraite anticipée \(FIRE\)/,
mixedNote: /partenaire est à la retraite/,
};
/* PS module flow: federal public servant answers → results card + chart series, per language. */
function runPs(lang, M, done) {
console.log('\n--- ' + lang.toUpperCase() + ' (PS module) ---');
load(lang, {
a: {
province: 'ON', age: 60, retireAge: 60, household: 'single',
income: 0, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0,
spendingAnnual: 50000,
psPension: true, psJoinYear: 2000, psServiceYears: 30, psAvgSalary: 100000,
psOccupation: 'it', psItLevel: 'IT-02', psItStep: 1
}, step: 0, screen: 'results', lang: lang });
setTimeout(() => {
const card = q('#ps-card');
ok('PS results card visible', card.hidden === false);
ok('legacy saved psServiceYears → psServiceOverride (pre-auto plans keep their exact numbers)',
JSON.parse(store[LS]).a.psServiceOverride === 30, JSON.stringify(JSON.parse(store[LS]).a.psServiceOverride));
const h = N(card._html);
ok('PS card title localized', M.psCard.test(h), h.slice(0, 70));
ok('PS card pension at 60 (2%×100k×30 = 60,000)', M.psAt.test(h), (h.match(M.psAt) || [''])[0]);
ok('PS card from-65 (lifetime 46,012.50)', M.psFrom.test(h), (h.match(M.psFrom) || [''])[0]);
ok('PS card bridge line (13,987.50)', M.psBridge.test(h), (h.match(M.psBridge) || [''])[0]);
ok('PS card sources link PSPC canada.ca', /href="https:\/\/www\.canada\.ca/.test(h));
ok('PS card IT agreement source (2024-12-22)', /2024-12-22/.test(h));
ok('PS card table uses non-colliding class ps-tbl (grid-on-table would blank the card)',
/<table class="ps-tbl">/.test(h) && (h.match(/<tr>/g) || []).length >= 7,
'rows=' + (h.match(/<tr>/g) || []).length);
ok('as-of line localized (' + lang + ')', lang === 'fr' ? (!/October|Oct-Dec/.test(h) && /octobre 2026/.test(h)) : /October 2026/.test(h));
ok('PS series in G2 chart', M.psSeries.test(N(q('#g2')._html)), N(q('#g2')._html).slice(0, 40));
ok('PS insight in live panel', M.psInsight.test(N(q('#panel-insight')._html)), N(q('#panel-insight')._html).slice(0, 70));
done();
}, 400);
}
/* PS module quiz click-through — regression test (2026-10-09): typing into an initially-empty
PS number step (join year / service / salary) MUST re-enable Next. Pre-fix, renderStep validated
only at render, so the button stayed disabled and a real user was STUCK on the join step.
PS-AUTO-SERVICE (2026-10-09): the ps_service step now prefills itself from age + join year +
retireAge (defaults: age 45, joined 2005 → joined at 24, retire 65 → auto 41). Walk checks:
auto prefill + derivation hint + summary use the auto value; a typed override (30) drives the
summary instead; "Use calculated" restores the auto summary. EN + FR. */
function runPsQuiz(lang, done) {
console.log('\n--- ' + lang.toUpperCase() + ' (PS quiz click-through) ---');
load(lang);
setTimeout(() => {
const opt = makeEl('opt'); opt.dataset.v = 'ON';
q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
click('btn-begin'); // re-render step 0 with patched querySelectorAll
const body = () => N(q('#step-body').innerHTML);
const typed = {};
let guard = 0, joinDisabledBefore = null;
while (guard++ < 40) {
const st = JSON.parse(store[LS]);
const step = st.step;
const next = q('#btn-next');
if (step === 0 && !typed.prov) { opt.dataset.v = 'ON'; opt._l.click(); typed.prov = 1; continue; }
if (step === 11 && !typed.ps) { typed.ps = 1; opt.dataset.v = 'yes'; opt._l.click();
ok('PS toggle: Yes picked', JSON.parse(store[LS]).a.psPension === true); continue; }
if (step === 12 && !typed.join) { typed.join = 1; joinDisabledBefore = next.disabled;
const f1 = q('#f1'); f1.value = '2005'; f1._l.input && f1._l.input.call(f1);
ok('PS join step starts disabled (empty field)', joinDisabledBefore === true, 'disabled=' + joinDisabledBefore);
ok('typing into empty PS step enables Next (regression: validate on input)', q('#btn-next').disabled === false, 'after=' + q('#btn-next').disabled);
continue; }
if (step === 13 && !typed.auto) {
typed.auto = 1;
/* PS-AUTO-SERVICE: NO typing here — auto = 65 − (45 − (2026 − 2005)) = 41 */
ok('ps_service prefilled with the auto value 41 (joined 2005 at 24, retire 65) — no typing',
/value="41"/.test(body()));
ok('ps_service auto hint shows the derivation',
lang === 'en' ? /Calculated: joined 2005 at age 24, retiring at 65 → 41 years of service/.test(body())
: /Calculé : entrée en poste en 2005 à 24 ans, départ à 65 ans → 41 années de service/.test(body()),
(body().match(/<p class="hint">[^<]*/) || [''])[0]);
ok('ps_service Next enabled from the auto value, override stays null',
q('#btn-next').disabled === false && JSON.parse(store[LS]).a.psServiceOverride == null,
'disabled=' + q('#btn-next').disabled);
ok('reset button in DOM but HIDDEN in auto mode',
/id="ps-svc-reset"[^>]*display:none/.test(body()) && /id="ps-usecalc"/.test(body()));
continue;
}
if (step === 14 && !typed.sal) { typed.sal = 1; const f1 = q('#f1'); f1.value = '90000'; f1._l.input && f1._l.input.call(f1); continue; }
if (step === 15 && !typed.occ) { typed.occ = 1; opt.dataset.v = 'it'; opt._l.click(); continue; }
if (step === 16) {
const a2 = JSON.parse(store[LS]).a;
if (!a2.psItLevel) { opt.dataset.v = 'IT-03'; opt._l.click(); continue; }
if (!a2.psItStep) { opt.dataset.v = '3'; opt._l.click(); continue; }
}
if (next.disabled) break;
click('btn-next');
if (q('#scr-results').hidden === false) break;
}
ok('PS click-through reached results', q('#scr-results').hidden === false, 'guard=' + guard);
ok('PS card rendered after click-through', !!q('#ps-card') && q('#ps-card').hidden === false);
const cardA = N(q('#ps-card').innerHTML);
ok('auto service flows to the summary: 35 years used (auto 41, engine cap 35)',
lang === 'en' ? /35 years \(max 35\)/.test(cardA) : /35 années \(max 35\)/.test(cardA), cardA.slice(0, 160));
ok('auto summary amount (S 35, avg 108,324, G1 @65): from-65 59,508.05',
lang === 'en' ? /59,508\.05/.test(cardA) : /59[\s\u00a0\u202f]508,05/.test(cardA), cardA.slice(0, 160));
setTimeout(() => {
/* back to ps_service via the REAL route (results → Edit answers → 6× Back), type an override */
click('btn-edit');
for (let i = 0; i < 6; i++) click('btn-back');
let st = JSON.parse(store[LS]);
ok('back-navigation lands on ps_service (step 13)', st.step === 13, 'step=' + st.step);
ok('ps_service shows the auto value again (override null)', /value="41"/.test(body()) && st.a.psServiceOverride == null);
const f1b = q('#f1'); f1b.value = '30'; f1b._l.input && f1b._l.input.call(f1b);
click('btn-back'); click('btn-next'); // re-render ps_service with the override in place
st = JSON.parse(store[LS]);
ok('override typed → psServiceOverride = 30 (the value the engine must use)',
st.a.psServiceOverride === 30, JSON.stringify(st.a.psServiceOverride));
const bodyO = body();
ok('override hint states the override + the auto value',
lang === 'en' ? /Using your value \(30 years\).*gives 41 years/.test(bodyO)
: /Votre valeur \(30 années\) est utilisée.*donne 41 ans/.test(bodyO),
(bodyO.match(/<p class="hint">[^<]*/) || [''])[0]);
ok('"Use calculated" button VISIBLE (not hidden) while overridden',
/id="ps-svc-reset" style="margin:8px 0 0">/.test(bodyO) && /id="ps-usecalc"/.test(bodyO),
(bodyO.match(/id="ps-svc-reset"[^>]*>/) || [''])[0]);
let g2 = 0;
while (g2++ < 12 && q('#scr-results').hidden !== false) {
if (q('#btn-next').disabled) break;
click('btn-next');
}
const cardB = N(q('#ps-card').innerHTML);
ok('override flows to the summary: 30 years used',
lang === 'en' ? /30 years \(max 35\)/.test(cardB) : /30 années \(max 35\)/.test(cardB), cardB.slice(0, 160));
ok('override summary amount (S 30): from-65 51,006.90',
lang === 'en' ? /51,006\.90/.test(cardB) : /51[\s\u00a0\u202f]006,90/.test(cardB), cardB.slice(0, 160));
setTimeout(() => {
click('btn-edit');
for (let i = 0; i < 6; i++) click('btn-back');
ok('back on ps_service with the override (button shown)',
JSON.parse(store[LS]).step === 13 && /id="ps-svc-reset" style="margin:8px 0 0">/.test(body()));
const uc = q('#ps-usecalc');
ok('"Use calculated" handler wired', !!uc && !!uc._l.click);
uc._l.click.call(uc);
st = JSON.parse(store[LS]);
ok('"Use calculated" clears the override → auto again', st.a.psServiceOverride === null, JSON.stringify(st.a.psServiceOverride));
ok('auto value prefilled + auto hint restored',
/value="41"/.test(body()) && (lang === 'en' ? /Calculated: joined 2005/.test(body()) : /Calculé : entrée en poste en 2005/.test(body())));
let g3 = 0;
while (g3++ < 12 && q('#scr-results').hidden !== false) {
if (q('#btn-next').disabled) break;
click('btn-next');
}
const cardC = N(q('#ps-card').innerHTML);
ok('use-calc restores the auto summary (35 years · 59,508.05)',
(lang === 'en' ? /35 years \(max 35\)/.test(cardC) : /35 années \(max 35\)/.test(cardC)) &&
(lang === 'en' ? /59,508\.05/.test(cardC) : /59[\s\u00a0\u202f]508,05/.test(cardC)), cardC.slice(0, 160));
const a = JSON.parse(store[LS]).a;
ok('PS state persisted (join 2005 / no override / IT-03 step 3, salary 108,324)',
a.psPension === true && a.psJoinYear === 2005 && a.psServiceOverride === null &&
a.psOccupation === 'it' && a.psItLevel === 'IT-03' && a.psItStep === 3 && a.psAvgSalary === 108324,
JSON.stringify({ j: a.psJoinYear, o: a.psOccupation, l: a.psItLevel, s: a.psItStep, sal: a.psAvgSalary }));
done();
}, 400);
}, 400);
}, 400);
}
/* 2026-10 math fix (FIRE): quiz retire step — slider floor 40 + FIRE hint, per language. */
function runFire(lang, M, done) {
console.log('\n--- ' + lang.toUpperCase() + ' (FIRE: retire age 40) ---');
load(lang);
click('btn-begin');
const opt = makeEl('opt'); opt.dataset.v = 'ON';
q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
click('btn-begin');
opt._l.click();
let guard = 0;
while (guard++ < 20) {
if (M.retireStep.test(N(q('#step-body')._html))) break;
if (q('#btn-next').disabled) break;
click('btn-next');
}
const h = N(q('#step-body')._html);
ok('retire step reached', M.retireStep.test(h), 'steps=' + guard);
ok('FIRE: quiz retire slider min=40', /min="40"/.test(h), (h.match(/<input type="range"[^>]*>/) || [''])[0]);
ok('FIRE: hint mentions FIRE support', M.fireHint.test(h), h.slice(0, 90));
ok('FIRE: slider value renders (fmt years)', /id="sv"/.test(h));
done();
}
/* 2026-10 math fix (couple mixed rows): † on Total cell + bilingual legend under the table.
The engine keys couple rows by shared AGE and both partners share retireAge, so real mixed
draw windows need divergent phases — fixture patches project() to flag 3 draw rows. */
function runMixed(lang, M, done) {
console.log('\n--- ' + lang.toUpperCase() + ' (couple mixed-row † legend) ---');
/* engine module is require-cached: wrap and RESTORE project() so the control load is clean */
const E0 = require(path.join(SITE, 'engine', 'engine.js')), orig = E0.project;
load(lang, {
a: {
province: 'ON', age: 52, household: 'couple', income: 60000, rrsp: 100000, tfsa: 30000,
nonreg: 20000, monthlyContribution: 500, retireAge: 55, spendingAnnual: 93000, cppStartAge: 60,
partner: { age: 50, income: 0, rrsp: 20000, tfsa: 10000, nonreg: 5000, monthlyContribution: 200 }
}, step: 0, screen: 'results', lang: lang
}, function (w) {
w.RetireEngine.project = function (p) {
const r = orig(p);
r.years.filter(y => y.phase === 'draw').slice(0, 3).forEach(y => { y.mixed = true; });
return r;
};
});
const tblHtml = q('#yr-table')._html;
ok('mixed rows footnoted † on Total cell (3 flagged)', (tblHtml.match(/†/g) || []).length === 3, (tblHtml.match(/†/g) || []).length);
ok('† sits inside the Total <b> cell', /<b>[^<]*\s†<\/b>/.test(tblHtml), (tblHtml.match(/<b>[^<]*†[^<]*<\/b>/) || [''])[0]);
const note = q('#yr-mixed-note');
ok('bilingual legend shown under the table', note.hidden === false && M.mixedNote.test(N(note.textContent)), N(note.textContent).slice(0, 60));
/* control: single-person results have no † and the legend stays hidden */
E0.project = orig;
load(lang, {
a: { province: 'ON', age: 45, household: 'single', income: 60000, rrsp: 100000, tfsa: 30000,
nonreg: 20000, monthlyContribution: 500, retireAge: 65, spendingAnnual: 50000 },
step: 0, screen: 'results', lang: lang });
ok('control single: no † rows, legend hidden', !/†/.test(q('#yr-table')._html) && q('#yr-mixed-note').hidden === true);
done();
}
run('en', EN, () => run('fr', FR, () => runPs('en', {
psCard: /federal public service pension/, psAt: /60,000/, psFrom: /46,012\.50/,
psBridge: /13,987\.50/, psSeries: /Public service pension/, psInsight: /covers about/
}, () => runPs('fr', {
psCard: /pension de la fonction publique fédérale/, psAt: /60[\s\u00a0\u202f]000/, psFrom: /46[\s\u00a0\u202f]012,50/,
psBridge: /13[\s\u00a0\u202f]987,50/, psSeries: /Pension de la fonction publique/, psInsight: /couvre environ/
}, () => runPsQuiz('en', () => runPsQuiz('fr', () =>
runFire('en', EN, () => runFire('fr', FR, () => runMixed('en', EN, () => runMixed('fr', FR, () => {
const idx = fs.readFileSync(path.join(SITE, 'index.html'), 'utf8');
ok('what-if retirement slider min 40 (index.html)', /id="wi-ret" min="40"/.test(idx), (idx.match(/id="wi-ret"[^>]*/) || [''])[0]);
ok('legend element present under yearly table (index.html)', /id="yr-mixed-note"/.test(idx));
ok('EN privacy line present in index.html', /never leave your browser/.test(idx));
ok('FR privacy string present in i18n.js', /ne quittent jamais votre navigateur/.test(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')));
console.log(fails ? '\nSMOKE-FAIL: ' + fails + ' of ' + total : '\nSMOKE-OK all ui smoke checks passed (' + total + ')');
process.exit(fails ? 1 : 0);
}))))))))))
;