fix(journal): surface variable eval errors

Collect and report failed declarations, failed tag modifier expressions, and
circular dependencies instead of swallowing them. Diagnostics are deduped,
logged with key/expression context, cleared on re-init, and exposed through
useJournalCompletions and the VariableView warning banner. A bad declaration
no longer aborts the rest of a cascade.

Reject malformed numeric literals in the tokenizer so inputs like "1.2.3"
throw instead of being truncated by parseFloat.
This commit is contained in:
hyper
2026-09-12 22:22:18 +08:00
parent a26d53cbeb
commit 9f372fa2b0
7 changed files with 399 additions and 9 deletions
+117
View File
@@ -40,6 +40,7 @@ import {
getDeclExpr,
extractDependencies,
rebuildReactivityFromStore,
getReactivityDiagnostics,
} from "../../components/journal/var-reactivity";
import { parseInput } from "../../components/journal/command-parser";
import {
@@ -396,6 +397,15 @@ describe("evaluateExpression", () => {
).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 });
@@ -574,6 +584,113 @@ describe("var-reactivity", () => {
});
});
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: [] });