feat(store): add setRuleFrom and removeRuleFrom
setRuleFrom replaces rather than appends for a month that already has a version, keeping the effective rule unambiguous, and keeps the series sorted. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe9f6c6dec
commit
de128e3c87
@@ -302,3 +302,79 @@ describe("settings", () => {
|
||||
expect(data.fxRates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rule versions", () => {
|
||||
const pct = (percent: number) => ({ kind: "percentOfIncome" as const, percent });
|
||||
|
||||
test("setRuleFrom adds a version to a post that had none", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleFrom(data, postId, "2026-04", pct(10));
|
||||
expect(data.posts[0]!.rules).toEqual([{ from: "2026-04", rule: pct(10) }]);
|
||||
});
|
||||
|
||||
test("setRuleFrom REPLACES an existing version for the same month", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleFrom(data, postId, "2026-04", pct(10));
|
||||
actions.setRuleFrom(data, postId, "2026-04", pct(15));
|
||||
expect(data.posts[0]!.rules).toHaveLength(1);
|
||||
expect(data.posts[0]!.rules[0]!.rule).toEqual(pct(15));
|
||||
});
|
||||
|
||||
test("versions are kept sorted regardless of insertion order", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleFrom(data, postId, "2026-07", pct(15));
|
||||
actions.setRuleFrom(data, postId, "2026-04", pct(10));
|
||||
actions.setRuleFrom(data, postId, "2026-05", pct(12));
|
||||
expect(data.posts[0]!.rules.map((v) => v.from)).toEqual([
|
||||
"2026-04",
|
||||
"2026-05",
|
||||
"2026-07",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a fixed rule is rounded at the mutation boundary", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleFrom(data, postId, "2026-04", {
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.345, currency: "DKK" },
|
||||
});
|
||||
const stored = data.posts[0]!.rules[0]!.rule;
|
||||
expect(stored.kind === "fixed" && stored.amount.amount).toBe(12.35);
|
||||
});
|
||||
|
||||
test("a percentage above 100 is stored, not clamped", () => {
|
||||
const data = draft();
|
||||
actions.setRuleFrom(data, data.posts[0]!.id, "2026-04", pct(150));
|
||||
expect(data.posts[0]!.rules[0]!.rule).toEqual(pct(150));
|
||||
});
|
||||
|
||||
test("removeRuleFrom deletes exactly that version", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleFrom(data, postId, "2026-04", pct(10));
|
||||
actions.setRuleFrom(data, postId, "2026-07", pct(15));
|
||||
actions.removeRuleFrom(data, postId, "2026-04");
|
||||
expect(data.posts[0]!.rules).toEqual([{ from: "2026-07", rule: pct(15) }]);
|
||||
});
|
||||
|
||||
test("removing a month that has no version is a no-op, not an error", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleFrom(data, postId, "2026-04", pct(10));
|
||||
actions.removeRuleFrom(data, postId, "2026-09");
|
||||
expect(data.posts[0]!.rules).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("both throw on an unknown post id", () => {
|
||||
expect(() => actions.setRuleFrom(draft(), "ghost", "2026-04", pct(10))).toThrow(
|
||||
/Unknown post: ghost/,
|
||||
);
|
||||
expect(() => actions.removeRuleFrom(draft(), "ghost", "2026-04")).toThrow(
|
||||
/Unknown post: ghost/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+31
-1
@@ -1,5 +1,5 @@
|
||||
import { newId } from "../domain/seed.ts";
|
||||
import { monthOf } from "../domain/months.ts";
|
||||
import { compareMonths, monthOf } from "../domain/months.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import type {
|
||||
Currency,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Purchase,
|
||||
PurchaseId,
|
||||
Rule,
|
||||
RuleVersion,
|
||||
Schedule,
|
||||
Split,
|
||||
} from "../domain/types.ts";
|
||||
@@ -144,6 +145,35 @@ export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): voi
|
||||
[a.order, b.order] = [b.order, a.order];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or replaces the version starting at `from`, keeping `rules` sorted.
|
||||
* Replacing rather than appending is what keeps the effective rule
|
||||
* unambiguous: two versions sharing a month would both be "in effect", and
|
||||
* ruleAt would resolve to whichever the sort happened to leave last.
|
||||
*/
|
||||
export function setRuleFrom(
|
||||
draft: Dataset,
|
||||
postId: PostId,
|
||||
from: MonthId,
|
||||
rule: Rule,
|
||||
): void {
|
||||
const post = requirePost(draft, postId);
|
||||
const version: RuleVersion = { from, rule: roundRule(rule) };
|
||||
const existing = post.rules.findIndex((v) => v.from === from);
|
||||
if (existing === -1) post.rules.push(version);
|
||||
else post.rules[existing] = version;
|
||||
post.rules.sort((a, b) => compareMonths(a.from, b.from));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the version starting at `from`. Removing the earliest one means the
|
||||
* months before the next version now resolve to no rule, and allocate zero.
|
||||
*/
|
||||
export function removeRuleFrom(draft: Dataset, postId: PostId, from: MonthId): void {
|
||||
const post = requirePost(draft, postId);
|
||||
post.rules = post.rules.filter((v) => v.from !== from);
|
||||
}
|
||||
|
||||
export function addPurchase(
|
||||
draft: Dataset,
|
||||
purchase: Omit<Purchase, "id">,
|
||||
|
||||
Reference in New Issue
Block a user