diff --git a/tests/render.test.ts b/tests/render.test.ts new file mode 100644 index 0000000..a2e1c16 --- /dev/null +++ b/tests/render.test.ts @@ -0,0 +1,160 @@ +/** + * Render smoke tests — the layer every earlier bug slipped through. + * + * Components are rendered to static HTML (no browser, no DOM) and we assert + * the expected content is actually IN the output. These would have caught: + * - Markdown never receiving its text (assistant messages rendered empty) + * - ask answers rendered as graded quiz answers (red ✗) + * - quiz options built from native radio inputs (the desync bug) + * + * Mermaid: SSR skips effects, so diagrams don't render here — that's fine, + * we only assert the surrounding markup doesn't crash. + * + * Note: components carry `import React from "react"` because tsx (the test + * runner) uses the classic JSX transform, which needs React in scope. Vite + * uses the automatic runtime, where the import is simply unused. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createElement as h } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Markdown } from "../web/src/components/Markdown.tsx"; +import { QuizCard } from "../web/src/components/QuizCard.tsx"; +import { AnsweredCard } from "../web/src/components/AnsweredCard.tsx"; +import { EntryBlock } from "../web/src/views/SessionView.tsx"; +import type { ClientQuestion, QuestionAnswerData } from "../src/shared/protocol.ts"; + +describe("render smoke tests", () => { + it("Markdown renders the actual text content", () => { + const html = renderToStaticMarkup(h(Markdown, { text: "Hello **bold** world" })); + assert.match(html, /Hello/); + assert.match(html, /bold<\/strong>/); + assert.match(html, /world/); + }); + + it("Markdown renders LaTeX via KaTeX", () => { + const html = renderToStaticMarkup(h(Markdown, { text: "Energy is $E = mc^2$ here." })); + assert.match(html, /here\./); + assert.match(html, /katex/i, "KaTeX markup present"); + }); + + it("Markdown survives a mermaid block without crashing", () => { + const html = renderToStaticMarkup(h(Markdown, { text: "Before\n\n```mermaid\ngraph TD\nA-->B\n```\n\nAfter" })); + assert.match(html, /Before/); + assert.match(html, /After/); + }); + + it("QuizCard renders options as buttons (no native radio/checkbox inputs)", () => { + const q: ClientQuestion = { + id: "q1", + kind: "quiz", + question: "Which planet?", + mode: "single-select", + options: [ + { label: "Mercury", value: "mercury" }, + { label: "Venus", value: "venus" }, + ], + multiSelect: false, + }; + const html = renderToStaticMarkup(h(QuizCard, { question: q, send: () => {} })); + assert.match(html, /Mercury/); + assert.match(html, /Venus/); + assert.match(html, /I don't know/); + assert.ok(!/]*type="radio"/.test(html), "no native radio inputs (v1 desync bug)"); + assert.ok(!/]*type="checkbox"/.test(html), "no native checkbox inputs"); + assert.match(html, /]*role="radio"/, "options are buttons with radio role"); + }); + + it("QuizCard multi-select uses checkbox role on buttons", () => { + const q: ClientQuestion = { + id: "q2", + kind: "quiz", + question: "Pick all.", + mode: "multi-select", + options: [ + { label: "A", value: "a" }, + { label: "B", value: "b" }, + ], + multiSelect: true, + }; + const html = renderToStaticMarkup(h(QuizCard, { question: q, send: () => {} })); + assert.match(html, /]*role="checkbox"/); + }); + + it("AnsweredCard: quiz answers are graded", () => { + const data: QuestionAnswerData = { + questionId: "q1", + kind: "quiz", + questionText: "Which planet?", + mode: "single-select", + options: [ + { index: 1, label: "Mercury" }, + { index: 2, label: "Venus" }, + ], + answers: [{ index: 1, label: "Mercury", value: "mercury" }], + correctIndices: [1], + correct: true, + explanation: "Because.", + }; + const html = renderToStaticMarkup(h(AnsweredCard, { data })); + assert.match(html, /Correct/); + assert.match(html, /✓/); + }); + + it("AnsweredCard: ask answers are NOT graded (no ✗, no verdict)", () => { + const data: QuestionAnswerData = { + questionId: "q1", + kind: "ask", + questionText: "What do you prefer?", + mode: "single-select", + options: [ + { index: 1, label: "Option A" }, + { index: 2, label: "Option B" }, + ], + answers: [{ index: 2, label: "Option B", value: "b" }], + }; + const html = renderToStaticMarkup(h(AnsweredCard, { data })); + assert.match(html, /Option B/); + assert.ok(!html.includes("✗"), "ask answers never show ✗ (v1 grading bug)"); + assert.ok(!/Incorrect/.test(html), "no grading verdict for ask"); + assert.ok(!/Correct<\/div>/.test(html)); + }); + + it("AnsweredCard: dismissed questions render as skipped", () => { + const data: QuestionAnswerData = { + questionId: "q1", + kind: "quiz", + questionText: "Q?", + mode: "single-select", + options: [], + answers: [], + dismissed: true, + }; + const html = renderToStaticMarkup(h(AnsweredCard, { data })); + assert.match(html, /Skipped/); + }); + + it("EntryBlock: toolResult entries (LLM plumbing) are never rendered", () => { + const html = renderToStaticMarkup( + h(EntryBlock, { + entry: { + type: "toolResult", + id: "e1", + toolName: "quiz", + text: "Quiz displayed to the learner. End your turn now and wait…", + }, + }), + ); + assert.ok(!html.includes("End your turn"), "dispatch instructions must not leak into the transcript"); + }); + + it("EntryBlock: quiz-answer user messages are not duplicated in the transcript", () => { + const html = renderToStaticMarkup( + h(EntryBlock, { entry: { type: "user", id: "e2", text: "[Quiz answer] Selected: Mercury" } }), + ); + assert.ok(!html.includes("Mercury"), "already rendered as an answer card"); + const normal = renderToStaticMarkup(h(EntryBlock, { entry: { type: "user", id: "e3", text: "Teach me physics" } })); + assert.match(normal, /Teach me physics/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a05c14a..86b2f78 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "noUncheckedIndexedAccess": true, "verbatimModuleSyntax": true, "allowImportingTsExtensions": true, + "jsx": "react-jsx", "noEmit": true, "skipLibCheck": true }, diff --git a/web/src/App.tsx b/web/src/App.tsx index e7f98ff..23cdef8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,7 @@ * resumes via POST before connecting. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import type { NewSessionResponse, SessionSummary } from "../../src/shared/protocol.ts"; import { Home } from "./views/Home.tsx"; import { SessionView } from "./views/SessionView.tsx"; diff --git a/web/src/app.css b/web/src/app.css index 7df8dbd..96b8e86 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -311,14 +311,6 @@ textarea:disabled { max-width: 420px; } -.tool-result { - padding: 2px 24px; - font-size: 12.5px; - color: var(--text-muted); - font-style: italic; - max-width: 820px; -} - .composer { display: flex; gap: 10px; diff --git a/web/src/components/AnsweredCard.tsx b/web/src/components/AnsweredCard.tsx index d55dfd9..e472ea6 100644 --- a/web/src/components/AnsweredCard.tsx +++ b/web/src/components/AnsweredCard.tsx @@ -1,3 +1,4 @@ +import React from "react"; import type { QuestionAnswerData } from "../../../src/shared/protocol.ts"; /** diff --git a/web/src/components/GraphPlaceholder.tsx b/web/src/components/GraphPlaceholder.tsx index 5408ad8..79e8979 100644 --- a/web/src/components/GraphPlaceholder.tsx +++ b/web/src/components/GraphPlaceholder.tsx @@ -1,3 +1,5 @@ +import React from "react"; + /** * GraphPlaceholder — the home view's main panel. Deliberately isolated: the * real knowledge graph (territory map, domain clusters, frontier) is a diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx index 5bd92a0..642d6a4 100644 --- a/web/src/components/Markdown.tsx +++ b/web/src/components/Markdown.tsx @@ -1,8 +1,7 @@ -import { memo, useEffect, useRef, useState } from "react"; +import React, { memo, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; -import "katex/dist/katex.min.css"; /** * Markdown — renders assistant prose with LaTeX (KaTeX) and mermaid diagrams @@ -73,7 +72,9 @@ export const Markdown = memo(function Markdown({ text }: { text: string }) { return
{children}
; }, }} - /> + > + {text} + ); }); diff --git a/web/src/components/Message.tsx b/web/src/components/Message.tsx index a8043a8..9dd3c33 100644 --- a/web/src/components/Message.tsx +++ b/web/src/components/Message.tsx @@ -7,7 +7,7 @@ * survives reloads and resumes. */ -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import type { ClientToolCall } from "../../../src/shared/protocol.ts"; import { Markdown } from "./Markdown.tsx"; diff --git a/web/src/components/QuizCard.tsx b/web/src/components/QuizCard.tsx index 04ae79f..7ace956 100644 --- a/web/src/components/QuizCard.tsx +++ b/web/src/components/QuizCard.tsx @@ -11,7 +11,7 @@ * - text mode (ask): free-form answer */ -import { useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import type { ClientQuestion } from "../../../src/shared/protocol.ts"; interface QuizCardProps { diff --git a/web/src/components/Transcript.tsx b/web/src/components/Transcript.tsx index b8a8d88..3081505 100644 --- a/web/src/components/Transcript.tsx +++ b/web/src/components/Transcript.tsx @@ -4,7 +4,7 @@ * auto-scrolling pauses and a "jump to latest" pill appears. */ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import React, { useEffect, useRef, useState, type ReactNode } from "react"; export function Transcript({ children, scrollToken }: { children: ReactNode; scrollToken: string | number }) { const ref = useRef(null); diff --git a/web/src/main.tsx b/web/src/main.tsx index 8601ec3..91bd33e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,7 @@ -import { StrictMode } from "react"; +import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.tsx"; +import "katex/dist/katex.min.css"; import "./app.css"; createRoot(document.getElementById("root")!).render( diff --git a/web/src/views/Home.tsx b/web/src/views/Home.tsx index 08b371b..ae43a59 100644 --- a/web/src/views/Home.tsx +++ b/web/src/views/Home.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import React, { useState } from "react"; import type { SessionSummary } from "../../../src/shared/protocol.ts"; import { GraphPlaceholder } from "../components/GraphPlaceholder.tsx"; diff --git a/web/src/views/SessionView.tsx b/web/src/views/SessionView.tsx index b8057f8..3a1b742 100644 --- a/web/src/views/SessionView.tsx +++ b/web/src/views/SessionView.tsx @@ -10,7 +10,7 @@ * and the composer. */ -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import type { DisplayEntry, SessionState } from "../useSession.ts"; import { useSession } from "../useSession.ts"; import { Markdown } from "../components/Markdown.tsx"; @@ -29,7 +29,7 @@ interface SessionViewProps { /** User messages already rendered as answer cards — don't duplicate them. */ const HIDDEN_USER_PREFIXES = ["[Quiz answer]", "[Answer to your question]"]; -function EntryBlock({ entry }: { entry: DisplayEntry }) { +export function EntryBlock({ entry }: { entry: DisplayEntry }) { switch (entry.type) { case "user": if (HIDDEN_USER_PREFIXES.some((p) => entry.text.startsWith(p))) return null; @@ -37,7 +37,9 @@ function EntryBlock({ entry }: { entry: DisplayEntry }) { case "assistant": return ; case "toolResult": - return
{entry.text}
; + // Internal plumbing for the LLM ("Quiz displayed to the learner, + // end your turn…") — never learner-visible. + return null; case "question": // Rendered by the pending-question flow; when answered, the // questionAnswered entry renders instead.