`Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it, whatever its currency, and `digitsFor` is gone: the sixteen call sites read the setting, and the domain functions that took a currency table purely to look digits up take a `digits: number` instead. Schema 6. The owner asked for this with the cost in front of them, and the cost is real: a dataset mixing minor units cannot be described any more. Yen at 0 beside kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank. Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md argues it in full, including what re-adding a per-currency override would take, and AGENTS.md §1 now says not to reintroduce one without revisiting the decision. The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base currency's. A dataset holding a three-decimal currency already has three-decimal amounts in it, and adopting the base's 2 would truncate the next edit of one; the maximum can only keep more precision, never less. On every dataset this app has written all four currencies record 2, so nothing rounds differently than before. Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and shows as 50; totals recompute at the new precision. Rewriting each Money is a destructive whole-dataset write, which §1 owes a backup export first, and it is lossy one way. Display turned out to be the hole in the plan. src/ui/format.ts pinned minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read the dataset at all — equally broken under per-currency digits, where a JPY rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a Settings control that display ignores would look broken at 0 and be lossy at 3, hiding precision the dataset holds. So format.ts takes digits like roundMoney does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes the formatter as an argument because it is a plain helper, not a component. currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at ZERO decimals through rounding, both FX directions, both split modes, plan division, the fold and the month view still tells a real implementation from one assuming 2 — it just cannot prove per-currency flow, because there is none. It also asserts that raising the setting to 2 changes what the fold produces. Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written straight into IndexedDB migrates to a setting of 3, the table loses its Decimals column, and editing the control persists schema 6 with digits stripped from each currency and income left at 20000.55. The same month reads 600/206 at zero decimals and 600.000/205.561 at three, so the figures recompute rather than merely reformat. Not verified here: the .ods in a real spreadsheet. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
256 lines
8.9 KiB
TypeScript
256 lines
8.9 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import {
|
|
exportDatasetJson,
|
|
parseDatasetJson,
|
|
describeDataset,
|
|
ImportValidationError,
|
|
exportFilename,
|
|
} from "./json.ts";
|
|
import { createSeedDataset } from "../domain/seed.ts";
|
|
import { monthView, yearView } from "../domain/views.ts";
|
|
import type { Dataset } from "../domain/types.ts";
|
|
|
|
function populated(): Dataset {
|
|
const data = createSeedDataset("2026-01");
|
|
data.months[0]!.income = { amount: 20000, currency: "DKK" };
|
|
data.purchases.push({
|
|
id: "p1",
|
|
date: "2026-01-15",
|
|
description: "Console",
|
|
total: { amount: 3000, currency: "DKK" },
|
|
splitMode: "percent",
|
|
splits: [
|
|
{ postId: data.posts[0]!.id, value: 70, absorbsRemainder: true },
|
|
{ postId: data.posts[2]!.id, value: 30, absorbsRemainder: false },
|
|
],
|
|
schedule: {
|
|
slices: [
|
|
{ month: "2026-01", amount: { amount: 1000, currency: "DKK" } },
|
|
{ month: "2026-02", amount: { amount: 2000, currency: "DKK" } },
|
|
],
|
|
},
|
|
});
|
|
return data;
|
|
}
|
|
|
|
describe("round trip", () => {
|
|
test("export then import yields an equal dataset", () => {
|
|
const data = populated();
|
|
expect(parseDatasetJson(exportDatasetJson(data))).toEqual(data);
|
|
});
|
|
|
|
test("derived output is identical after a round trip", () => {
|
|
const data = populated();
|
|
const restored = parseDatasetJson(exportDatasetJson(data));
|
|
expect(monthView(restored, "2026-01")).toEqual(monthView(data, "2026-01"));
|
|
expect(yearView(restored, 2026)).toEqual(yearView(data, 2026));
|
|
});
|
|
|
|
test("export is human-readable, indented JSON", () => {
|
|
expect(exportDatasetJson(createSeedDataset("2026-01"))).toContain("\n ");
|
|
});
|
|
});
|
|
|
|
describe("validation", () => {
|
|
test("rejects malformed JSON", () => {
|
|
expect(() => parseDatasetJson("{not json")).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("rejects a dataset missing a required collection", () => {
|
|
const data = populated() as any;
|
|
delete data.purchases;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/purchases/);
|
|
});
|
|
|
|
test("rejects a purchase whose splits reference an unknown post", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits[0]!.postId = "ghost";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/ghost/);
|
|
});
|
|
|
|
test("rejects a purchase with no remainder-absorbing split", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits[0]!.absorbsRemainder = false;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/absorbsRemainder/);
|
|
});
|
|
|
|
test("rejects a purchase with two remainder-absorbing splits", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits[1]!.absorbsRemainder = true;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/exactly one/i);
|
|
});
|
|
|
|
test("rejects a purchase with no splits", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits = [];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/at least one split/i);
|
|
});
|
|
|
|
test("rejects an unsupported currency", () => {
|
|
const data = populated() as any;
|
|
// CHF, not GBP: GBP is one of the currencies the app ships with now.
|
|
data.settings.baseCurrency = "CHF";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/CHF/);
|
|
});
|
|
|
|
test("rejects a malformed MonthId", () => {
|
|
const data = populated();
|
|
data.months[0]!.id = "2026-1";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-1/);
|
|
});
|
|
|
|
test("rejects a MonthId with an out-of-range month", () => {
|
|
const data = populated();
|
|
data.months[0]!.id = "2026-13";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13/);
|
|
});
|
|
});
|
|
|
|
test("describeDataset counts what an import would replace", () => {
|
|
expect(describeDataset(populated())).toEqual({
|
|
posts: 3,
|
|
months: 1,
|
|
purchases: 1,
|
|
});
|
|
});
|
|
|
|
test("exportFilename is stable and sortable", () => {
|
|
expect(exportFilename("2026-09")).toBe("budget-2026-09.json");
|
|
});
|
|
|
|
describe("purchase dates", () => {
|
|
test("accepts a full date and a month-only date", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "2026-01-15";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
data.purchases[0]!.date = "2026-01";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
test("rejects a malformed date instead of letting the fold throw later", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "not-a-date";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/not-a-date/);
|
|
});
|
|
|
|
test("rejects an out-of-range month in a purchase date", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "2026-13-01";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13-01/);
|
|
});
|
|
|
|
test("rejects an out-of-range day", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "2026-01-32";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-01-32/);
|
|
});
|
|
});
|
|
|
|
describe("rule versions on import", () => {
|
|
const pct = (percent: number) => ({ kind: "percentOfIncome" as const, percent });
|
|
|
|
test("accepts a well-formed series", () => {
|
|
const data = populated();
|
|
data.posts[0]!.rules = [
|
|
{ from: "2026-01", rule: pct(10) },
|
|
{ from: "2026-07", rule: pct(15) },
|
|
];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
test("accepts an empty series — an unbudgeted post is legal", () => {
|
|
const data = populated();
|
|
data.posts[0]!.rules = [];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
test("rejects a post whose rules is missing or not an array", () => {
|
|
const data = populated() as any;
|
|
delete data.posts[0].rules;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/rules/);
|
|
});
|
|
|
|
test("rejects a malformed from month", () => {
|
|
const data = populated();
|
|
data.posts[0]!.rules = [{ from: "2026-1", rule: pct(10) }];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-1/);
|
|
});
|
|
|
|
test("rejects an out-of-range from month", () => {
|
|
const data = populated();
|
|
data.posts[0]!.rules = [{ from: "2026-13", rule: pct(10) }];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13/);
|
|
});
|
|
|
|
test("rejects two versions sharing a month, which would be ambiguous", () => {
|
|
const data = populated();
|
|
data.posts[0]!.rules = [
|
|
{ from: "2026-04", rule: pct(10) },
|
|
{ from: "2026-04", rule: pct(15) },
|
|
];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-04/);
|
|
});
|
|
|
|
test("does NOT reject a percentage above 100", () => {
|
|
const data = populated();
|
|
data.posts[0]!.rules = [{ from: "2026-01", rule: pct(150) }];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
test("does NOT reject a negative fixed amount", () => {
|
|
const data = populated();
|
|
data.posts[0]!.rules = [
|
|
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: -500, currency: "DKK" } } },
|
|
];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
// Without this, toBase() throws MissingRateError deep inside the balance
|
|
// fold instead of at the import boundary.
|
|
test("rejects a fixed rule in an unsupported currency", () => {
|
|
const data = populated() as any;
|
|
data.posts[0].rules = [
|
|
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "XYZ" } } },
|
|
];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/XYZ/);
|
|
});
|
|
|
|
// Without this, resolveRule() falls through to the percentage branch and
|
|
// every figure downstream of it becomes NaN, silently.
|
|
test("rejects a rule of an unrecognized kind", () => {
|
|
const data = populated() as any;
|
|
data.posts[0].rules = [{ from: "2026-01", rule: { kind: "everySecondTuesday" } }];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/everySecondTuesday/);
|
|
});
|
|
});
|
|
|
|
describe("the decimal-places setting on import", () => {
|
|
test("a valid setting round-trips", () => {
|
|
const data = populated();
|
|
data.settings.digits = 0;
|
|
expect(parseDatasetJson(exportDatasetJson(data)).settings.digits).toBe(0);
|
|
});
|
|
|
|
test.each([
|
|
["missing", undefined],
|
|
["fractional", 1.5],
|
|
["negative", -1],
|
|
["beyond four places", 5],
|
|
["a string", "2"],
|
|
])("a %s setting is refused", (_label, value) => {
|
|
const data = populated() as any;
|
|
if (value === undefined) delete data.settings.digits;
|
|
else data.settings.digits = value;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("a per-currency digits left over in a hand-edited file is ignored, not refused", () => {
|
|
// An unknown field is not a reason to refuse the owner's only backup.
|
|
const data = populated() as any;
|
|
data.currencies[0].digits = 3;
|
|
const out = parseDatasetJson(JSON.stringify(data));
|
|
expect("digits" in out.currencies[0]!).toBe(false);
|
|
expect(out.settings.digits).toBe(2);
|
|
});
|
|
});
|