test: add variable system tests and jest config updates
- Add comprehensive tests for variable system, including parsing, expression evaluation, and reactivity. - Add `moduleNameMapper` to `jest.config.js` to resolve `.js` imports to `.ts` source files. - Add a mock for `csv-parse/browser/esm/sync` to support Node-based testing.
This commit is contained in:
parent
2c762b0411
commit
c106b6f79c
|
|
@ -4,6 +4,10 @@ export default {
|
|||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
moduleNameMapper: {
|
||||
// Resolve .js imports to .ts source files (ESM convention in TS source)
|
||||
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Jest mock for csv-parse/browser/esm/sync
|
||||
*
|
||||
* The real import path is csv-parse/browser/esm/sync which only works in
|
||||
* browser bundlers. In Node (Jest), we redirect to csv-parse/sync.
|
||||
*/
|
||||
export { parse } from "csv-parse/sync";
|
||||
|
|
@ -0,0 +1,868 @@
|
|||
/**
|
||||
* Tests for the variable/tag system:
|
||||
* - declare-parser (CSV parsing)
|
||||
* - block-scanner (attribute parsing)
|
||||
* - variable-expression (expression evaluation)
|
||||
* - var-reactivity (dependency graph, cascade, tag activation)
|
||||
* - command-parser (input parsing)
|
||||
* - directive-scanner (spark table detection)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock("csv-parse/browser/esm/sync", () => {
|
||||
// Redirect browser-specific import to Node-compatible sync parser
|
||||
const actual = jest.requireActual("csv-parse/sync");
|
||||
return { parse: actual.parse };
|
||||
});
|
||||
|
||||
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 { parseDeclareCsv } from "./declare-parser";
|
||||
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner";
|
||||
import { evaluateExpression } from "../../components/journal/variable-expression";
|
||||
import {
|
||||
initReactivity,
|
||||
computeCascade,
|
||||
computeInitialValues,
|
||||
getCombined,
|
||||
setBase,
|
||||
getMods,
|
||||
getDeclExpr,
|
||||
extractDependencies,
|
||||
} from "../../components/journal/var-reactivity";
|
||||
import { parseInput } from "../../components/journal/command-parser";
|
||||
import {
|
||||
inspectSparkTableCsv,
|
||||
buildSparkTableCompletion,
|
||||
} from "./directive-scanner";
|
||||
import Slugger from "github-slugger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a minimal VariableStore for testing */
|
||||
function store(entries: Record<string, string> = {}): Record<string, string> {
|
||||
return { ...entries };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// declare-parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseDeclareCsv", () => {
|
||||
test("parses variable declarations", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5+$mod_hp
|
||||
,$ac,10+$dex`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(2);
|
||||
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5+$mod_hp" });
|
||||
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
|
||||
expect(result.tagModifiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("parses tag modifiers", () => {
|
||||
const csv = `tag,key,expr
|
||||
#warrior,$mod_hp,20
|
||||
#mage,$mod_mp,15`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(0);
|
||||
expect(result.tagModifiers).toHaveLength(2);
|
||||
expect(result.tagModifiers[0]).toEqual({
|
||||
tag: "#warrior",
|
||||
target: "$mod_hp",
|
||||
expression: "20",
|
||||
});
|
||||
expect(result.tagModifiers[1]).toEqual({
|
||||
tag: "#mage",
|
||||
target: "$mod_mp",
|
||||
expression: "15",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses mixed declarations and tag modifiers", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5
|
||||
#warrior,$mod_hp,20
|
||||
,$ac,10+$dex`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(2);
|
||||
expect(result.tagModifiers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rejects missing key column", () => {
|
||||
const csv = `tag,expr
|
||||
,$con*5`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'role=declare blocks must have "key" and "expr" columns',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects missing expr column", () => {
|
||||
const csv = `tag,key
|
||||
,$hp`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'role=declare blocks must have "key" and "expr" columns',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects tag without # prefix", () => {
|
||||
const csv = `tag,key,expr
|
||||
warrior,$mod_hp,20`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'tag must start with #',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects key without $ prefix in declaration", () => {
|
||||
const csv = `tag,key,expr
|
||||
,hp,$con*5`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'key must start with $',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects key without $ prefix in tag modifier", () => {
|
||||
const csv = `tag,key,expr
|
||||
#warrior,mod_hp,20`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'key must start with $',
|
||||
);
|
||||
});
|
||||
|
||||
test("skips rows with empty key or expr (warns, doesn't throw)", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5
|
||||
,,`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("handles empty CSV", () => {
|
||||
const result = parseDeclareCsv("", "test.md");
|
||||
expect(result.variables).toHaveLength(0);
|
||||
expect(result.tagModifiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles whitespace-only CSV", () => {
|
||||
const result = parseDeclareCsv(" \n ", "test.md");
|
||||
expect(result.variables).toHaveLength(0);
|
||||
expect(result.tagModifiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("columns can be in any order", () => {
|
||||
const csv = `expr,key,tag
|
||||
$con*5,$hp,
|
||||
20,$mod_hp,#warrior`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(1);
|
||||
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5" });
|
||||
expect(result.tagModifiers).toHaveLength(1);
|
||||
expect(result.tagModifiers[0]).toEqual({
|
||||
tag: "#warrior",
|
||||
target: "$mod_hp",
|
||||
expression: "20",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 block-processor.
|
||||
const attrs = parseBlockAttrs('id=stats role=declare as=none');
|
||||
expect(attrs.id).toBe("stats");
|
||||
expect(attrs.role).toBe("declare");
|
||||
expect(attrs.as).toBe("none");
|
||||
});
|
||||
|
||||
test("parses quoted values", () => {
|
||||
const attrs = parseBlockAttrs('csv 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('csv role=declare foo=bar baz=42');
|
||||
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
||||
});
|
||||
|
||||
test("handles empty info string", () => {
|
||||
const attrs = parseBlockAttrs("");
|
||||
expect(attrs.lang).toBe("");
|
||||
expect(attrs.id).toBeUndefined();
|
||||
expect(attrs.role).toBeUndefined();
|
||||
expect(attrs.as).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles empty info string (lang extracted separately)", () => {
|
||||
const attrs = parseBlockAttrs("");
|
||||
expect(attrs.lang).toBe("");
|
||||
expect(attrs.id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlockAs", () => {
|
||||
test("returns explicit as value", () => {
|
||||
expect(resolveBlockAs("declare", "codeblock")).toBe("codeblock");
|
||||
});
|
||||
|
||||
test("defaults to none when role is set", () => {
|
||||
expect(resolveBlockAs("declare", undefined)).toBe("none");
|
||||
expect(resolveBlockAs("file", undefined)).toBe("none");
|
||||
});
|
||||
|
||||
test("defaults to md-table for spark-table role", () => {
|
||||
expect(resolveBlockAs("spark-table", undefined)).toBe("md-table");
|
||||
});
|
||||
|
||||
test("defaults to codeblock when no role and no as", () => {
|
||||
expect(resolveBlockAs(undefined, undefined)).toBe("codeblock");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// variable-expression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("evaluateExpression", () => {
|
||||
test("evaluates simple arithmetic", () => {
|
||||
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined });
|
||||
expect(result.value).toBe(14);
|
||||
});
|
||||
|
||||
test("evaluates with parentheses", () => {
|
||||
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
|
||||
expect(result.value).toBe(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).toBe(80); // 12*5 + 20
|
||||
});
|
||||
|
||||
test("returns 0 for undefined variables", () => {
|
||||
const result = evaluateExpression("$unknown + 5", {
|
||||
lookup: () => undefined,
|
||||
});
|
||||
expect(result.value).toBe(5);
|
||||
});
|
||||
|
||||
test("throws on tag values in arithmetic", () => {
|
||||
expect(() =>
|
||||
evaluateExpression("$class + 5", {
|
||||
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
||||
}),
|
||||
).toThrow('$class is a tag');
|
||||
});
|
||||
|
||||
test("evaluates floor function", () => {
|
||||
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
|
||||
expect(result.value).toBe(3);
|
||||
});
|
||||
|
||||
test("evaluates ceil function", () => {
|
||||
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined });
|
||||
expect(result.value).toBe(4);
|
||||
});
|
||||
|
||||
test("evaluates round function", () => {
|
||||
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
|
||||
expect(result.value).toBe(4);
|
||||
});
|
||||
|
||||
test("evaluates unary minus", () => {
|
||||
const result = evaluateExpression("-5 + 10", { lookup: () => undefined });
|
||||
expect(result.value).toBe(5);
|
||||
});
|
||||
|
||||
test("evaluates dice notation", () => {
|
||||
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined });
|
||||
expect(typeof result.value).toBe("number");
|
||||
// 3d6 is between 3 and 18, +5 gives 8-23
|
||||
expect(result.value).toBeGreaterThanOrEqual(8);
|
||||
expect(result.value).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
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("handles decimal numbers", () => {
|
||||
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
||||
expect(result.value).toBeCloseTo(6);
|
||||
});
|
||||
|
||||
test("nested function calls", () => {
|
||||
const result = evaluateExpression("floor(ceil(3.2))", {
|
||||
lookup: () => undefined,
|
||||
});
|
||||
// ceil(3.2) = 4, floor(4) = 4
|
||||
expect(result.value).toBe(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).toBe(10); // floor(7.5) + 3 = 7 + 3
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
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("returns empty array when no declarations", () => {
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
const results = computeInitialValues(store());
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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 tag value without numeric mods", () => {
|
||||
initReactivity({ declarations: [], tagModifiers: [] });
|
||||
setBase("$class", "#warrior");
|
||||
expect(getCombined("$class")).toBe("#warrior");
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
{ tag: "#warrior", target: "$mod_str", expression: "5" },
|
||||
],
|
||||
});
|
||||
|
||||
// Set $class to #warrior — should activate both modifiers
|
||||
setBase("$class", "#warrior");
|
||||
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
// 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("deactivates tag modifiers when tag value changes", () => {
|
||||
initReactivity({
|
||||
declarations: [],
|
||||
tagModifiers: [
|
||||
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||
],
|
||||
});
|
||||
|
||||
// First activate
|
||||
setBase("$class", "#warrior");
|
||||
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
// Now change to a different tag
|
||||
setBase("$class", "#mage");
|
||||
const cascade = computeCascade(
|
||||
"$class",
|
||||
"#warrior",
|
||||
store({ $class: "#mage" }),
|
||||
);
|
||||
|
||||
// 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" },
|
||||
{ tag: "#mage", target: "$mod_hp", expression: "10" },
|
||||
],
|
||||
});
|
||||
|
||||
// Activate #warrior
|
||||
setBase("$class", "#warrior");
|
||||
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
// Switch to #mage
|
||||
setBase("$class", "#mage");
|
||||
const cascade = computeCascade(
|
||||
"$class",
|
||||
"#warrior",
|
||||
store({ $class: "#mage" }),
|
||||
);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
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 tag transition during re-evaluation", () => {
|
||||
initReactivity({
|
||||
declarations: [
|
||||
{ key: "$class", expression: "$level > 5 ? '#warrior' : '#novice'" },
|
||||
],
|
||||
tagModifiers: [
|
||||
{ tag: "#warrior", target: "$mod_hp", expression: "20" },
|
||||
{ tag: "#novice", target: "$mod_hp", expression: "5" },
|
||||
],
|
||||
// Note: the expression above uses a ternary which isn't supported
|
||||
// by the expression evaluator. We'll test with direct tag values.
|
||||
});
|
||||
|
||||
// Set $class directly to a tag
|
||||
setBase("$class", "#novice");
|
||||
computeCascade("$class", undefined, store({ $class: "#novice" }));
|
||||
|
||||
// Now change to #warrior
|
||||
setBase("$class", "#warrior");
|
||||
const cascade = computeCascade(
|
||||
"$class",
|
||||
"#novice",
|
||||
store({ $class: "#warrior" }),
|
||||
);
|
||||
|
||||
// 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" },
|
||||
],
|
||||
});
|
||||
|
||||
setBase("$class", "#warrior");
|
||||
computeCascade("$class", undefined, store({ $class: "#warrior" }));
|
||||
|
||||
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", () => {
|
||||
const result = parseInput("/roll 3d6+5");
|
||||
expect(result.type).toBe("roll");
|
||||
expect(result.payload).toEqual({ notation: "3d6+5", label: "3d6+5" });
|
||||
});
|
||||
|
||||
test("parses /spark command", () => {
|
||||
const result = parseInput("/spark my-table");
|
||||
expect(result.type).toBe("spark");
|
||||
expect(result.payload).toEqual({ key: "my-table" });
|
||||
});
|
||||
|
||||
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("请补全命令");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// directive-scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 slugger = new Slugger();
|
||||
const result = buildSparkTableCompletion(
|
||||
csv,
|
||||
"/rules/combat.md",
|
||||
"/rules/combat/test.csv",
|
||||
false,
|
||||
slugger,
|
||||
);
|
||||
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 slugger = new Slugger();
|
||||
const result = buildSparkTableCompletion(
|
||||
csv,
|
||||
"/test.md",
|
||||
"/test.csv",
|
||||
false,
|
||||
slugger,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("sets remix flag", () => {
|
||||
const csv = `d6,Result
|
||||
1,Yes`;
|
||||
const slugger = new Slugger();
|
||||
const result = buildSparkTableCompletion(
|
||||
csv,
|
||||
"/test.md",
|
||||
"/test.csv",
|
||||
true,
|
||||
slugger,
|
||||
);
|
||||
expect(result!.remix).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue