ps: auto-derive years of service from join year + retirement age

This commit is contained in:
krisf committed 2026-10-09 15:41:58 -04:00
1 parent a0fbb390b5
commit 97cecb91a7
17 files changed
+667 -18

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
// PS-AUTO-SERVICE tests — auto-derivation of "years of pensionable service"
// (join year + retirement age + current age). Run: node tests/ps-auto-service.test.js
// Derivation (canonical, PS-AUTO-SERVICE plan):
// worked = currentYear − joinYear ; joinAge = age − worked ; s = retireAge − joinAge ; clamp [0, 60]
'use strict';
const P = require("../site/engine/pspension.js");
let pass = 0, fail = 0;
function ok(name, cond, detail) {
if (cond) { pass++; console.log(" PASS " + name + (detail !== undefined ? " [" + detail + "]" : "")); }
else { fail++; console.log(" FAIL " + name + (detail !== undefined ? " [" + detail + "]" : "")); }
}
const A = P.autoServiceYears;
console.log("PS auto-service tests (PS.autoServiceYears)");
console.log("---- plan cases ----");
ok("age 42, joined 2000, retire 65, year 2026 → joined at 16 → 49", A(42, 2000, 65, 2026) === 49, A(42, 2000, 65, 2026));
ok("age 30, joined 2020, retire 65, year 2026 → joined at 24 → 41", A(30, 2020, 65, 2026) === 41, A(30, 2020, 65, 2026));
ok("join year in the future (2030 @ age 40) → clamp to 0", A(40, 2030, 40, 2026) === 0, A(40, 2030, 40, 2026));
ok("retireAge < ageAtJoin (age 60, join 2020 → joined at 54, retire 50) → 0", A(60, 2020, 50, 2026) === 0, A(60, 2020, 50, 2026));
ok("retireAge = ageAtJoin (joined at 54, retire 54) → 0", A(60, 2020, 54, 2026) === 0, A(60, 2020, 54, 2026));
console.log("---- missing / invalid → null ----");
ok("age missing → null", A(null, 2000, 65, 2026) === null);
ok("age '' → null", A("", 2000, 65, 2026) === null);
ok("age 'abc' → null", A("abc", 2000, 65, 2026) === null);
ok("joinYear missing → null", A(42, null, 65, 2026) === null);
ok("joinYear '' → null", A(42, "", 65, 2026) === null);
ok("joinYear 999 (not a year) → null", A(42, 999, 65, 2026) === null);
ok("retireAge missing → null", A(42, 2000, null, 2026) === null);
ok("retireAge 0 → null", A(42, 2000, 0, 2026) === null);
ok("age 0 → null", A(0, 2000, 65, 2026) === null);
console.log("---- clamps + shapes ----");
ok("upper clamp 60 (age 20, joined 1960, retire 65 → raw 111 → 60)", A(20, 1960, 65, 2026) === 60, A(20, 1960, 65, 2026));
ok("whole years (age 43, join 2001, retire 65 → worked 25, joined at 18 → 47)", A(43, 2001, 65, 2026) === 47, A(43, 2001, 65, 2026));
ok("string inputs accepted ('42','2000','65','2026' → 49)", A("42", "2000", "65", "2026") === 49, A("42", "2000", "65", "2026"));
ok("currentYear defaults to the calendar year (joined this year, retire 65, age 25 → 40±1)",
Math.abs(A(25, new Date().getFullYear(), 65) - 40) <= 1, A(25, new Date().getFullYear(), 65));
console.log("---- engine contract (35-yr cap stays the engine's job) ----");
{
const s = A(42, 2000, 65, 2026); // 49
const r = P.computePSPension({ joinYear: 2000, retireAge: 65, serviceYears: s, avgSalary: 100000, ampe: 74600 });
ok("auto 49 feeds the engine → serviceCapped 35", r.serviceCapped === 35, r.serviceCapped);
ok("auto 49 unreduced at 65 (G1, S≥2): atRetirement = 0.01375·74600·35 + 0.02·25400·35 = 53,681.25",
Math.abs(r.pensionAtRetirement - (0.01375 * 74600 * 35 + 0.02 * 25400 * 35)) < 0.005, r.pensionAtRetirement);
const s2 = A(30, 2020, 65, 2026); // 41 → capped 35
const r2 = P.computePSPension({ joinYear: 2020, retireAge: 65, serviceYears: s2, avgSalary: 100000, ampe: 74600 });
ok("auto 41 → serviceCapped 35 (G2)", r2.serviceCapped === 35 && r2.group === 2, r2.serviceCapped + " g" + r2.group);
}
console.log("\nRESULT: " + pass + " pass, " + fail + " fail");
process.exit(fail ? 1 : 0);
+99 -9
View File
@@ -15,7 +15,17 @@ function makeEl(id) {
id, _html: '', _l: {}, style: {}, dataset: {}, hidden: false,
value: '', textContent: '', disabled: false,
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); },
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'); },
@@ -188,6 +198,8 @@ function runPs(lang, M, done) {
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]);
@@ -207,7 +219,11 @@ function runPs(lang, M, done) {
/* 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. */
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);
@@ -215,6 +231,7 @@ function runPsQuiz(lang, done) {
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) {
@@ -229,7 +246,22 @@ function runPsQuiz(lang, done) {
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.svc) { typed.svc = 1; const f1 = q('#f1'); f1.value = '30'; f1._l.input && f1._l.input.call(f1); 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) {
@@ -241,14 +273,72 @@ function runPsQuiz(lang, done) {
click('btn-next');
if (q('#scr-results').hidden === false) break;
}
const a = JSON.parse(store[LS]).a;
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);
ok('PS state persisted (join 2005 / service 30 / IT-03 step 3)',
a.psPension === true && a.psJoinYear === 2005 && a.psServiceYears === 30 && a.psOccupation === 'it' && a.psItLevel === 'IT-03' && a.psItStep === 3,
JSON.stringify({ j: a.psJoinYear, s: a.psServiceYears, o: a.psOccupation, l: a.psItLevel, st: a.psItStep }));
ok('IT step pick pre-filled salary (IT-03 s3 = 108,324)', a.psAvgSalary === 108324, a.psAvgSalary);
done();
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);
}