summarum/src/workspace.test.ts

150 lines
6.1 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { SumEngine } from "./engine";
import { Workspace, SheetSource } from "./workspace";
function makeWorkspace(initial: Record<string, { title: string; text: string }>) {
const engine = new SumEngine();
const store = new Map(Object.entries(initial).map(([id, v]) => [id, { id, ...v }]));
const sheets = (): SheetSource[] => [...store.values()];
const ws = new Workspace(engine, sheets);
return { engine, store, ws };
}
describe("Workspace exports", () => {
it("exposes assigned variables, total and last through @Sheet.key", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500\nfood = $200" },
});
const r = ws.evaluateSheet("dash", "@Budget.rent\n@Budget.total\n@Budget.last");
expect(r[0].text).toBe("$500");
expect(r[1].text).toBe("$700");
expect(r[2].text).toBe("$200");
});
it("caches a sheet's exports until invalidated", () => {
const { ws, store } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
});
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500");
store.get("budget")!.text = "rent = $900";
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500");
ws.invalidate("budget");
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$900");
});
it("evaluates a sheet lazily — only when it's actually referenced", () => {
const { engine, ws } = makeWorkspace({
never: { title: "Never", text: "1 + 1" },
});
const spy = vi.spyOn(engine, "evaluateDocument");
ws.evaluateSheet("dash", "hello");
expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(false);
ws.evaluateSheet("dash", "@Never.total");
expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(true);
});
});
describe("Workspace invalidation", () => {
it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => {
const { ws, store } = makeWorkspace({
a: { title: "A", text: "x = 10" },
b: { title: "B", text: "z = @A.x" },
c: { title: "C", text: "y = 5" },
});
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10");
expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5");
store.get("a")!.text = "x = 99";
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10");
ws.invalidate("a");
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("99");
expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5");
});
});
describe("Workspace cycle detection", () => {
it("a true two-sheet cycle terminates instead of looping forever", () => {
const { ws } = makeWorkspace({
a: { title: "A", text: "z = @B.total" },
b: { title: "B", text: "z = @A.total" },
});
const r = ws.evaluateSheet("dash", "@A.z");
expect(r[0].value).toBeNull();
expect(r[0].error).toBeDefined();
});
it("a sheet referencing itself surfaces a circular reference immediately", () => {
const { ws } = makeWorkspace({ self: { title: "Self", text: "" } });
const text = "a = 5\nb = @Self.a";
expect(() => ws.evaluateSheet("self", text)).not.toThrow();
const r = ws.evaluateSheet("self", text);
expect(r[1].value).toBeNull();
expect(r[1].error).toBe("circular reference");
});
it("does not cache exports for a sheet whose own line hit circular reference", () => {
// Resolving @A.z evaluates A -> resolves @B.total -> evaluates B -> resolves
// @A.total -> A is already on the resolving stack, so B's own line
// (`z = @A.total`) gets the literal error "circular reference". A's own line
// only sees the secondary error 'sheet "B" has no total', so A gets cached —
// but B must not, since its LineResult[] literally contains "circular reference".
const { engine, ws } = makeWorkspace({
a: { title: "A", text: "z = @B.total" },
b: { title: "B", text: "z = @A.total" },
});
const first = ws.evaluateSheet("dash", "@A.z");
expect(first[0].error).toBe('no variable "z" in "A"');
const spy = vi.spyOn(engine, "evaluateDocument");
const second = ws.evaluateSheet("dash2", "@B.z");
expect(second[0].error).toBe('no variable "z" in "B"');
// A is cached from the first pass, so this second call re-evaluates B only.
// If exportsFor(B) had cached the poisoned result from the first pass instead,
// B's text would never be re-evaluated here.
expect(spy.mock.calls.some(([text]) => text === "z = @A.total")).toBe(true);
});
});
describe("Workspace.renameSheet", () => {
it("rewrites bare-form references to the new title", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
dash: { title: "Dashboard", text: "@Budget.rent + 1" },
});
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([{ id: "dash", text: "@Money.rent + 1" }]);
});
it("switches to bracket form when the new title needs it", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
dash: { title: "Dashboard", text: "@Budget.rent" },
});
expect(ws.renameSheet("budget", "Budget", "Monthly Budget")).toEqual([
{ id: "dash", text: "@[Monthly Budget].rent" },
]);
});
it("rewrites existing bracket-form references too", () => {
const { ws } = makeWorkspace({
trip: { title: "Trip to Lisbon", text: "food = 25" },
dash: { title: "Dashboard", text: "@[Trip to Lisbon].food" },
});
expect(ws.renameSheet("trip", "Trip to Lisbon", "Lisbon")).toEqual([{ id: "dash", text: "@Lisbon.food" }]);
});
it("never rewrites the renamed sheet's own text", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "a = 1\nb = @Budget.a" },
});
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]);
});
it("leaves unrelated sheets untouched", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
other: { title: "Other", text: "just text, no refs" },
});
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]);
});
});