From c106b6f79c4723c154060c98843c3f8a70e8fa1a Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 13 Jul 2026 11:08:48 +0800 Subject: [PATCH] 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. --- jest.config.js | 4 + .../__mocks__/csv-parse-browser-esm-sync.ts | 7 + src/cli/completions/variable-system.test.ts | 868 ++++++++++++++++++ 3 files changed, 879 insertions(+) create mode 100644 src/cli/completions/__mocks__/csv-parse-browser-esm-sync.ts create mode 100644 src/cli/completions/variable-system.test.ts diff --git a/jest.config.js b/jest.config.js index 9eed8a1..33d964a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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', diff --git a/src/cli/completions/__mocks__/csv-parse-browser-esm-sync.ts b/src/cli/completions/__mocks__/csv-parse-browser-esm-sync.ts new file mode 100644 index 0000000..98231bb --- /dev/null +++ b/src/cli/completions/__mocks__/csv-parse-browser-esm-sync.ts @@ -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"; diff --git a/src/cli/completions/variable-system.test.ts b/src/cli/completions/variable-system.test.ts new file mode 100644 index 0000000..4ba5a4a --- /dev/null +++ b/src/cli/completions/variable-system.test.ts @@ -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 = {}): Record { + 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); + }); +});