import { describe, expect, it } from "vitest"; import { DATA, EXP_GROUPS, LATEST_YEAR, TAX_GROUPS, YEARS, type TaxGroupId, } from "../data/dataset"; import { MODEL_SWITCHES, EMPTY_POLICY, STRUCTURAL, calibrate, evaluate, reference, simulate, wageChangePct, baseBreakeven, evaluateGrowth, goalGap, growthPathParams, growthRelative, growthRelativeParams, taxRateBreakeven, profitBreakeven, behaviourFactor, growthPathDerived, growthReconciliation, type ScenarioParams, hoursDerived, } from "./engine"; import { SCENARIOS, describeScenario, growthVariantParams } from "./scenarios"; import { parseSearch, toSearch } from "../lib/state"; const Y = LATEST_YEAR; const neutral: ScenarioParams = { inputMode: "direct", jobsLostPct: 0, jobsReplacedPct: 0, retainedJobWagePct: 0, newJobWagePct: 100, lostJobWagePct: 100, productionGainPct: 0, wageChangePct: 0, aiImportCost: 0, savingToProfitsPct: 0, savingToPricesPct: 0, publicEfficiencyPct: 0, publicImplementationPct: 0, transferSupportPct: 0, consumptionPassthroughPct: 0, dividendToHouseholdsPct: 0, extraSupportCost: 0, newJobOutputPct: 0, hoursEmploymentPct: 0, hoursPerPersonPct: 0, hoursAnnualWagePct: 0, hoursProductivityPct: 0, growthHorizonYears: 5, growthEmploymentHiringPct: 0, growthEmploymentAiPct: 0, growthHoursHiringPct: 0, growthHoursAiPct: 0, growthProductivityHiringPct: 0, growthProductivityAiPct: 0, growthWagePassHiringPct: 100, growthWagePassAiPct: 100, growthAiImportCostHiring: 0, }; describe("data", () => { it("har verifierade år och lönesumma exklusive arbetsgivaravgifter", () => { expect(YEARS.length).toBeGreaterThan(15); for (const y of YEARS) { const d = DATA[y]!; expect(d.wages).toBeGreaterThan(0); expect(d.wages).toBeLessThan(d.compensation); } }); it("skatte- och utgiftsgrupper summerar till totalerna", () => { for (const y of YEARS) { const d = DATA[y]!; const taxSum = TAX_GROUPS.reduce((a, g) => a + d.taxes[g.id], 0); expect(taxSum).toBeCloseTo(d.taxTotal, 6); const expSum = EXP_GROUPS.reduce((a, g) => a + d.expenditure[g.id], 0); expect(expSum).toBeCloseTo(d.expenditureTotal, 6); expect(d.taxTotal + d.otherRevenue).toBeCloseTo(d.revenueTotal, 6); expect(d.revenueTotal - d.expenditureTotal).toBeCloseTo(d.balance, 6); } }); }); describe("noll förändring", () => { it("återger referensen exakt", () => { const cal = calibrate(Y); const ref = reference(cal); const res = simulate(cal, neutral, EMPTY_POLICY); expect(res.taxTotal).toBeCloseTo(ref.taxTotal, 8); expect(res.expenditureTotal).toBeCloseTo(ref.expenditureTotal, 8); expect(res.balance).toBeCloseTo(ref.balance, 8); }); it("referensen återger basårets officiella skatteintäkter", () => { const cal = calibrate(Y); const ref = reference(cal); expect(ref.taxTotal).toBeCloseTo(DATA[Y]!.taxTotal, 6); expect(ref.expenditureTotal).toBeCloseTo(DATA[Y]!.expenditureTotal, 6); expect(ref.balance).toBeCloseTo(DATA[Y]!.balance, 6); }); }); describe("jobbantaganden", () => { it("100 jobb, 10 försvinner, 5 nya med samma lön ger −5 procent", () => { const p: ScenarioParams = { ...neutral, inputMode: "jobs", jobsLostPct: 10, jobsReplacedPct: 50, newJobWagePct: 100, }; expect(wageChangePct(p)).toBeCloseTo(-5, 10); }); it("halva lönen i de nya jobben ger −7,5 procent", () => { expect( wageChangePct({ ...neutral, inputMode: "jobs", jobsLostPct: 10, jobsReplacedPct: 50, newJobWagePct: 50, }), ).toBeCloseTo(-7.5, 10); }); it("högre löneinkomst i kvarvarande jobb räknas separat", () => { expect( wageChangePct({ ...neutral, inputMode: "jobs", jobsLostPct: 10, jobsReplacedPct: 50, newJobWagePct: 100, retainedJobWagePct: 2, }), ).toBeCloseTo(-3.2, 10); }); it("direkt läge ignorerar jobbantagandena, så de aldrig överlappar", () => { expect( wageChangePct({ ...neutral, inputMode: "direct", wageChangePct: -3, jobsLostPct: 30, }), ).toBe(-3); }); it("beskrivningen bygger på samma löneförändring som beräkningen", () => { const p: ScenarioParams = { ...neutral, inputMode: "jobs", jobsLostPct: 10, jobsReplacedPct: 50, newJobWagePct: 100, }; expect(describeScenario(p).join(" ")).toContain("−5,0 procent"); expect(simulate(calibrate(Y), p).detail.wageChangePct).toBeCloseTo(-5, 10); }); it("produktivitetsförvalet innehåller löneantagande för kvarvarande jobb", () => { const productivity = SCENARIOS.find((s) => s.id === "productivity"); if (!productivity) throw new Error("Produktivitetsscenariot saknas"); expect(productivity.params.retainedJobWagePct).toBeGreaterThan(0); expect(describeScenario(productivity.params).join(" ")).toContain("kvarvarande jobben"); expect(simulate(calibrate(Y), productivity.params).detail.wageChangePct).toBeCloseTo( wageChangePct(productivity.params), 10, ); }); }); describe("löner och arbetsgivaravgifter används på rätt ställen", () => { const p: ScenarioParams = { ...neutral, wageChangePct: -10, transferSupportPct: 50, publicEfficiencyPct: 0, }; it("omställningsstödet räknas på löner (D11), inte på D1", () => { const d = DATA[Y]!; const res = simulate(calibrate(Y), p); const lostCompensation = d.compensation * calibrate(Y).privateWageShare * 0.1; const onD1 = lostCompensation * 0.5; const onD11 = lostCompensation * (d.wages / d.compensation) * 0.5; expect(res.detail.support).toBeCloseTo(onD11, 6); expect(res.detail.support).toBeLessThan(onD1 - 10); }); it("arbetskraftskostnaden är D1 plus löneskattedelen av D29, inte bara D1", () => { const d = DATA[Y]!; const cal = calibrate(Y); const res = simulate(cal, p); const onD1 = d.compensation * cal.privateWageShare * 0.1; expect(res.detail.grossPrivateSaving).toBeCloseTo( onD1 * (1 + cal.employerTaxRatio), 6, ); // Rättningen ökar besparingen; tidigare version räknade bara D1. expect(res.detail.grossPrivateSaving).toBeGreaterThan(onD1); expect(cal.employerTaxRatio).toBeGreaterThan(0.05); expect(cal.employerTaxRatio).toBeLessThan(0.3); }); }); describe("kontrollfall: högre löner utan högre produktion", () => { const cal = calibrate(Y); const higher: ScenarioParams = { ...neutral, wageChangePct: 5, productionGainPct: 0, savingToProfitsPct: 100, }; it("ökad arbetskraftskostnad sänker vinsten i stället för att klippas vid noll", () => { const res = simulate(cal, higher); expect(res.detail.grossPrivateSaving).toBeLessThan(0); expect(res.detail.netSaving).toBeLessThan(0); expect(res.detail.toProfits).toBeCloseTo(res.detail.netSaving, 10); expect(res.detail.corporateTaxOnChange).toBeLessThan(0); expect(res.taxes.corporate).toBeLessThan(reference(cal).taxes.corporate); }); it("produktionsökning är ett eget antagande som räknas separat", () => { const withGain = simulate(cal, { ...higher, productionGainPct: 2 }); const without = simulate(cal, higher); expect(withGain.detail.productionGain).toBeGreaterThan(0); expect(without.detail.productionGain).toBe(0); expect(withGain.detail.netSaving - without.detail.netSaving).toBeCloseTo( withGain.detail.productionGain, 10, ); }); }); describe("kontrollfall: ren kostnadsbesparing", () => { const cal = calibrate(Y); const res = simulate(cal, { ...neutral, wageChangePct: -5, savingToProfitsPct: 100, savingToPricesPct: 0, dividendToHouseholdsPct: 50, }); it("bolagsskatten på förändringen använder marginell sats, inte kalibrerad nivå", () => { expect(res.detail.corporateTaxOnChange).toBeCloseTo( res.detail.toProfits * cal.marginalProfitTax, 10, ); expect(cal.marginalProfitTax).toBeCloseTo(0.206 * 0.85, 10); }); it("utdelning tas ur vinst efter bolagsskatt och dubbelräknas inte mot kvarhållen vinst", () => { const afterTax = res.detail.toProfits - res.detail.corporateTaxOnChange; expect(res.detail.dividends + res.detail.retainedProfit).toBeCloseTo(afterTax, 10); expect(res.detail.dividends).toBeCloseTo(afterTax * 0.5, 10); expect(res.detail.dividendTax).toBeCloseTo(res.detail.dividends * 0.3, 10); }); }); describe("kontrollfall: offentlig effektivisering", () => { const cal = calibrate(Y); const res = simulate(cal, { ...neutral, publicEfficiencyPct: 3, publicImplementationPct: 30 }); it("sänker utgifterna netto och sänker samtidigt skatterna på arbete", () => { const ref = reference(cal); expect(res.detail.netPublicSaving).toBeCloseTo( res.detail.grossPublicSaving * 0.7, 10, ); expect(res.expenditureTotal).toBeLessThan(ref.expenditureTotal); expect(res.taxes.labour_income).toBeLessThan(ref.taxes.labour_income); expect(res.detail.support).toBe(0); }); }); describe("jobbens sammansättning och stödbehov", () => { const cal = calibrate(Y); it("annan snittlön i försvunna jobb bevarar den ursprungliga lönesumman", () => { const a = wageChangePct({ ...neutral, inputMode: "jobs", jobsLostPct: 10, jobsReplacedPct: 0, lostJobWagePct: 100 }); const b = wageChangePct({ ...neutral, inputMode: "jobs", jobsLostPct: 10, jobsReplacedPct: 0, lostJobWagePct: 60 }); expect(a).toBeCloseTo(-10, 10); expect(b).toBeCloseTo(-6, 10); // Neutralt fall: inga jobb borta → oförändrad lönesumma oavsett relativlön. expect( wageChangePct({ ...neutral, inputMode: "jobs", jobsLostPct: 0, lostJobWagePct: 60 }), ).toBeCloseTo(0, 10); }); it("högre löner för kvarvarande tar inte bort stödbehovet för dem som förlorat jobbet", () => { const base: ScenarioParams = { ...neutral, inputMode: "jobs", jobsLostPct: 10, jobsReplacedPct: 0, transferSupportPct: 50, }; const flat = simulate(cal, base); const rich = simulate(cal, { ...base, retainedJobWagePct: 12 }); expect(rich.detail.wageChangePct).toBeGreaterThan(0); expect(rich.detail.support).toBeCloseTo(flat.detail.support, 10); expect(rich.detail.support).toBeGreaterThan(0); }); }); describe("kapitalvinstskatt", () => { it("följer inte lönesumman", () => { const cal = calibrate(Y); const ref = reference(cal); const res = simulate(cal, { ...neutral, wageChangePct: -10, savingToProfitsPct: 0 }); expect(res.taxes.household_capital).toBeCloseTo(ref.taxes.household_capital, 6); }); }); describe("A → B → C och uppdelningen", () => { const scen = SCENARIOS[2]!.params; const cases: { name: string; policy: Parameters[2] }[] = [ { name: "inga åtgärder", policy: EMPTY_POLICY }, { name: "bara skatteändring", policy: { tax: { corporate: 20 }, spending: {} } }, { name: "bara utgiftsändring", policy: { tax: {}, spending: { other: -10 } } }, { name: "både skatt och utgifter", policy: { tax: { corporate: 20, vat: 5, labour_income: -3 }, spending: { health_care: 4, social_protection: -6 }, }, }, ]; for (const c of cases) { it(`summerar exakt (${c.name})`, () => { const cal = calibrate(Y); const ev = evaluate(cal, scen, c.policy); const sum = ev.contributions.reduce((a, x) => a + x.amount, 0); expect(sum).toBeCloseTo(ev.final.balance - ev.ref.balance, 6); expect(ev.balanceChangeAi + ev.balanceChangePolicy).toBeCloseTo( ev.final.balance - ev.ref.balance, 6, ); }); it(`varje effekt räknas exakt en gång (${c.name})`, () => { const cal = calibrate(Y); const ev = evaluate(cal, scen, c.policy); const aiSum = ev.contributions .filter((x) => x.kind === "ai") .reduce((a, x) => a + x.amount, 0); const polSum = ev.contributions .filter((x) => x.kind === "policy") .reduce((a, x) => a + x.amount, 0); expect(aiSum).toBeCloseTo(ev.balanceChangeAi, 6); expect(polSum).toBeCloseTo(ev.balanceChangePolicy, 6); expect(new Set(ev.contributions.map((x) => x.id)).size).toBe( ev.contributions.length, ); }); } it("reproducerar den rapporterade dubbelräkningen som rättad", () => { // Tidöprofilen, basår 2024, scenariot "AI ersätter mycket arbete", // bolagsvinster +20 procent relativt. const cal = calibrate(2024); const ev = evaluate(cal, SCENARIOS[2]!.params, { tax: { corporate: 20 }, spending: {} }); const sum = ev.contributions.reduce((a, x) => a + x.amount, 0); expect(sum).toBeCloseTo(ev.final.balance - ev.ref.balance, 6); // Bolagsskattebidraget i AI-delen innehåller ingen policyeffekt. const profit = ev.contributions.find((x) => x.id === "profit")!; expect(profit.amount).toBeCloseTo( ev.ai.taxes.corporate - ev.ref.taxes.corporate, 8, ); }); }); describe("egna skatteändringar", () => { it("höjd beskattning ger mer, men inte proportionellt", () => { const cal = calibrate(Y); const base = simulate(cal, neutral, EMPTY_POLICY); const raised = simulate(cal, neutral, { tax: { vat: 10 }, spending: {} }); const ratio = raised.taxes.vat / base.taxes.vat; expect(ratio).toBeGreaterThan(1); expect(ratio).toBeLessThan(1.1); }); it("utgiftsändring ändrar utgifterna i rätt riktning", () => { const cal = calibrate(Y); const base = simulate(cal, neutral, EMPTY_POLICY); const cut = simulate(cal, neutral, { tax: {}, spending: { education: -10 } }); expect(cut.expenditure.education).toBeCloseTo(base.expenditure.education * 0.9, 6); }); }); describe("val av profil påverkar beräkningen", () => { it("olika basår ger olika resultat", () => { const a = evaluate(calibrate(2010), SCENARIOS[1]!.params); const b = evaluate(calibrate(2024), SCENARIOS[1]!.params); expect(Math.abs(a.taxChangeAi - b.taxChangeAi)).toBeGreaterThan(1); }); }); describe("delade länkar", () => { it("återskapar samma antaganden och resultat", () => { const state = parseSearch({ p: "tido-2022", y: 2024, s: "capital" }); const mutated = { ...state, params: { ...state.params, aiImportCost: 210, inputMode: "direct" as const }, policy: { tax: { corporate: 15 } as Partial>, spending: {} }, }; const round = parseSearch(toSearch(mutated)); expect(round.params).toEqual(mutated.params); expect(round.policy).toEqual(mutated.policy); const before = evaluate(calibrate(mutated.baseYear), mutated.params, mutated.policy); const after = evaluate(calibrate(round.baseYear), round.params, round.policy); expect(after.final.balance).toBeCloseTo(before.final.balance, 10); }); it("bevarar basår, AI-antaganden och egna åtgärder i interna länksökningar", () => { const state = parseSearch({ p: "tido-2022", y: 2024, s: "capital", jl: 18, jr: 40, rw: 2, jw: 95, ai: 175, tx: "corporate:20,vat:5", ex: "education:-4", }); const round = parseSearch(toSearch(state)); expect(round.periodId).toBe("tido-2022"); expect(round.baseYear).toBe(2024); expect(round.scenarioId).toBe("capital"); expect(round.params.jobsLostPct).toBe(18); expect(round.params.retainedJobWagePct).toBe(2); expect(round.params.newJobWagePct).toBe(95); expect(round.params.aiImportCost).toBe(175); expect(round.policy.tax.corporate).toBe(20); expect(round.policy.tax.vat).toBe(5); expect(round.policy.spending.education).toBe(-4); }); it("markerar äldre modellversion", () => { expect(parseSearch({ mv: "m1.0.0" }).staleVersion?.model).toBe("m1.0.0"); expect(parseSearch({}).staleVersion).toBeNull(); }); }); describe("tillväxtscenariot", () => { const cal = calibrate(Y); const growth = SCENARIOS.find((s) => s.id === "growth")!.params; it("ger noll skillnad när de två vägarna har identiska antaganden", () => { const same = { ...growth, growthEmploymentHiringPct: 5, growthEmploymentAiPct: 5, growthHoursHiringPct: 1, growthHoursAiPct: 1, growthProductivityHiringPct: 6, growthProductivityAiPct: 6, growthWagePassHiringPct: 80, growthWagePassAiPct: 80, growthAiImportCostHiring: growth.aiImportCost, }; const e = evaluateGrowth(cal, same, EMPTY_POLICY); expect(e.taxChange).toBeCloseTo(0, 6); expect(e.final.balance).toBeCloseTo(e.ref.balance, 6); expect(e.twoPathTaxDiff).toBeCloseTo(0, 6); }); it("ger lägre skatteintäkter men inget omställningsstöd när AI-vägen ger färre jobb", () => { const e = evaluateGrowth(cal, growth, EMPTY_POLICY); expect(e.taxChange).toBeLessThan(0); const support = evaluate(cal, growthRelativeParams(growth), EMPTY_POLICY); expect(support.final.expenditure.social_protection).toBeCloseTo( support.ref.expenditure.social_protection, 6, ); }); it("mäter mot samma referens som övriga scenarier, egna åtgärder separat", () => { const policy = { tax: { corporate: 10 }, spending: {} }; const e = evaluateGrowth(cal, growth, policy); const plain = evaluateGrowth(cal, growth, EMPTY_POLICY); expect(e.baseline.taxTotal).toBeCloseTo(plain.ref.taxTotal, 6); expect(e.taxChangePolicy).not.toBeCloseTo(0, 3); expect(e.taxChangePath + e.taxChangePolicy).toBeCloseTo(e.taxChange, 6); }); it("räknar båda vägarna från samma historiska utgångsläge", () => { const e = evaluateGrowth(cal, growth, EMPTY_POLICY); expect(e.ref.taxTotal).toBeCloseTo(reference(cal).taxTotal, 6); expect(e.taxChange).toBeCloseTo(e.final.taxTotal - e.ref.taxTotal, 6); }); }); describe("kompensation och brytpunkt", () => { const cal = calibrate(Y); const capital = SCENARIOS.find((s) => s.id === "capital")!.params; const ev = evaluate(cal, capital, EMPTY_POLICY); it("delar upp skatteintäkterna i arbete och övrigt utan rest", () => { for (const r of [ev.ref, ev.final]) expect(r.taxByOrigin.labour + r.taxByOrigin.other).toBeCloseTo(r.taxTotal, 6); }); it("når skattemålet genom faktisk modellkörning", () => { const b = baseBreakeven(cal, capital, EMPTY_POLICY, ev.ref, "taxes", ["profits"]); expect(b).not.toBeNull(); expect(b!.extraTax).toBeGreaterThan(0); expect(goalGap("taxes", ev.ref, b!.result)).toBeLessThan(0.1); }); it("kräver mer för målet om även utgifterna ska täckas", () => { const taxes = baseBreakeven(cal, capital, EMPTY_POLICY, ev.ref, "taxes", ["profits"]); const fin = baseBreakeven(cal, capital, EMPTY_POLICY, ev.ref, "finances", ["profits"]); expect(fin).not.toBeNull(); expect(fin!.total).toBeGreaterThan(taxes!.total); expect(goalGap("finances", ev.ref, fin!.result)).toBeLessThan(0.1); }); it("dubbelräknar inte när flera kanaler kombineras", () => { const combo = baseBreakeven(cal, capital, EMPTY_POLICY, ev.ref, "taxes", [ "profits", "consumption", ]); expect(combo).not.toBeNull(); expect(goalGap("taxes", ev.ref, combo!.result)).toBeLessThan(0.1); const sum = combo!.perChannel.reduce((a, c) => a + c.amount, 0); expect(sum).toBeCloseTo(combo!.total, 6); }); it("hittar en relativ skattehöjning som når målet", () => { const r = taxRateBreakeven(cal, capital, EMPTY_POLICY, ev.ref, "taxes", ["corporate", "vat"]); expect(r).not.toBeNull(); expect(r!.pct).toBeGreaterThan(0); expect(goalGap("taxes", ev.ref, r!.result)).toBeLessThan(0.1); }); it("hanterar vändpunkten i beteendeformeln (kapitalgruppen, −0,1 % lönesumma)", () => { const s: ScenarioParams = { ...neutral, inputMode: "direct", wageChangePct: -0.1 }; const e = evaluate(cal, s, EMPTY_POLICY); const gap = goalGap("taxes", e.ref, e.ai); expect(gap).toBeGreaterThan(0); // Matematisk kontroll: e = 0,5 ger maximum vid +50 % och ursprungsnivå vid +100 %. expect(behaviourFactor(50, 0.5)).toBeCloseTo(1.125, 9); expect(behaviourFactor(100, 0.5)).toBeCloseTo(1, 9); const at50 = simulate(cal, s, { tax: { household_capital: 50 }, spending: {} }); expect(at50.taxTotal - e.ai.taxTotal).toBeGreaterThan(gap); const r = taxRateBreakeven(cal, s, EMPTY_POLICY, e.ref, "taxes", ["household_capital"]); expect(r).not.toBeNull(); expect(r!.pct).toBeGreaterThan(0); expect(r!.pct).toBeLessThan(50); expect(goalGap("taxes", e.ref, r!.result)).toBeLessThanOrEqual(1e-6); // Minsta höjning: strax under lösningen nås inte målet. const below = simulate(cal, s, { tax: { household_capital: r!.pct - 0.01 }, spending: {} }); expect(goalGap("taxes", e.ref, below)).toBeGreaterThan(0); }); it("kontrollfall m3.2 (tidigare avgränsning av privat sektor): cirka 3,337 %", () => { const saved = { ...MODEL_SWITCHES }; MODEL_SWITCHES.privateSurplusScope = false; try { const c = calibrate(Y); const s: ScenarioParams = { ...neutral, inputMode: "direct", wageChangePct: -0.1 }; const e = evaluate(c, s, EMPTY_POLICY); const r = taxRateBreakeven(c, s, EMPTY_POLICY, e.ref, "taxes", ["household_capital"]); expect(r!.pct).toBeCloseTo(3.337, 2); } finally { Object.assign(MODEL_SWITCHES, saved); } }); it("kontrollfall m3.3: marknadssektorernas lönesumma ger cirka 3,326 %", () => { const s: ScenarioParams = { ...neutral, inputMode: "direct", wageChangePct: -0.1 }; const e = evaluate(cal, s, EMPTY_POLICY); const r = taxRateBreakeven(cal, s, EMPTY_POLICY, e.ref, "taxes", ["household_capital"]); expect(r!.pct).toBeCloseTo(3.326, 2); expect(goalGap("taxes", e.ref, r!.result)).toBeLessThanOrEqual(1e-6); }); it("kontrollfall: gammal skatteavgränsning (endast D51C) ger cirka 7,333 %", () => { const saved = { ...MODEL_SWITCHES }; Object.assign(MODEL_SWITCHES, { capitalTaxInD51A: false, privateSurplusScope: false }); try { const c = calibrate(Y); const s: ScenarioParams = { ...neutral, inputMode: "direct", wageChangePct: -0.1 }; const e = evaluate(c, s, EMPTY_POLICY); const r = taxRateBreakeven(c, s, EMPTY_POLICY, e.ref, "taxes", ["household_capital"]); expect(r).not.toBeNull(); expect(r!.pct).toBeCloseTo(7.333, 2); expect(goalGap("taxes", e.ref, r!.result)).toBeLessThanOrEqual(1e-6); } finally { Object.assign(MODEL_SWITCHES, saved); } }); it("gränsfall nära vändpunkten: lösning mellan rutnätspunkterna hittas", () => { const s: ScenarioParams = { ...neutral, inputMode: "direct", // Ursprungligen kalibrerat för privat löneandel 0,72; skalas till aktuell andel. wageChangePct: (-0.774957616775229 * 0.72) / cal.privateWageShare, }; const policy = { tax: { household_capital: 0.25 }, spending: {} }; const e = evaluate(cal, s, policy); // Vid ytterligare 49,75 % (totalt +50 %, vändpunkten) överskrids målet knappt. const at = simulate(cal, s, { tax: { household_capital: 50 }, spending: {} }); const overshoot = -goalGap("taxes", e.ref, at); expect(overshoot).toBeGreaterThan(0); expect(overshoot).toBeLessThan(0.001); const r = taxRateBreakeven(cal, s, policy, e.ref, "taxes", ["household_capital"]); expect(r).not.toBeNull(); expect(r!.pct).toBeLessThanOrEqual(49.75 + 1e-9); // Kontroll genom ordinarie motor. const check = simulate(cal, s, { tax: { household_capital: 0.25 + r!.pct }, spending: {} }); expect(goalGap("taxes", e.ref, check)).toBeLessThanOrEqual(1e-6); const below = simulate(cal, s, { tax: { household_capital: 0.25 + r!.pct - 0.01 }, spending: {}, }); expect(goalGap("taxes", e.ref, below)).toBeGreaterThan(0); }); it("hanterar flera grupper och redan gjorda skatteändringar", () => { const policy = { tax: { corporate: 30, vat: -5 }, spending: {} }; const e = evaluate(cal, capital, policy); const r = taxRateBreakeven(cal, capital, policy, e.ref, "taxes", ["corporate", "vat"]); if (r) { expect(goalGap("taxes", e.ref, r.result)).toBeLessThanOrEqual(1e-6); const below = simulate(cal, capital, { tax: { corporate: 30 + r.pct - 0.01, vat: -5 + r.pct - 0.01 }, spending: {}, }); expect(goalGap("taxes", e.ref, below)).toBeGreaterThan(0); } }); it("profitBreakeven och baseBreakeven ger samma svar i tillväxtscenariot", () => { const growth = SCENARIOS.find((s) => s.id === "growth")!.params; const g = evaluateGrowth(cal, growth, EMPTY_POLICY); const ai = growthRelativeParams(growth); const b = baseBreakeven(cal, ai, EMPTY_POLICY, g.baseline, "finances", ["profits"]); const p = profitBreakeven(cal, ai, EMPTY_POLICY, g.baseline, "finances"); if (b) { expect(p!.neededSurplus).toBeCloseTo(b.total, 6); expect(goalGap("finances", g.baseline, b.result)).toBeLessThanOrEqual(1e-6); } else expect(p).toBeNull(); }); }); describe("m3.2.0: kapitalbeskattning och offentlig personal", () => { const cal = calibrate(Y); it("bokför skatt på ny utdelning i D51A, inte i D51C", () => { const s = { ...neutral, productionGainPct: 2, savingToProfitsPct: 100, dividendToHouseholdsPct: 50 }; const ref = reference(cal); const r = simulate(cal, s); expect(r.taxes.household_capital).toBeCloseTo(ref.taxes.household_capital, 9); expect(r.capitalIncomeTax).toBeGreaterThan(ref.capitalIncomeTax); expect(r.capitalTax).toBeCloseTo(r.capitalIncomeTax + r.taxes.household_capital, 9); }); it("räknar om offentlig arbetskraftskostnad till D1", () => { const r = simulate(cal, { ...neutral, publicEfficiencyPct: 2 }); expect(r.detail.publicWageLoss * (1 + cal.employerTaxRatio)).toBeCloseTo( r.detail.publicLabourCost, 9, ); expect(cal.data.compensation - r.bases.compensation).toBeCloseTo(r.detail.publicWageLoss, 9); }); it("ger omställningsstöd även till offentligt anställda som förlorar jobbet", () => { const r = simulate(cal, { ...neutral, publicEfficiencyPct: 2, transferSupportPct: 50 }); expect(r.detail.lostPublicWageIncome).toBeGreaterThan(0); expect(r.detail.support).toBeCloseTo(r.detail.lostWageIncome * 0.5, 9); }); it("härleder produktion ur jobb × arbetstid × produktivitet", () => { const growth = SCENARIOS.find((s) => s.id === "growth")!.params; for (const path of ["hiring", "ai"] as const) { const g = growthPathDerived(growth, path); expect(1 + g.productionPct / 100).toBeCloseTo( (1 + g.employmentPct / 100) * (1 + g.productivityPerJobPct / 100), 9, ); const rec = growthReconciliation(cal, growth, path); expect(rec.netSaving).toBeCloseTo(rec.productionGain - rec.dLabourCost - rec.aiImportCost, 6); } // Med oförändrad löneandel och utan AI-kostnad växer vinsten i takt med produktionen. const h = growthReconciliation(cal, { ...growth, growthAiImportCostHiring: 0 }, "hiring"); expect(h.netSaving).toBeGreaterThan(0); }); }); describe("m3.2.1: äldre tillväxtlänkar", () => { const raw = { s: "growth", mv: "m3.1.0", gwh: "7", gwa: "2", gph: "9", gpa: "4", geh: "15" }; it("upptäcker ej återskapbara inställningar", () => { const st = parseSearch(raw); expect(st.legacy.map((l) => l.key).sort()).toEqual(["gpa", "gph", "gwa", "gwh"]); expect(st.staleVersion?.model).toBe("m3.1.0"); }); it("bevarar originalparametrar och version vid ny skrivning", () => { const st = parseSearch(raw); const out = toSearch({ ...st, baseYear: st.baseYear }); expect(out["gwh"]).toBe("7"); expect(out["gpa"]).toBe("4"); expect(out["mv"]).toBe("m3.1.0"); const again = parseSearch(out); expect(again.legacy).toHaveLength(4); }); it("aktivt val att skapa nytt scenario rensar dem", () => { const st = parseSearch(raw); const out = toSearch({ ...st, legacy: [], reinterpreted: false, staleVersion: null }); expect(out["gwh"]).toBeUndefined(); expect(parseSearch(out).legacy).toHaveLength(0); expect(parseSearch(out).staleVersion).toBeNull(); }); }); describe("m3.2.1: känslighet för lönegenomslag", () => { it("ger förväntade storleksordningar", () => { const cal = calibrate(Y); const g = growthVariantParams(SCENARIOS.find((s) => s.id === "growth")!.params, "fewer"); const d = (h: number, a: number) => evaluateGrowth(cal, { ...g, growthWagePassHiringPct: h, growthWagePassAiPct: a }, EMPTY_POLICY) .twoPathTaxDiff; // m3.2:s tvåvägsmått, nu med rättat produktionsunderlag (bakgrund i appen). expect(d(100, 50)).toBeLessThan(0); expect(d(50, 50)).toBeGreaterThan(0); expect(d(100, 100)).toBeGreaterThan(d(50, 50)); }); }); describe("m3.3.0: jämförbara scenarier", () => { const cal = calibrate(Y); const growth = growthVariantParams(SCENARIOS.find((s) => s.id === "growth")!.params, "fewer"); const off = (fn: () => T): T => { const saved = { ...MODEL_SWITCHES }; MODEL_SWITCHES.privateSurplusScope = false; try { return fn(); } finally { Object.assign(MODEL_SWITCHES, saved); } }; it("ger de oberoende kontrollerade kvoterna för förvalen", () => { const r = growthRelative(growth); expect(r.employmentPct).toBeCloseTo(-1.9417, 3); expect(r.wageBillPct).toBeCloseTo(-2.8539, 3); expect(r.productionPct).toBeCloseTo(3.0752, 3); expect(r.aiImportCost).toBeCloseTo(108.38, 1); }); it("återger −18,99 mdkr med den tidigare avgränsningen av produktionsunderlaget", () => { const t = off(() => { const c = calibrate(Y); return evaluateGrowth(c, growth, EMPTY_POLICY).taxChange; }); expect(t).toBeCloseTo(-18.99, 1); }); it("är skalinvariant: dubblad gemensam tillväxt ändrar inte kvoterna", () => { const a = growthRelative(growth); const b = growthRelative({ ...growth, growthProductivityHiringPct: 15.5625, growthProductivityAiPct: ((1.13 * 1.075) - 1) * 100 + 0 }); // Gemensam faktor 1,075 i produktiviteten för båda vägarna. expect(b.productionPct).toBeCloseTo(a.productionPct, 1); }); it("räknar AI-inköp exakt en gång", () => { const s = growthRelativeParams(growth); const r0 = simulate(cal, s).detail; const r1 = simulate(cal, { ...s, aiImportCost: s.aiImportCost + 1 }).detail; expect(r1.netSaving - r0.netSaving).toBeCloseTo(-1, 9); expect(r0.netSaving).toBeCloseTo(r0.productionGain - r0.dLabourCost - r0.aiImportCost, 9); }); it("uteblivna jobb ger inget stöd, bara uttryckliga merkostnader", () => { const s = { ...growthRelativeParams(growth), transferSupportPct: 80 }; expect(simulate(cal, s).detail.support).toBeCloseTo(0, 9); expect(simulate(cal, { ...s, extraSupportCost: 12 }).detail.support).toBeCloseTo(12, 9); const direct = simulate(cal, { ...s, inputMode: "direct" }).detail.support; expect(direct).toBeGreaterThan(0); }); it("stöd vid offentlig personalminskning fungerar även i läget uteblivna jobb", () => { const s = { ...growthRelativeParams(growth), transferSupportPct: 50, publicEfficiencyPct: 2 }; const r = simulate(cal, s).detail; expect(r.lostPublicWageIncome).toBeGreaterThan(0); expect(r.support).toBeCloseTo(r.lostPublicWageIncome * 0.5, 6); }); it("nyanställningsväg lika med basåret ger AI-vägen själv", () => { const s = { ...growth, growthEmploymentHiringPct: 0, growthHoursHiringPct: 0, growthProductivityHiringPct: 0, growthAiImportCostHiring: 0 }; const rel = simulate(cal, growthRelativeParams(s)); const path = simulate(cal, growthPathParams(s, "ai")); expect(rel.taxTotal).toBeCloseTo(path.taxTotal, 6); }); it("använder marknadssektorernas avgränsning ur sektorsräkenskaperna", () => { expect(cal.privateWageShare).toBeGreaterThan(0.69); expect(cal.privateWageShare).toBeLessThan(0.75); expect(cal.privateSurplus / cal.data.operatingSurplus).toBeCloseTo(1742.638 / 2111.763, 4); }); it("markerar äldre tillväxtlänkar som omtolkade och bevarar parametrarna", () => { const st = parseSearch({ s: "growth", mv: "m3.2.1", geh: 10, rk: "x" }); expect(st.reinterpreted).toBe(true); const out = toSearch(st); expect(out["mv"]).toBe("m3.2.1"); expect(out["geh"]).toBe(10); expect(parseSearch({ s: "capital", mv: "m3.2.1" }).reinterpreted).toBe(false); }); }); describe("m3.3.0: variant Företagen växer utan fler jobb", () => { it("ändrar bara två antaganden och räknas som AI-avvikelse", async () => { const { growthVariantParams, activeGrowthVariant } = await import("./scenarios"); const cal = calibrate(Y); const base = SCENARIOS.find((s) => s.id === "growth")!.params; expect(activeGrowthVariant(base)).toBe("nojobs"); const v = growthVariantParams(base, "fewer"); expect(activeGrowthVariant(v)).toBe("fewer"); expect(activeGrowthVariant({ ...base, growthProductivityAiPct: -2 })).toBeNull(); expect(activeGrowthVariant({ ...base, growthHoursAiPct: -5 })).toBeNull(); const diff = Object.keys(v).filter((k) => (v as any)[k] !== (base as any)[k]); expect(diff.sort()).toEqual(["growthEmploymentAiPct", "growthWagePassAiPct"]); const r = growthRelative(v); expect(r.employmentPct).toBeCloseTo((1.01 / 1.03 - 1) * 100, 6); const g = evaluateGrowth(cal, v, EMPTY_POLICY); expect(g.taxChange).toBeGreaterThan(evaluateGrowth(cal, base, EMPTY_POLICY).taxChange); }); it("behåller 2024 års verifierade storleksordningar för båda varianterna och scenario 4", () => { const cal = calibrate(2024); const growthBase = SCENARIOS.find((s) => s.id === "growth")?.params; const capital = SCENARIOS.find((s) => s.id === "capital")?.params; expect(growthBase).toBeDefined(); expect(capital).toBeDefined(); if (!growthBase || !capital) return; const noJobs = evaluateGrowth(cal, growthVariantParams(growthBase, "nojobs"), EMPTY_POLICY); const fewer = evaluateGrowth(cal, growthVariantParams(growthBase, "fewer"), EMPTY_POLICY); // Tidigare förval A (m3.4.0) räknas oförändrat; m3.4.1 har förval C. const scenarioFour = evaluate(cal, { ...capital, jobsLostPct: 20, aiImportCost: 160 }, EMPTY_POLICY); const c = evaluate(cal, capital, EMPTY_POLICY); expect(capital.jobsLostPct).toBe(30); expect(capital.aiImportCost).toBe(240); expect(c.final.taxTotal - c.ref.taxTotal).toBeCloseTo(-142.0, 0); expect(c.final.expenditureTotal - c.ref.expenditureTotal).toBeCloseTo(215.3, 0); expect(c.final.balance - c.ref.balance).toBeCloseTo(-357.3, 0); expect(noJobs.taxChange).toBeCloseTo(-52.6, 0); expect(fewer.taxChange).toBeCloseTo(-20.9, 0); // m3.4.0: −118,6 → −98,6 genom rättat omställningsstöd (stödet räknas på de // förlorade jobbens lön; högre stöd beskattas och konsumeras). expect(scenarioFour.taxChangeTotal).toBeCloseTo(-98.6, 0); }); }); describe("m3.4.0: omställningsstöd, fler jobb och fyradagarsvecka", () => { const cal = calibrate(2024); const byId = (id: string) => SCENARIOS.find((s) => s.id === id)!.params; it("nya jobb tar inte bort stödbehovet", () => { const p = { ...neutral, inputMode: "jobs" as const, jobsLostPct: 6, transferSupportPct: 35 }; const a = simulate(cal, { ...p, jobsReplacedPct: 0 }).detail.support; const b = simulate(cal, { ...p, jobsReplacedPct: 120 }).detail.support; expect(a).toBeGreaterThan(0); expect(b).toBeCloseTo(a, 9); }); it("fler nya jobb än försvunna fungerar genom hela flödet", () => { const p = byId("morejobs"); expect(wageChangePct(p)).toBeCloseTo(1.2, 9); const r = simulate(cal, p); expect(r.detail.support).toBeGreaterThan(0); const va = r.detail.productionBase; expect(r.detail.productionFromNewJobs).toBeCloseTo(va * 0.072, 6); expect(r.detail.productionFromAssumption).toBe(0); // Ingen dubbelräkning: bidraget = nya jobbens produktion, inget annat. expect(r.detail.productionGain).toBeCloseTo(r.detail.productionFromNewJobs, 9); // AI-kostnaden dras exakt en gång. expect(r.detail.netSaving).toBeCloseTo(r.detail.grossPrivateSaving + r.detail.productionGain - p.aiImportCost, 9); }); it("fyradagarsvecka: 0,8 × 1,25 ger oförändrad produktion och lönesumma", () => { const p = byId("fourday"); const h = hoursDerived(p); expect(h.productionPct).toBeCloseTo(0, 9); expect(h.wageBillPct).toBeCloseTo(0, 9); expect(h.hourlyWagePct).toBeCloseTo(25, 9); const r = simulate(cal, p); expect(r.detail.dLabourCost).toBeCloseTo(0, 9); expect(r.detail.productionGain).toBeCloseTo(0, 9); expect(r.detail.support).toBe(0); expect(r.detail.netSaving).toBeCloseTo(-p.aiImportCost, 9); // productionGainPct används inte i arbetstidsläget. expect(simulate(cal, { ...p, productionGainPct: 5 }).taxTotal).toBeCloseTo(r.taxTotal, 9); }); it("delposterna summerar till resultatet i alla sex scenarier", () => { for (const s of SCENARIOS) { if (s.id === "growth") continue; const e = evaluate(cal, s.params); const sum = e.contributions.reduce((a, c) => a + c.amount, 0); expect(sum).toBeCloseTo(e.final.balance - e.ref.balance, 6); } }); it("varianten Lika många jobb återges i länkar och återställs", () => { const st = parseSearch({ s: "transition", gv: "equal" }); expect(st.params.jobsReplacedPct).toBe(100); expect(toSearch(st)["gv"]).toBe("equal"); expect(parseSearch({ s: "fourday", mv: "m3.3.1" }).staleVersion).toEqual({ model: "m3.3.1" }); }); }); import { supportBreakdown, withoutSupport, taxRateBreakevenLimit, baseBreakevenLimit } from "./engine"; import { scenarioById as __sb } from "./scenarios"; describe("granskning 2026-09-30: stödets nettokostnad och brytpunktsbesked", () => { it("återflödet stämmer mot huvudresultatets komponenter", () => { const cal = calibrate(2024); const p = __sb("transition")!.params; const w = evaluate(cal, p).final; const wo = evaluate(cal, withoutSupport(p)).final; const b = supportBreakdown(w, wo); expect(b.expenditure).toBeCloseTo(w.detail.support, 6); expect(b.taxReturn).toBeGreaterThan(0); expect(b.taxReturn).toBeLessThan(b.expenditure); expect(w.balance - wo.balance).toBeCloseTo(-b.net + (w.revenueTotal - w.taxTotal) - (wo.revenueTotal - wo.taxTotal), 6); }); it("gränsbesked ger ett högsta tillskott ≥ 0", () => { const cal = calibrate(2024); const p = __sb("capital")!.params; const e = evaluate(cal, p); const r = taxRateBreakevenLimit(cal, p, EMPTY_POLICY, e.ref, "finances", ["corporate"]); expect(r.maxExtra).toBeGreaterThanOrEqual(0); const b = baseBreakevenLimit(cal, p, EMPTY_POLICY, e.ref, "finances", ["profits"]); expect(["range", "peaked"]).toContain(b.status); }); it("skiljer intervallets slut från modellens högsta tillskott i ett isolerat fall", () => { const cal = calibrate(2024); const scenario = __sb("capital"); if (!scenario) throw new Error("Scenariot capital saknas"); const preset = scenario.params; const constructed = { ...preset, jobsLostPct: 95, transferSupportPct: 0, extraSupportCost: 3000, }; const e = evaluate(cal, constructed, EMPTY_POLICY); const gap = goalGap("finances", e.ref, e.final); const base = baseBreakeven(cal, constructed, EMPTY_POLICY, e.ref, "finances", ["profits"]); const baseLimit = baseBreakevenLimit(cal, constructed, EMPTY_POLICY, e.ref, "finances", ["profits"]); const rate = taxRateBreakeven(cal, constructed, EMPTY_POLICY, e.ref, "finances", ["vat", "excise"]); const rateLimit = taxRateBreakevenLimit(cal, constructed, EMPTY_POLICY, e.ref, "finances", ["vat", "excise"]); expect(gap).toBeCloseTo(2380.7404, 3); expect(base).toBeNull(); expect(baseLimit.status).toBe("range"); expect(baseLimit.atMaximum).toBeCloseTo(baseLimit.limit, 6); expect(baseLimit.maxExtra).toBeCloseTo(2287.9302, 3); expect(rate).toBeNull(); expect(rateLimit.status).toBe("range"); expect(rateLimit.atMaximum).toBeCloseTo(rateLimit.limit, 6); expect(rateLimit.maxExtra).toBeCloseTo(656.3366, 3); }); });