Files
learnharness/tests/repair-math-display.test.tsx
T

205 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Display repair is shared, not a mutation of saved practice/teaching arguments. */
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { validateToolArguments } from "@earendil-works/pi-ai";
import { problemInputObject } from "../src/agent/tools/practice.ts";
import { PracticeDock } from "../components/practice-dock.tsx";
import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx";
import { MessageResponse } from "../components/ai-elements/message.tsx";
import {
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
toLlmMessages, type LhMessage, type ArtifactPartProps,
} from "../src/core/index.ts";
const tex = String.raw`\nabla f(x) \in \mathbb{R}^{m \times n}`;
const clean = `$${tex}$`;
const doubled = clean.replace(/\\/g, "\\\\");
const quadrupled = clean.replace(/\\/g, "\\\\\\\\");
const debris = "$\forall x \\in \\mathbb{R}$"; // JSON's form feed consumed the f.
const repairedDebris = String.raw`\forall x \in \mathbb{R}`;
const matrix = "$$\n" + String.raw`\\begin{pmatrix}1 & 2 \\ 3 & 4\\end{pmatrix}` + "\n$$";
const repairedMatrix = String.raw`\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}`;
const part = (input: unknown, over: Partial<ArtifactPartProps["part"]> = {}): ArtifactPartProps["part"] => ({
type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over,
});
const dock = (input: { statement: string }, expanded = true) => (
<PracticeDock problemCallId="call_math" input={input} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
);
function math(html: string) {
const { document } = parseHTML(html);
return {
annotations: [...document.querySelectorAll('annotation[encoding="application/x-tex"]')].map((n) => n.textContent),
glyphs: [...document.querySelectorAll(".katex-html")].map((n) => n.textContent).join(""),
errors: document.querySelectorAll(".katex-error").length,
};
}
function assertMath(html: string, expected = [tex], glyphs = ["∇", "∈", "×"]) {
const actual = math(html);
assert.deepEqual(actual.annotations, expected, "exact TeX, not merely absence of errors");
assert.equal(actual.errors, 0);
for (const glyph of glyphs) assert.ok(actual.glyphs.includes(glyph), `rendered glyph ${glyph}`);
}
describe("repairMathForDisplay — the existing composition, unchanged", () => {
const unchanged = [
clean,
String.raw`outside \\nabla`,
String.raw`$a \\\nabla b$`,
String.raw`$a \\\\\nabla b$`,
String.raw`$a \\ 2 \\& 3 \\[6pt] 4$`,
"$$\na \\\\\\\\ 2\nb\n$$", // four real row-break slashes survive too.
"`" + doubled + "`",
"```tex\n" + doubled + "\n```",
"`" + debris + "`",
"```tex\n" + debris + "\n```",
String.raw`$\\nabla`,
"$$A = \\\\begin{pmatrix}\n1 & 2\n\\\\end{pmatrix}$$", // unsupported midline display close.
];
for (const [index, value] of [...unchanged, doubled, quadrupled, debris, matrix,
String.raw`\(\\nabla\)`, "\\[\n\\\\nabla\n\\]"].entries()) {
it(`matches the old composition byte-for-byte (${index})`, () => {
assert.equal(repairMathForDisplay(value), healOverescapedCommands(restoreMathEscapes(value)));
});
}
it("preserves clean math, odd runs, row breaks, code, prose and existing recognition limits", () => {
for (const value of unchanged) assert.equal(repairMathForDisplay(value), value);
assert.equal(repairMathForDisplay(doubled), clean);
assert.equal(repairMathForDisplay(quadrupled), clean);
assert.equal(repairMathForDisplay(debris), `$${repairedDebris}$`);
assert.equal(repairMathForDisplay(matrix), `$$\n${repairedMatrix}\n$$`);
});
});
describe("practice display surfaces", () => {
for (const [name, raw, expected, glyphs] of [
["two command slashes", doubled, tex, ["∇", "∈", "×"]],
["four command slashes", quadrupled, tex, ["∇", "∈", "×"]],
["JSON debris", debris, repairedDebris, ["∀", "∈"]],
["clean math", clean, tex, ["∇", "∈", "×"]],
["matrix with legitimate row break", matrix, repairedMatrix, ["1", "2", "3", "4"]],
] as const) {
it(`${name}: expanded dock, settled statement, verdict, committed reveal and legacy fallback`, () => {
const input = Object.freeze({ statement: raw, modelSolution: `HIDDEN COMMITMENT ${raw}` });
const conclusion = Object.freeze({ outcome: "solved", verdict: raw, solutionReveal: raw });
const before = JSON.stringify({ input, conclusion });
for (const element of [dock(input), <ProblemCard part={part(input)} />]) {
const html = renderToStaticMarkup(element);
assertMath(html, [expected], [...glyphs]);
assert.ok(!html.includes("HIDDEN COMMITMENT"));
}
assertMath(renderToStaticMarkup(<ConcludeCard part={part(conclusion, { type: "tool-conclude", reveal: raw })} />),
[expected, expected], [...glyphs]);
assertMath(renderToStaticMarkup(<ConcludeCard part={part({ outcome: "solved", solutionReveal: raw })} />),
[expected], [...glyphs]);
assert.equal(JSON.stringify({ input, conclusion }), before);
});
}
it("keeps a boundary-converted numeric statement raw and preserves prior dock passthrough", () => {
const raw = { statement: 17, mode: "recall", modelSolution: "Committed solution." };
const call = { type: "toolCall", id: "numeric_problem", name: "problem", arguments: raw };
const validated = validateToolArguments(
{ name: "problem", parameters: problemInputObject } as never,
call as never,
) as { statement: unknown };
assert.equal(validated.statement, "17", "the actual tool boundary converts its cloned arguments");
assert.equal(raw.statement, 17, "saved arguments retain the number");
const messages = [
{ id: "numeric_pose", role: "assistant", content: [call], timestamp: 1 },
{ id: "numeric_ack", role: "toolResult", toolCallId: call.id, toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
] as unknown as LhMessage[];
const before = structuredClone(messages);
const active = activeProblemOf(messages);
assert.ok(active);
assert.equal((active.input as { statement: unknown }).statement, 17);
// Mirror the existing runtime cast at the Chat -> PracticeDock seam.
const input = active.input as { statement: string };
let expanded = "";
assert.doesNotThrow(() => { expanded = renderToStaticMarkup(dock(input)); });
const { document } = parseHTML(expanded);
assert.equal(document.querySelector(".lh-prose")!.innerHTML,
parseHTML(renderToStaticMarkup(<MessageResponse>{input.statement}</MessageResponse>)).document.firstElementChild!.outerHTML,
"non-string values pass through exactly as before, without string coercion or a new fallback");
const collapsed = renderToStaticMarkup(dock(input, false));
assert.match(collapsed, /data-testid="practice-dock-bar"/);
assert.equal(parseHTML(collapsed).document.querySelector("span.truncate")!.textContent, "17");
assert.deepEqual(math(collapsed).annotations, []);
assert.deepEqual(messages, before);
assert.equal(raw.statement, 17);
});
it("preserves code spans/fences across every repaired display surface", () => {
const raw = "`" + doubled + "`\n\n```text\n" + doubled + "\n```";
for (const element of [dock({ statement: raw }), <ProblemCard part={part({ statement: raw })} />,
<ConcludeCard part={part({ outcome: "solved", verdict: raw }, { reveal: raw })} />]) {
const html = renderToStaticMarkup(element);
assert.deepEqual(math(html).annotations, []);
const { document } = parseHTML(html);
assert.ok(document.querySelector("code")?.textContent?.includes(doubled));
}
});
it("does not change the collapsed preview, dismissal or global prose renderer", () => {
const preview = renderToStaticMarkup(dock({ statement: doubled }, false));
assert.ok(preview.includes(doubled));
assert.deepEqual(math(preview).annotations, []);
const dismissed = renderToStaticMarkup(<ConcludeCard part={part({ outcome: "dismissed", verdict: doubled }, { reveal: doubled })} />);
assert.ok(dismissed.includes("Problem dismissed."));
assert.deepEqual(math(dismissed).annotations, []);
const plain = math(renderToStaticMarkup(<MessageResponse>{doubled}</MessageResponse>));
assert.deepEqual(plain.annotations, [doubled.slice(1, -1)]);
assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path.
});
it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => {
const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris };
const ask = { question: doubled };
const explain = { question: doubled, correctAnswer: debris };
const before = structuredClone({ quiz, ask, explain });
assert.equal(parseQuizInput(quiz)?.question, clean);
assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`);
assert.equal(parseAskInput(ask)?.question, clean);
assert.equal(parseExplainInput(explain)?.question, clean);
assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`);
assertMath(renderToStaticMarkup(<MessageResponse>{parseAskInput(ask)!.question}</MessageResponse>));
assert.deepEqual({ quiz, ask, explain }, before);
});
it("native history, active args and the view join remain raw; commitment appears only at conclusion", () => {
const stamps = { api: "faux", provider: "test", model: "test-model", timestamp: 1, stopReason: "toolUse" };
const input = { statement: doubled, modelSolution: `COMMITTED ${quadrupled}` };
const messages = [
{ ...stamps, id: "a1", role: "assistant", content: [{ type: "toolCall", id: "p1", name: "problem", arguments: input }] },
{ id: "r1", role: "toolResult", toolCallId: "p1", toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
] as unknown as LhMessage[];
const concluded = [...messages,
{ ...stamps, id: "a2", role: "assistant", content: [{ type: "toolCall", id: "c1", name: "conclude", arguments: { outcome: "solved", verdict: doubled } }] },
{ id: "r2", role: "toolResult", toolCallId: "c1", toolName: "conclude", content: [{ type: "text", text: "Problem concluded" }], details: null, isError: false, timestamp: 3 },
] as unknown as LhMessage[];
const before = structuredClone(concluded);
const history = structuredClone(toLlmMessages(concluded));
assert.deepEqual(activeProblemOf(messages)?.input, input);
const problem = projectView(messages)[0]!.parts.find((p) => p.type === "tool-problem")!;
assert.ok(problem.state && problem.toolCallId);
assert.deepEqual(problem.input, input);
const statementHtml = renderToStaticMarkup(<ProblemCard part={{ ...problem, state: problem.state, toolCallId: problem.toolCallId }} />);
assertMath(statementHtml);
assert.ok(!statementHtml.includes("COMMITTED"));
const conclusion = projectView(concluded).flatMap((m) => m.parts).find((p) => p.type === "tool-conclude")!;
assert.ok(conclusion.state && conclusion.toolCallId);
assert.equal(conclusion.reveal, input.modelSolution);
const revealHtml = renderToStaticMarkup(<ConcludeCard part={{ ...conclusion, state: conclusion.state, toolCallId: conclusion.toolCallId }} />);
assert.ok(revealHtml.includes("COMMITTED"));
assertMath(revealHtml, [tex, tex]);
assert.deepEqual(concluded, before);
assert.deepEqual(toLlmMessages(concluded), history);
});
});