Files
ttrpg-tools/src/cli/completions/variable-system.test.ts
T
hyper 264038e27c fix(markdown): keep hyphenated block attribute keys
The key pattern \w+ could not cross a hyphen, so data-config=foo was parsed as config=foo and the data- prefix was lost. Allow hyphens in keys, matching parseDirectiveAttrs.
2026-09-12 23:44:43 +08:00

1424 lines
45 KiB
TypeScript

/**
* 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<string, string> = {}): Record<string, string> {
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);
});
});