import { describe, expect, it } from "vitest"; import { EMPTY_POLICY, calibrate, evaluate, jobAssumptionIssue, lostWageIncomeShare, newJobShare, wageChangePct, type ScenarioParams, } from "./engine"; import { SCENARIOS } from "./scenarios"; import { LATEST_YEAR } from "../data/dataset"; import { parseSearch, toSearch } from "../lib/state"; const cal = calibrate(LATEST_YEAR); const jobsScenarios = SCENARIOS.filter((s) => s.params.inputMode === "jobs"); const finiteDeep = (v: unknown): boolean => { if (typeof v === "number") return Number.isFinite(v); if (v && typeof v === "object") return Object.values(v).every(finiteDeep); return true; }; describe("utökat intervall för försvunna jobb (0–100 %)", () => { it("alla scenariers förval är oförändrade och förenliga", () => { for (const s of SCENARIOS) expect(jobAssumptionIssue(s.params)).toBeNull(); }); for (const lost of [0, 40, 99, 100]) { for (const replaced of [0, 150]) { it(`${lost} % bortfall och ${replaced} % ersättning ger ändliga resultat`, () => { for (const sc of jobsScenarios) { const p: ScenarioParams = { ...sc.params, jobsLostPct: lost, jobsReplacedPct: replaced, lostJobWagePct: 100 }; expect(jobAssumptionIssue(p)).toBeNull(); const r = evaluate(cal, p, EMPTY_POLICY); expect(finiteDeep(r)).toBe(true); expect(Number.isFinite(wageChangePct(p))).toBe(true); expect(newJobShare(p)).toBeCloseTo((lost / 100) * (replaced / 100), 10); } }); } } it("100 % bortfall utan ersättning och oförändrad lön: lönesumman faller 100 %", () => { const p = { ...jobsScenarios[0]!.params, jobsLostPct: 100, jobsReplacedPct: 0, lostJobWagePct: 100, retainedJobWagePct: 0 }; expect(wageChangePct(p)).toBeCloseTo(-100, 10); expect(lostWageIncomeShare(p)).toBeCloseTo(1, 10); }); it("100 % bortfall med 100 % ersättning och samma lön: lönesumman oförändrad", () => { const p = { ...jobsScenarios[0]!.params, jobsLostPct: 100, jobsReplacedPct: 100, lostJobWagePct: 100, newJobWagePct: 100 }; expect(wageChangePct(p)).toBeCloseTo(0, 10); }); it("99 % och 100 % bortfall ligger nära varandra (ingen hopp vid gränsen)", () => { const base = { ...jobsScenarios[0]!.params, lostJobWagePct: 100, jobsReplacedPct: 50 }; const a = wageChangePct({ ...base, jobsLostPct: 99 }); const b = wageChangePct({ ...base, jobsLostPct: 100 }); expect(Math.abs(a - b)).toBeLessThan(2); }); it("värden inom tidigare intervall räknas som förut", () => { const p = { ...jobsScenarios[0]!.params, jobsLostPct: 10, jobsReplacedPct: 50, lostJobWagePct: 100, newJobWagePct: 100, retainedJobWagePct: 0 }; expect(wageChangePct(p)).toBeCloseTo(-5, 10); }); }); describe("validering av oförenliga jobbantaganden", () => { const b = jobsScenarios[0]!.params; it("100 % bortfall kräver att de försvunna jobbens lön är 100 % av snittet", () => { expect(jobAssumptionIssue({ ...b, jobsLostPct: 100, lostJobWagePct: 90 })).not.toBeNull(); expect(jobAssumptionIssue({ ...b, jobsLostPct: 100, lostJobWagePct: 110 })).not.toBeNull(); }); it("de försvunna jobbens lönesumma får inte överstiga hela lönesumman", () => { expect(jobAssumptionIssue({ ...b, jobsLostPct: 80, lostJobWagePct: 130 })).not.toBeNull(); expect(jobAssumptionIssue({ ...b, jobsLostPct: 80, lostJobWagePct: 125 })).toBeNull(); }); it("bortfall utanför 0–100 och negativ ersättning avvisas utan klampning", () => { expect(jobAssumptionIssue({ ...b, jobsLostPct: 101 })).not.toBeNull(); expect(jobAssumptionIssue({ ...b, jobsLostPct: -1 })).not.toBeNull(); expect(jobAssumptionIssue({ ...b, jobsReplacedPct: -5 })).not.toBeNull(); }); }); describe("delningslänk med 100 % bortfall", () => { it("värdet följer med oförändrat", () => { const s = parseSearch({ s: jobsScenarios[0]!.id, jl: 100 }); expect(s.params.jobsLostPct).toBe(100); expect(toSearch(s)["jl"]).toBe(100); expect(parseSearch(toSearch(s) as never).params.jobsLostPct).toBe(100); }); });