/** * Tests for the variable/tag system: * - declare-parser (YAML parsing) * - block-scanner (attribute parsing) * - variable-expression (expression evaluation) * - var-reactivity (dependency graph, cascade, tag activation) * - command-parser (input parsing) * - content-registry (spark table detection) */ // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- // csv-parse/browser/esm/sync and github-slugger are mapped to CJS builds // globally in jest.config.js moduleNameMapper. jest.mock("github-slugger", () => { // Simple slugger mock for testing const MockSlugger = jest.fn(function (this: any) { this.slug = (s: string) => s.toLowerCase().replace(/\s+/g, "-"); }); return { __esModule: true, default: MockSlugger }; }); // --------------------------------------------------------------------------- // Imports // --------------------------------------------------------------------------- import { parseDeclare } from "./declare-parser"; import { parseBlockAttrs } from "./block-scanner"; import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression"; import { initReactivity, computeCascade, computeInitialValues, getCombined, setBase, getMods, getDeclExpr, extractDependencies, rebuildReactivityFromStore, getReactivityDiagnostics, resolveVariable, } from "../../components/journal/var-reactivity"; import { parseInput } from "../../components/journal/command-parser"; import { inspectSparkTableCsv, buildSparkTableCompletion, isSparkTableHeader, } from "../content-registry"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Create a minimal VariableStore for testing */ function store(entries: Record = {}): Record { return { ...entries }; } // --------------------------------------------------------------------------- // declare-parser // --------------------------------------------------------------------------- describe("parseDeclare", () => { test("parses variable declarations", () => { const result = parseDeclare(` vars: $hp: $con*5 + $mod_hp $ac: 10 + $dex `); expect(result.variables).toEqual([ { key: "$hp", expression: "$con*5 + $mod_hp" }, { key: "$ac", expression: "10 + $dex" }, ]); expect(result.tagModifiers).toHaveLength(0); }); test("parses tag modifiers with thresholds", () => { const result = parseDeclare(` tags: warrior: 2: $mod_hp: 20 mage: 1: $mod_mp: 15 `); expect(result.variables).toHaveLength(0); expect(result.tagModifiers).toEqual([ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 }, { tag: "#mage", target: "$mod_mp", expression: "15", threshold: 1 }, ]); }); test("multiple thresholds for one tag stack in ascending order", () => { const result = parseDeclare(` tags: warrior: 3: $mod_hp: 20 1: $mod_hp: 10 `); expect(result.tagModifiers).toEqual([ { tag: "#warrior", target: "$mod_hp", expression: "10", threshold: 1 }, { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 3 }, ]); }); test("one threshold can carry multiple targets", () => { const result = parseDeclare(` tags: warrior: 1: $mod_hp: 20 $mod_str: 1 `); expect(result.tagModifiers).toEqual([ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, { tag: "#warrior", target: "$mod_str", expression: "1", threshold: 1 }, ]); }); test("accepts a tag name written with #", () => { const result = parseDeclare('tags:\n "#warrior":\n 1:\n $mod_hp: 20\n'); expect(result.tagModifiers[0]).toEqual({ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1, }); }); test("normalizes numeric values to expression text", () => { const result = parseDeclare("vars:\n $con: 10\n $dex: 12\n"); expect(result.variables).toEqual([ { key: "$con", expression: "10" }, { key: "$dex", expression: "12" }, ]); }); test("YAML numeric forms are resolved before stringification", () => { const result = parseDeclare("vars:\n $a: 0x1F\n $b: 1e3\n"); expect(result.variables).toEqual([ { key: "$a", expression: "31" }, { key: "$b", expression: "1000" }, ]); }); test("parses mixed declarations and tag modifiers", () => { const result = parseDeclare(` vars: $hp: $con*5 $ac: 10+$dex tags: warrior: 1: $mod_hp: 20 `); expect(result.variables).toHaveLength(2); expect(result.tagModifiers).toHaveLength(1); }); test("handles empty, whitespace, and comment-only bodies", () => { for (const body of ["", " \n ", "# just a comment\n"]) { expect(parseDeclare(body)).toEqual({ variables: [], tagModifiers: [] }); } }); test("rejects invalid YAML", () => { expect(() => parseDeclare("vars: [unclosed\n")).toThrow("not valid YAML"); }); test("rejects duplicate keys", () => { expect(() => parseDeclare("vars:\n $hp: 1\n $hp: 2\n")).toThrow( "duplicated mapping key", ); }); test("rejects unknown top-level keys", () => { expect(() => parseDeclare("variable:\n $hp: 1\n")).toThrow( "Unrecognized key", ); }); test("rejects a variable name without $", () => { expect(() => parseDeclare("vars:\n hp: 1\n")).toThrow( "variable names must start with $", ); }); test("rejects a tag modifier target without $", () => { expect(() => parseDeclare("tags:\n warrior:\n 1:\n mod_hp: 20\n"), ).toThrow("tag modifier targets must start with $"); }); test("rejects a threshold that is not a positive integer", () => { expect(() => parseDeclare("tags:\n warrior:\n 0:\n $mod_hp: 20\n"), ).toThrow("threshold must be a positive integer"); }); test("rejects an empty expression", () => { expect(() => parseDeclare("vars:\n $hp:\n")).toThrow( "expression must not be empty", ); }); test("rejects non-numeric, non-string values", () => { expect(() => parseDeclare("vars:\n $hp: true\n")).toThrow(); }); }); // --------------------------------------------------------------------------- // block-scanner // --------------------------------------------------------------------------- describe("parseBlockAttrs", () => { test("parses standard attributes", () => { // parseBlockAttrs receives the info string AFTER the lang. // The lang is extracted from the fenced block regex capture group // and applied separately in content-registry. const attrs = parseBlockAttrs("id=stats role=declare"); expect(attrs.id).toBe("stats"); expect(attrs.role).toBe("declare"); }); test("parses quoted values", () => { const attrs = parseBlockAttrs('yaml id="my stats" role=declare'); expect(attrs.id).toBe("my stats"); expect(attrs.role).toBe("declare"); }); test("collects unknown attributes in extra", () => { const attrs = parseBlockAttrs("yaml role=declare foo=bar baz=42"); expect(attrs.extra).toEqual({ foo: "bar", baz: "42" }); }); test("keeps hyphenated attribute names intact", () => { const attrs = parseBlockAttrs("yaml role=tag data-config=foo data-src=./x"); expect(attrs.extra).toEqual({ "data-config": "foo", "data-src": "./x" }); }); test("keeps hyphenated attribute names with quoted values", () => { const attrs = parseBlockAttrs('yaml role=tag data-config="my config"'); expect(attrs.extra).toEqual({ "data-config": "my config" }); }); test("handles empty info string", () => { const attrs = parseBlockAttrs(""); expect(attrs.lang).toBe(""); expect(attrs.id).toBeUndefined(); expect(attrs.role).toBeUndefined(); }); test("handles empty info string (lang extracted separately)", () => { const attrs = parseBlockAttrs(""); expect(attrs.lang).toBe(""); expect(attrs.id).toBeUndefined(); }); }); // Role behavior is covered by the scanDoc tests in // src/cli/content-registry.test.ts. // --------------------------------------------------------------------------- // variable-expression // --------------------------------------------------------------------------- describe("evaluateExpression", () => { test("evaluates simple arithmetic", () => { const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "number", value: 14 }); }); test("evaluates with parentheses", () => { const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "number", value: 20 }); }); test("evaluates variable references", () => { const result = evaluateExpression("$con * 5 + $mod_hp", { lookup: (name) => { if (name === "$con") return "12"; if (name === "$mod_hp") return "20"; return undefined; }, }); expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20 }); test("throws on unknown variables", () => { expect(() => evaluateExpression("$unknown + 5", { lookup: () => undefined }), ).toThrow("Unknown variable: $unknown"); }); test("throws on type mismatch (tagmap + number)", () => { expect(() => evaluateExpression("$class + 5", { lookup: (name) => (name === "$class" ? "#warrior" : undefined), }), ).toThrow("Type mismatch"); }); test("resolves tagmap variable without arithmetic", () => { const result = evaluateExpression("$class", { lookup: (name) => (name === "$class" ? "#warrior:1" : undefined), }); expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } }); }); test("evaluates tagmap literals", () => { const result = evaluateExpression("#warrior:1", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } }); }); test("evaluates bare tag as tagmap", () => { const result = evaluateExpression("#warrior", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } }); }); test("evaluates multi-entry tagmap literal", () => { const result = evaluateExpression("#warrior:1;#druid:2", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } }); }); test("merges tagmaps with +", () => { const result = evaluateExpression("#warrior:1 + #druid:2", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } }); }); test("adds counts for same tag with +", () => { const result = evaluateExpression("#warrior:1 + #warrior:2", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } }); }); test("subtracts tagmaps with -", () => { const result = evaluateExpression("#warrior:3;#druid:2 - #druid:2", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } }); }); test("throws on tagmap * tagmap", () => { expect(() => evaluateExpression("#warrior:1 * #druid:2", { lookup: () => undefined }), ).toThrow("Type mismatch"); }); test("throws on tagmap / tagmap", () => { expect(() => evaluateExpression("#warrior:1 / #druid:2", { lookup: () => undefined }), ).toThrow("Type mismatch"); }); test("evaluates floor function", () => { const result = evaluateExpression("floor(3.7)", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "number", value: 3 }); }); test("evaluates ceil function", () => { const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "number", value: 4 }); }); test("evaluates round function", () => { const result = evaluateExpression("round(3.5)", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "number", value: 4 }); }); test("evaluates unary minus", () => { const result = evaluateExpression("-5 + 10", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "number", value: 5 }); }); test("throws on negating tagmap", () => { expect(() => evaluateExpression("-#warrior", { lookup: () => undefined }), ).toThrow("Type mismatch"); }); test("evaluates dice notation", () => { const result = evaluateExpression("3d6 + 5", { lookup: () => undefined }); expect(result.value.kind).toBe("number"); // 3d6 is between 3 and 18, +5 gives 8-23 expect(result.value.value).toBeGreaterThanOrEqual(8); expect(result.value.value).toBeLessThanOrEqual(23); }); test("evaluates bare dice notation", () => { const result = evaluateExpression("d20", { lookup: () => undefined }); expect(result.value.kind).toBe("number"); expect(result.value.value).toBeGreaterThanOrEqual(1); expect(result.value.value).toBeLessThanOrEqual(20); }); test("evaluates bare dice alongside modifiers", () => { const result = evaluateExpression("d6 + 5", { lookup: () => undefined }); expect(result.value.kind).toBe("number"); expect(result.value.value).toBeGreaterThanOrEqual(6); expect(result.value.value).toBeLessThanOrEqual(11); }); test("throws on division by zero", () => { expect(() => evaluateExpression("10 / 0", { lookup: () => undefined }), ).toThrow("Division by zero"); }); test("throws on unknown function", () => { expect(() => evaluateExpression("foo(5)", { lookup: () => undefined }), ).toThrow("Unknown function"); }); test("throws on malformed expression", () => { expect(() => evaluateExpression("5 +", { lookup: () => undefined }), ).toThrow("Unexpected end"); }); test("throws on malformed number literals", () => { expect(() => evaluateExpression("1.2.3", { lookup: () => undefined }), ).toThrow("Invalid number literal"); expect(() => evaluateExpression("1.", { lookup: () => undefined }), ).toThrow("Invalid number literal"); }); test("handles decimal numbers", () => { const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined }); expect(result.value).toEqual({ kind: "number", value: 6 }); }); test("nested function calls", () => { const result = evaluateExpression("floor(ceil(3.2))", { lookup: () => undefined, }); // ceil(3.2) = 4, floor(4) = 4 expect(result.value).toEqual({ kind: "number", value: 4 }); }); test("complex expression with variables and functions", () => { const result = evaluateExpression("floor($con / 2) + $mod", { lookup: (name) => { if (name === "$con") return "15"; if (name === "$mod") return "3"; return undefined; }, }); expect(result.value).toEqual({ kind: "number", value: 10 }); // floor(7.5) + 3 = 7 + 3 }); test("exprValueToString serializes number", () => { expect(exprValueToString({ kind: "number", value: 42 })).toBe("42"); }); test("exprValueToString serializes tagmap", () => { expect(exprValueToString({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } })) .toBe("#warrior:1;#druid:2"); }); test("exprValueToString returns 0 for empty tagmap", () => { expect(exprValueToString({ kind: "tagmap", value: {} })).toBe("0"); }); }); // --------------------------------------------------------------------------- // var-reactivity // --------------------------------------------------------------------------- describe("var-reactivity", () => { // Reset module state before each test beforeEach(() => { // Re-initialize with empty state to clear module-level maps initReactivity({ declarations: [], tagModifiers: [] }); }); describe("initReactivity", () => { test("builds dependency graph from declarations", () => { initReactivity({ declarations: [ { key: "$hp", expression: "$con * 5 + $mod_hp" }, { key: "$ac", expression: "10 + $dex" }, ], tagModifiers: [], }); const deps = extractDependencies("$con * 5 + $mod_hp"); expect(deps).toContain("$con"); expect(deps).toContain("$mod_hp"); }); test("detects circular dependencies", () => { expect(() => initReactivity({ declarations: [ { key: "$a", expression: "$b + 1" }, { key: "$b", expression: "$a + 1" }, ], tagModifiers: [], }), ).toThrow("Circular dependency"); }); test("detects self-referencing circular dependency", () => { expect(() => initReactivity({ declarations: [{ key: "$a", expression: "$a + 1" }], tagModifiers: [], }), ).toThrow("Circular dependency"); }); test("detects three-way circular dependency", () => { expect(() => initReactivity({ declarations: [ { key: "$a", expression: "$b + 1" }, { key: "$b", expression: "$c + 1" }, { key: "$c", expression: "$a + 1" }, ], tagModifiers: [], }), ).toThrow("Circular dependency"); }); test("allows diamond dependency (non-circular)", () => { // $d depends on $b and $c, both depend on $a — no cycle expect(() => initReactivity({ declarations: [ { key: "$b", expression: "$a + 1" }, { key: "$c", expression: "$a + 2" }, { key: "$d", expression: "$b + $c" }, ], tagModifiers: [], }), ).not.toThrow(); }); test("keeps the previous configuration when a re-init fails", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); initReactivity({ declarations: [{ key: "$hp", expression: "10 + 5" }], tagModifiers: [], }); expect(computeInitialValues(store()).find((r) => r.key === "$hp")?.value).toBe( "15", ); expect(() => initReactivity({ declarations: [ { key: "$a", expression: "$b + 1" }, { key: "$b", expression: "$a + 1" }, ], tagModifiers: [], }), ).toThrow(); // The failed re-init did not clobber the working configuration. const results = computeInitialValues(store()); expect(results.find((r) => r.key === "$hp")?.value).toBe("15"); expect(results.find((r) => r.key === "$a")).toBeUndefined(); warn.mockRestore(); }); }); describe("computeInitialValues", () => { test("computes values for all declared variables", () => { initReactivity({ declarations: [ { key: "$hp", expression: "10 + 5" }, { key: "$ac", expression: "15" }, ], tagModifiers: [], }); const results = computeInitialValues(store()); const hpResult = results.find((r) => r.key === "$hp"); const acResult = results.find((r) => r.key === "$ac"); expect(hpResult?.value).toBe("15"); expect(acResult?.value).toBe("15"); }); test("evaluates in dependency order", () => { initReactivity({ declarations: [ { key: "$hp", expression: "$con * 5" }, { key: "$con", expression: "12" }, ], tagModifiers: [], }); const results = computeInitialValues(store()); const hpResult = results.find((r) => r.key === "$hp"); expect(hpResult?.value).toBe("60"); // 12 * 5 }); test("reports the base alongside the combined value", () => { initReactivity({ declarations: [{ key: "$hp", expression: "$con * 5" }], tagModifiers: [], }); const results = computeInitialValues({ $con: "12" }); const hp = results.find((r) => r.key === "$hp"); expect(hp?.value).toBe("60"); expect(hp?.base).toBe("60"); }); test("skips variables already present in the store", () => { initReactivity({ declarations: [ { key: "$hp", expression: "10 + 5" }, { key: "$ac", expression: "15" }, ], tagModifiers: [], }); const results = computeInitialValues({ $hp: "99" }, new Set(["$hp"])); // Existing value is left untouched; only missing keys are computed. expect(results.find((r) => r.key === "$hp")).toBeUndefined(); expect(results.find((r) => r.key === "$ac")?.value).toBe("15"); }); test("returns empty array when no declarations", () => { initReactivity({ declarations: [], tagModifiers: [] }); const results = computeInitialValues(store()); expect(results).toHaveLength(0); }); }); describe("diagnostics", () => { test("reports a declaration that fails to evaluate", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); initReactivity({ declarations: [ { key: "$hp", expression: "10 + 5" }, { key: "$bad", expression: "#warrior * 2" }, ], tagModifiers: [], }); const results = computeInitialValues(store()); expect(results.find((r) => r.key === "$hp")?.value).toBe("15"); expect(results.find((r) => r.key === "$bad")).toBeUndefined(); const diags = getReactivityDiagnostics(); expect(diags).toHaveLength(1); expect(diags[0].kind).toBe("declaration"); expect(diags[0].key).toBe("$bad"); expect(diags[0].expression).toBe("#warrior * 2"); warn.mockRestore(); }); test("dedupes repeated failures of the same expression", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); initReactivity({ declarations: [{ key: "$bad", expression: "#warrior * 2" }], tagModifiers: [], }); computeInitialValues(store()); computeInitialValues(store()); expect(getReactivityDiagnostics()).toHaveLength(1); warn.mockRestore(); }); test("clears diagnostics on re-init", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); initReactivity({ declarations: [{ key: "$bad", expression: "#warrior * 2" }], tagModifiers: [], }); computeInitialValues(store()); expect(getReactivityDiagnostics()).toHaveLength(1); initReactivity({ declarations: [], tagModifiers: [] }); expect(getReactivityDiagnostics()).toHaveLength(0); warn.mockRestore(); }); test("records a diagnostic when declarations are circular", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); expect(() => initReactivity({ declarations: [ { key: "$a", expression: "$b + 1" }, { key: "$b", expression: "$a + 1" }, ], tagModifiers: [], }), ).toThrow(); const diags = getReactivityDiagnostics(); expect(diags).toHaveLength(1); expect(diags[0].kind).toBe("init"); warn.mockRestore(); }); test("reports a tag modifier that fails to evaluate", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$hp", expression: "#x * 2", threshold: 1 }, ], }); setBase("$class", "#warrior:1"); computeCascade("$class", undefined, store({ $class: "#warrior:1" })); const diags = getReactivityDiagnostics(); expect(diags).toHaveLength(1); expect(diags[0].kind).toBe("modifier"); expect(diags[0].key).toBe("$hp"); expect(diags[0].tag).toBe("#warrior"); expect(diags[0].source).toBe("$class"); warn.mockRestore(); }); test("keeps cascading other dependents when a declaration fails", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); initReactivity({ declarations: [ { key: "$bad", expression: "$con * #warrior" }, { key: "$good", expression: "$con + 1" }, ], tagModifiers: [], }); const cascade = computeCascade("$con", undefined, store({ $con: "5" })); expect(cascade.find((r) => r.key === "$good")?.value).toBe("6"); expect(cascade.find((r) => r.key === "$bad")).toBeUndefined(); expect(getReactivityDiagnostics().some((d) => d.key === "$bad")).toBe(true); warn.mockRestore(); }); }); describe("getCombined / setBase", () => { test("returns base value when no mods", () => { initReactivity({ declarations: [], tagModifiers: [] }); setBase("$hp", "42"); expect(getCombined("$hp")).toBe("42"); }); test("returns 0 for unknown keys", () => { initReactivity({ declarations: [], tagModifiers: [] }); expect(getCombined("$unknown")).toBe("0"); }); test("falls back to store for unknown keys", () => { initReactivity({ declarations: [], tagModifiers: [] }); expect(getCombined("$hp", { $hp: "99" })).toBe("99"); }); test("returns tagmap value without numeric mods", () => { initReactivity({ declarations: [], tagModifiers: [] }); setBase("$class", "#warrior:1"); expect(getCombined("$class")).toBe("#warrior:1"); }); }); describe("resolveVariable", () => { test("returns undefined for unknown variables", () => { initReactivity({ declarations: [], tagModifiers: [] }); expect(resolveVariable("$nope")).toBeUndefined(); expect(resolveVariable("$nope", {})).toBeUndefined(); }); test("returns a real 0 rather than undefined", () => { initReactivity({ declarations: [], tagModifiers: [] }); setBase("$zero", "0"); expect(resolveVariable("$zero")).toBe("0"); }); test("resolves keys present only in the fallback store", () => { initReactivity({ declarations: [], tagModifiers: [] }); expect(resolveVariable("$hp", { $hp: "12" })).toBe("12"); }); }); describe("rebuildReactivityFromStore", () => { test("restores bases exactly without double-counting modifiers", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, ], }); // `variables` holds combined values; `bases` holds what derived them. const hydrated = { $class: "#warrior:1", $mod_hp: "20" }; const bases = { $class: "#warrior:1", $mod_hp: "0" }; rebuildReactivityFromStore(hydrated, bases); expect(getCombined("$mod_hp", hydrated)).toBe("20"); }); test("recovers a non-zero base and keeps modifiers applied", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$hp", expression: "20", threshold: 1 }, ], }); const hydrated = { $class: "#warrior:1", $hp: "70" }; // base 50 + 20 const bases = { $class: "#warrior:1", $hp: "50" }; rebuildReactivityFromStore(hydrated, bases); expect(getCombined("$hp", hydrated)).toBe("70"); // A later /set replaces the base; the modifier still applies. setBase("$hp", "60"); expect(getCombined("$hp", hydrated)).toBe("80"); }); test("deactivates a hydrated modifier on a later tag change", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 }, ], }); const hydrated = { $class: "#warrior:1", $mod_hp: "20" }; const bases = { $class: "#warrior:1", $mod_hp: "0" }; rebuildReactivityFromStore(hydrated, bases); setBase("$class", "#mage:1"); const cascade = computeCascade("$class", "#warrior:1", { ...hydrated, $class: "#mage:1", }); expect(cascade.find((r) => r.key === "$mod_hp")?.value).toBe("0"); expect(getCombined("$mod_hp", { $class: "#mage:1" })).toBe("0"); }); test("rebuilds a tagmap variable's base from its published base", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$pool", expression: "2", threshold: 1 }, ], }); // $pool is a tagmap variable that receives a +2 #warrior tagmap mod. const hydrated = { $class: "#warrior:1", $pool: "#druid:3;#warrior:2" }; const bases = { $class: "#warrior:1", $pool: "#druid:3" }; rebuildReactivityFromStore(hydrated, bases); expect(getCombined("$pool", hydrated)).toBe("#druid:3;#warrior:2"); }); test("is idempotent when called twice", () => { initReactivity({ declarations: [], tagModifiers: [] }); const hydrated = { $hp: "42" }; rebuildReactivityFromStore(hydrated, { $hp: "42" }); rebuildReactivityFromStore(hydrated, { $hp: "42" }); expect(getCombined("$hp", hydrated)).toBe("42"); }); }); describe("computeCascade — declarations", () => { test("does not re-publish or re-evaluate declared dependencies that did not change", () => { initReactivity({ declarations: [ { key: "$con", expression: "12" }, { key: "$hp", expression: "$con * 5" }, ], tagModifiers: [], }); // $con was set to 14; only its dependent $hp should be re-evaluated. setBase("$con", "14"); const cascade = computeCascade("$con", "12", store({ $con: "14" })); expect(cascade.map((r) => r.key)).toEqual(["$hp"]); expect(cascade[0].value).toBe("70"); }); }); describe("computeCascade — tag activation/deactivation", () => { test("activates tag modifiers when a tag value is set", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1, }, { tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1, }, ], }); // Set $class to #warrior:1 — should activate both modifiers setBase("$class", "#warrior:1"); const cascade = computeCascade( "$class", undefined, store({ $class: "#warrior:1" }), ); // Should produce combined values for both targets const modHp = cascade.find((r) => r.key === "$mod_hp"); const modStr = cascade.find((r) => r.key === "$mod_str"); expect(modHp?.value).toBe("20"); expect(modStr?.value).toBe("5"); }); test("reports the raw base alongside the combined value", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$hp", expression: "20", threshold: 1 }, ], }); setBase("$hp", "50"); setBase("$class", "#warrior:1"); const cascade = computeCascade( "$class", undefined, store({ $class: "#warrior:1" }), ); const hp = cascade.find((r) => r.key === "$hp"); expect(hp?.value).toBe("70"); expect(hp?.base).toBe("50"); }); test("deactivates tag modifiers when tag is removed from tagmap", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1, }, ], }); // First activate setBase("$class", "#warrior:1"); computeCascade("$class", undefined, store({ $class: "#warrior:1" })); // Now change to a different tag setBase("$class", "#mage:1"); const cascade = computeCascade( "$class", "#warrior:1", store({ $class: "#mage:1" }), ); // Should deactivate #warrior mods (mod_hp goes back to 0) const modHp = cascade.find((r) => r.key === "$mod_hp"); expect(modHp?.value).toBe("0"); }); test("switches between two tags with different modifiers", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1, }, { tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 }, ], }); // Activate #warrior setBase("$class", "#warrior:1"); computeCascade("$class", undefined, store({ $class: "#warrior:1" })); // Switch to #mage setBase("$class", "#mage:1"); const cascade = computeCascade( "$class", "#warrior:1", store({ $class: "#mage:1" }), ); // Cascade returns both deactivation ("0") and activation ("10") entries. // The last entry for a key is the authoritative one. const modHpEntries = cascade.filter((r) => r.key === "$mod_hp"); const lastModHp = modHpEntries[modHpEntries.length - 1]; expect(lastModHp?.value).toBe("10"); }); test("respects threshold — activates only when count >= threshold", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2, }, ], }); // Count 1 < threshold 2 — should NOT activate setBase("$class", "#warrior:1"); const cascade1 = computeCascade( "$class", undefined, store({ $class: "#warrior:1" }), ); const modHp1 = cascade1.find((r) => r.key === "$mod_hp"); expect(modHp1).toBeUndefined(); // Increase to count 2 >= threshold 2 — should activate setBase("$class", "#warrior:2"); const cascade2 = computeCascade( "$class", "#warrior:1", store({ $class: "#warrior:2" }), ); const modHp2 = cascade2.find((r) => r.key === "$mod_hp"); expect(modHp2?.value).toBe("20"); }); test("deactivates when count drops below threshold", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2, }, ], }); // Activate with count 2 setBase("$class", "#warrior:2"); computeCascade("$class", undefined, store({ $class: "#warrior:2" })); expect(getCombined("$mod_hp")).toBe("20"); // Drop to count 1 < threshold 2 — should deactivate setBase("$class", "#warrior:1"); const cascade = computeCascade( "$class", "#warrior:2", store({ $class: "#warrior:1" }), ); const modHp = cascade.find((r) => r.key === "$mod_hp"); expect(modHp?.value).toBe("0"); }); test("handles multi-tag tagmaps", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1, }, { tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 }, ], }); setBase("$class", "#warrior:2;#druid:1"); const cascade = computeCascade( "$class", undefined, store({ $class: "#warrior:2;#druid:1" }), ); const modHp = cascade.find((r) => r.key === "$mod_hp"); const modMp = cascade.find((r) => r.key === "$mod_mp"); expect(modHp?.value).toBe("20"); expect(modMp?.value).toBe("15"); }); }); describe("computeCascade — declaration re-evaluation", () => { test("re-evaluates dependents when a dependency changes", () => { initReactivity({ declarations: [{ key: "$hp", expression: "$con * 5" }], tagModifiers: [], }); // Set initial base for $con setBase("$con", "10"); // Manually set $hp base so it exists setBase("$hp", "50"); const cascade = computeCascade("$con", "10", store({ $con: "10" })); const hpResult = cascade.find((r) => r.key === "$hp"); expect(hpResult?.value).toBe("50"); // 10 * 5 }); test("cascades through multiple levels of dependents", () => { initReactivity({ declarations: [ { key: "$b", expression: "$a * 2" }, { key: "$c", expression: "$b + 10" }, ], tagModifiers: [], }); setBase("$a", "5"); setBase("$b", "10"); setBase("$c", "20"); const cascade = computeCascade("$a", "5", store({ $a: "5" })); const bResult = cascade.find((r) => r.key === "$b"); const cResult = cascade.find((r) => r.key === "$c"); expect(bResult?.value).toBe("10"); // 5 * 2 expect(cResult?.value).toBe("20"); // 10 + 10 }); test("handles tagmap transition during re-evaluation", () => { initReactivity({ declarations: [{ key: "$class", expression: "0" }], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1, }, { tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 }, ], }); // Set $class directly to #novice:1 setBase("$class", "#novice:1"); computeCascade("$class", undefined, store({ $class: "#novice:1" })); // Now change to #warrior:1 setBase("$class", "#warrior:1"); const cascade = computeCascade( "$class", "#novice:1", store({ $class: "#warrior:1" }), ); // Cascade returns both deactivation and activation entries. // The last entry for a key is authoritative. const modHpEntries = cascade.filter((r) => r.key === "$mod_hp"); const lastModHp = modHpEntries[modHpEntries.length - 1]; expect(lastModHp?.value).toBe("20"); }); }); describe("getMods / getDeclExpr", () => { test("getDeclExpr returns expression for declared variable", () => { initReactivity({ declarations: [{ key: "$hp", expression: "$con * 5" }], tagModifiers: [], }); expect(getDeclExpr("$hp")).toBe("$con * 5"); expect(getDeclExpr("$unknown")).toBeUndefined(); }); test("getMods returns active modifiers after tag activation", () => { initReactivity({ declarations: [], tagModifiers: [ { tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1, }, ], }); setBase("$class", "#warrior:1"); computeCascade("$class", undefined, store({ $class: "#warrior:1" })); const mods = getMods("$mod_hp"); expect(mods).toHaveLength(1); expect(mods[0].tag).toBe("#warrior"); expect(mods[0].value).toBe(20); expect(mods[0].source).toBe("$class"); }); }); describe("extractDependencies", () => { test("extracts $var references from expression", () => { const deps = extractDependencies("$con * 5 + $mod_hp"); expect(deps).toEqual(["$con", "$mod_hp"]); }); test("returns empty for expression without variables", () => { const deps = extractDependencies("10 + 5"); expect(deps).toEqual([]); }); test("deduplicates repeated variables", () => { const deps = extractDependencies("$a + $a + $b"); expect(deps).toEqual(["$a", "$b"]); }); }); }); // --------------------------------------------------------------------------- // command-parser // --------------------------------------------------------------------------- describe("parseInput", () => { test("parses /roll command with spark table slug", () => { const result = parseInput("/roll my-table"); expect(result.type).toBe("roll"); expect(result.payload).toEqual({ arg: "my-table" }); }); test("parses /roll command with dice notation", () => { const result = parseInput("/roll 3d6+5"); expect(result.type).toBe("roll"); expect(result.payload).toEqual({ arg: "3d6+5" }); }); test("parses /link command", () => { const result = parseInput("/link /rules/combat#initiative"); expect(result.type).toBe("link"); expect(result.payload).toEqual({ path: "/rules/combat", section: "initiative", }); }); test("parses /link without section", () => { const result = parseInput("/link /rules/combat"); expect(result.type).toBe("link"); expect(result.payload).toEqual({ path: "/rules/combat", section: undefined, }); }); test("parses /set command", () => { const result = parseInput("/set $hp $con*5+10"); expect(result.type).toBe("set"); expect(result.payload).toEqual({ key: "$hp", expr: "$con*5+10" }); }); test("parses /set with rolltag (pipe-separated tags)", () => { const result = parseInput("/set $class #w|#d|#s"); expect(result.type).toBe("rolltag"); expect(result.payload).toEqual({ key: "$class", tags: ["#w", "#d", "#s"], }); }); test("parses chat messages", () => { const result = parseInput("Hello world"); expect(result.type).toBe("chat"); expect(result.payload).toEqual({ text: "Hello world" }); }); test("returns error for empty /roll", () => { const result = parseInput("/roll "); expect(result.error).toBe("需要骰子表达式或种子表键名"); }); test("returns error for empty /set", () => { const result = parseInput("/set "); expect(result.error).toBe("格式: /set $key expression"); }); test("returns error for /set without expression", () => { const result = parseInput("/set $hp"); expect(result.error).toBe("格式: /set $key expression"); }); test("returns error for /set without $ prefix", () => { const result = parseInput("/set hp 10"); expect(result.error).toBe("key 必须以 $ 开头"); }); test("handles incomplete commands", () => { const result = parseInput("/roll"); expect(result.type).toBe("chat"); expect(result.error).toBe("请补全命令"); }); }); // --------------------------------------------------------------------------- // content-registry (spark tables) // --------------------------------------------------------------------------- describe("isSparkTableHeader", () => { test("detects plain dice formula", () => { expect(isSparkTableHeader("d6")).toBe(true); expect(isSparkTableHeader("d20")).toBe(true); expect(isSparkTableHeader("d100")).toBe(true); }); test("detects dice count and modifiers", () => { expect(isSparkTableHeader("2d6")).toBe(true); expect(isSparkTableHeader("3d6+5")).toBe(true); expect(isSparkTableHeader("1d8-2")).toBe(true); }); test("rejects non-dice headers", () => { expect(isSparkTableHeader("Name")).toBe(false); expect(isSparkTableHeader("d")).toBe(false); expect(isSparkTableHeader("6")).toBe(false); expect(isSparkTableHeader("d6x")).toBe(false); expect(isSparkTableHeader("roll")).toBe(false); }); test("trims surrounding whitespace", () => { expect(isSparkTableHeader(" d20 ")).toBe(true); }); }); describe("inspectSparkTableCsv", () => { test("detects spark table from CSV headers", () => { const csv = `d6,Name,Description 1,Alice,The brave 2,Bob,The wise`; const headers = inspectSparkTableCsv(csv); expect(headers).toEqual(["Name", "Description"]); }); test("detects d20 spark table", () => { const csv = `d20,Result 1,Critical failure 20,Critical success`; const headers = inspectSparkTableCsv(csv); expect(headers).toEqual(["Result"]); }); test("returns null for non-dice first column", () => { const csv = `Name,Value Alice,10 Bob,20`; const headers = inspectSparkTableCsv(csv); expect(headers).toBeNull(); }); test("returns null for single-column CSV", () => { const csv = `d6`; const headers = inspectSparkTableCsv(csv); expect(headers).toBeNull(); }); test("returns null for empty CSV", () => { const csv = ""; const headers = inspectSparkTableCsv(csv); expect(headers).toBeNull(); }); test("handles 2d6 notation", () => { const csv = `2d6,Outcome 2,Terrible 7,Average 12,Amazing`; const headers = inspectSparkTableCsv(csv); expect(headers).toEqual(["Outcome"]); }); }); describe("buildSparkTableCompletion", () => { test("builds completion from CSV data", () => { const csv = `d6,Name,Description 1,Alice,The brave`; const result = buildSparkTableCompletion( csv, "/rules/combat.md", "/rules/combat/test.csv", false, ); expect(result).not.toBeNull(); expect(result!.notation).toBe("d6"); expect(result!.headers).toEqual(["Name", "Description"]); expect(result!.filePath).toBe("/rules/combat"); expect(result!.remix).toBe(false); }); test("returns null for non-spark CSV", () => { const csv = `Name,Value Alice,10`; const result = buildSparkTableCompletion( csv, "/test.md", "/test.csv", false, ); expect(result).toBeNull(); }); test("sets remix flag", () => { const csv = `d6,Result 1,Yes`; const result = buildSparkTableCompletion( csv, "/test.md", "/test.csv", true, ); expect(result!.remix).toBe(true); }); });