feat(declare)!: parse role=declare blocks as YAML

Replace the tag/threshold/key/expr CSV body with YAML `vars:` and
`tags:` maps. `parseDeclare` validates the body with an exported Zod
schema and returns the same declaration and modifier shapes the
reactivity engine already consumes.

The breaking rename from `parseDeclareCsv` updates the registry, the
journal re-exports, and the `tttk check` role checks, where R003 now
reports schema errors with their path and R004 warns when a block
declares nothing.
This commit is contained in:
hyper
2026-09-12 22:50:48 +08:00
parent 16f2038fd8
commit fadfd4cba6
8 changed files with 300 additions and 224 deletions
+118 -118
View File
@@ -1,6 +1,6 @@
/**
* Tests for the variable/tag system:
* - declare-parser (CSV parsing)
* - declare-parser (YAML parsing)
* - block-scanner (attribute parsing)
* - variable-expression (expression evaluation)
* - var-reactivity (dependency graph, cascade, tag activation)
@@ -27,7 +27,7 @@ jest.mock("github-slugger", () => {
// Imports
// ---------------------------------------------------------------------------
import { parseDeclareCsv } from "./declare-parser";
import { parseDeclare } from "./declare-parser";
import { parseBlockAttrs } from "./block-scanner";
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import {
@@ -63,154 +63,154 @@ function store(entries: Record<string, string> = {}): Record<string, string> {
// declare-parser
// ---------------------------------------------------------------------------
describe("parseDeclareCsv", () => {
describe("parseDeclare", () => {
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" });
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", () => {
const csv = `tag,key,expr
#warrior,$mod_hp,20
#mage,$mod_mp,15`;
const result = parseDeclareCsv(csv, "test.md");
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).toHaveLength(2);
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,
});
expect(result.tagModifiers[1]).toEqual({
tag: "#mage",
target: "$mod_mp",
expression: "15",
threshold: 1,
});
});
test("parses tag modifiers with threshold", () => {
const csv = `tag,threshold,key,expr
#warrior,2,$mod_hp,20
#mage,,$mod_mp,15`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.tagModifiers).toHaveLength(2);
expect(result.tagModifiers[0]).toEqual({
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
});
expect(result.tagModifiers[1]).toEqual({
tag: "#mage",
target: "$mod_mp",
expression: "15",
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("rejects invalid threshold", () => {
const csv = `tag,threshold,key,expr
#warrior,0,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
"threshold must be a positive integer",
);
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 csv = `tag,key,expr
,$hp,$con*5
#warrior,$mod_hp,20
,$ac,10+$dex`;
const result = parseDeclareCsv(csv, "test.md");
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("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("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 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 unknown top-level keys", () => {
expect(() => parseDeclare("variable:\n $hp: 1\n")).toThrow(
"Unrecognized key",
);
});
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 a variable name without $", () => {
expect(() => parseDeclare("vars:\n hp: 1\n")).toThrow(
"variable names 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 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 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",
threshold: 1,
});
test("rejects non-numeric, non-string values", () => {
expect(() => parseDeclare("vars:\n $hp: true\n")).toThrow();
});
});
@@ -229,13 +229,13 @@ describe("parseBlockAttrs", () => {
});
test("parses quoted values", () => {
const attrs = parseBlockAttrs('csv id="my stats" role=declare');
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("csv role=declare foo=bar baz=42");
const attrs = parseBlockAttrs("yaml role=declare foo=bar baz=42");
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
});