ps: auto-derive years of service from join year + retirement age
This commit is contained in:
1 parent
a0fbb390b5
commit
97cecb91a7
17 files changed
+667
-18
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user