quick-fixes round: quiz-only teach-check reveal, ESC-abort confirm popup + question dismissal, confidence prompt diet
- requiresReveal gates on pending.tool === 'quiz' (owner: only graded quizzes arrive collapsed; ask/explain always open) - ESC-abort chain opens AbortTurnDialog everywhere (owner ruling); from a pending question abort IS dismissal — dismissedAnswer rides the answer path, the teacher acknowledges the skip in its next turn; popup-Esc is claimed via the state gate (two window listeners fire per Esc) - 25-confidence.md: stripped the slider/JSON/calibration mechanics — pure reading doctrine remains - toolQuestionKind named once in core (dock parity), dock switched to it
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ESC-abort guard (owner, 2026-10-02): the global Esc → abort chain
|
||||||
|
* opens this confirm popup EVERYWHERE — mid-stream aborts and pending-
|
||||||
|
* question dismissals alike. Esc on the popup itself cancels (claimed —
|
||||||
|
* it must never chain into another abort), which is also why Chat's
|
||||||
|
* abort handler ignores events while this dialog is open: two window
|
||||||
|
* listeners fire per Esc and stopPropagation alone cannot order them.
|
||||||
|
*
|
||||||
|
* MINIMAL, the delete-node dialog's tone: title, one line, Cancel +
|
||||||
|
* action. No busy state — both actions are local (a fetch that fires
|
||||||
|
* and forgets, an answer submit whose own machinery shows progress).
|
||||||
|
*
|
||||||
|
* What "abort" means is mode-dependent: from a running turn it kills
|
||||||
|
* the turn (partial stays); from a pending question there is no turn to
|
||||||
|
* kill — abort IS dismissal (QuestionAnswerData.dismissed), and the
|
||||||
|
* teacher acknowledges the skip in its next turn like any answer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What Esc is aborting — drives copy and the confirm action. */
|
||||||
|
export type AbortMode = "turn" | "question";
|
||||||
|
|
||||||
|
const COPY: Record<AbortMode, { title: string; line: string; action: string }> = {
|
||||||
|
turn: {
|
||||||
|
title: "Abort this turn?",
|
||||||
|
line: "The partial answer stays in the transcript.",
|
||||||
|
action: "Abort",
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
title: "Dismiss this question?",
|
||||||
|
line: "Skipped — the teacher moves on without it.",
|
||||||
|
action: "Dismiss",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The dialog's pure body — exported for SSR pins (the delete-node
|
||||||
|
* pattern). The wrapper owns effects + the portal. */
|
||||||
|
export function AbortTurnBody({
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
mode: AbortMode;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
const copy = COPY[mode];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={copy.title}
|
||||||
|
data-testid="abort-turn-dialog"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
|
||||||
|
// A click on the scrim cancels — the cautious default, like Esc.
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mx-4 w-full max-w-sm rounded-xl border bg-card p-5 shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title">
|
||||||
|
{copy.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground">{copy.line}</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="abort-turn-cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="abort-turn-confirm"
|
||||||
|
onClick={onConfirm}
|
||||||
|
// Destructive red — the app's established destructive pair.
|
||||||
|
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20"
|
||||||
|
>
|
||||||
|
{copy.action}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AbortTurnDialog({
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
/** What Esc is aborting; null = closed (nothing rendered). */
|
||||||
|
mode: AbortMode | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (mode: AbortMode) => void;
|
||||||
|
}) {
|
||||||
|
// Esc cancels the popup (claimed — never a chained abort).
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === null) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [mode, onClose]);
|
||||||
|
|
||||||
|
if (mode === null || typeof document === "undefined") return null;
|
||||||
|
return createPortal(
|
||||||
|
<AbortTurnBody mode={mode} onClose={onClose} onConfirm={() => onConfirm(mode)} />,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
+42
-8
@@ -30,6 +30,7 @@ import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSetti
|
|||||||
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
||||||
import { PlanMarginalia, planExists } from "@/components/plan-window";
|
import { PlanMarginalia, planExists } from "@/components/plan-window";
|
||||||
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
||||||
|
import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog";
|
||||||
import { WaypointsIcon } from "lucide-react";
|
import { WaypointsIcon } from "lucide-react";
|
||||||
import type { PlanBlockData } from "@/src/core";
|
import type { PlanBlockData } from "@/src/core";
|
||||||
import { QuestionDock } from "@/components/question-dock";
|
import { QuestionDock } from "@/components/question-dock";
|
||||||
@@ -38,7 +39,7 @@ import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsag
|
|||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
import { CoverageRing } from "@/components/extraction-coverage";
|
import { CoverageRing } from "@/components/extraction-coverage";
|
||||||
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
|
import { dismissedAnswer, findPendingToolCall, phaseAt3, phaseTimeline, toolQuestionKind } from "@/src/core";
|
||||||
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
import { useSettingsSaved } from "@/components/settings-signal";
|
||||||
|
|
||||||
@@ -298,13 +299,39 @@ export function Chat({
|
|||||||
// 409 not_running is the benign raced-settle: the stream's finish
|
// 409 not_running is the benign raced-settle: the stream's finish
|
||||||
// already settled the view (or the poll fallback adopts) — nothing owed.
|
// already settled the view (or the poll fallback adopts) — nothing owed.
|
||||||
}, [chatId, stop]);
|
}, [chatId, stop]);
|
||||||
|
// ESC-abort guard (owner, 2026-10-02): the abort chain opens a confirm
|
||||||
|
// popup EVERYWHERE (streams and pending questions alike). Both window
|
||||||
|
// listeners fire per Esc, so the state gate here — not stopPropagation
|
||||||
|
// ordering — is what keeps the popup's own Esc from chaining.
|
||||||
|
const [abortAsk, setAbortAsk] = useState<AbortMode | null>(null);
|
||||||
|
const abortAskRef = useRef<AbortMode | null>(null);
|
||||||
|
abortAskRef.current = abortAsk;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
if (busyRef.current) abortInFlightTurn();
|
if (abortAskRef.current !== null) return; // the popup's Esc — claimed
|
||||||
|
if (pendingRef.current) setAbortAsk("question");
|
||||||
|
else if (busyRef.current) setAbortAsk("turn");
|
||||||
};
|
};
|
||||||
window.addEventListener("lh:kbd-abort-turn", onAbort);
|
window.addEventListener("lh:kbd-abort-turn", onAbort);
|
||||||
return () => window.removeEventListener("lh:kbd-abort-turn", onAbort);
|
return () => window.removeEventListener("lh:kbd-abort-turn", onAbort);
|
||||||
}, [abortInFlightTurn]);
|
}, []);
|
||||||
|
const confirmAbort = useCallback(
|
||||||
|
(mode: AbortMode) => {
|
||||||
|
setAbortAsk(null);
|
||||||
|
if (mode === "turn") {
|
||||||
|
abortInFlightTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// From a pending question there is no turn to kill — abort IS
|
||||||
|
// dismissal, riding the same answer path as any answer (the
|
||||||
|
// teacher acknowledges the skip in its next turn).
|
||||||
|
const p = pendingRef.current;
|
||||||
|
if (!p) return;
|
||||||
|
const question = (p.input as { question?: string } | null)?.question ?? "";
|
||||||
|
void submitAnswer(p.tool, p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question));
|
||||||
|
},
|
||||||
|
[abortInFlightTurn, submitAnswer],
|
||||||
|
);
|
||||||
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
||||||
// (a job result landed while no client was open), resume the lesson — the
|
// (a job result landed while no client was open), resume the lesson — the
|
||||||
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
|
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
|
||||||
@@ -323,6 +350,10 @@ export function Chat({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pending = findPendingToolCall(messages);
|
const pending = findPendingToolCall(messages);
|
||||||
|
// The abort handler reads the pending question from a ref (the window
|
||||||
|
// listener is mount-scoped; the value it needs is render-scoped).
|
||||||
|
const pendingRef = useRef(pending);
|
||||||
|
pendingRef.current = pending;
|
||||||
const localStream = status === "submitted" || status === "streaming";
|
const localStream = status === "submitted" || status === "streaming";
|
||||||
const busy = localStream || turnInFlight;
|
const busy = localStream || turnInFlight;
|
||||||
const busyRef = useRef(busy);
|
const busyRef = useRef(busy);
|
||||||
@@ -351,13 +382,15 @@ export function Chat({
|
|||||||
void resumeStream();
|
void resumeStream();
|
||||||
}, [resumeStream]);
|
}, [resumeStream]);
|
||||||
|
|
||||||
// Teach-phase check (W5, vision §2): a pending question that lands once a
|
// Teach-phase check (W5, vision §2): a pending QUIZ that lands once a
|
||||||
// plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a
|
// plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a
|
||||||
// "Check your understanding" reveal button; probe- and plan-phase
|
// "Check your understanding" reveal button; probe- and plan-phase
|
||||||
// questions auto-open as today. A pending question can never precede its
|
// questions auto-open as today. Graded quizzes only (owner, 2026-10-02)
|
||||||
// phase's seam in the transcript (the teacher's turn ends at the question
|
// — ask/explain questions always arrive open. A pending question can
|
||||||
// call), so the session's current phase IS the question's phase.
|
// never precede its phase's seam in the transcript (the teacher's turn
|
||||||
const requiresReveal = pending !== null && phaseAt3(messages) === "teach";
|
// ends at the question call), so the session's current phase IS the
|
||||||
|
// question's phase.
|
||||||
|
const requiresReveal = pending !== null && pending.tool === "quiz" && phaseAt3(messages) === "teach";
|
||||||
|
|
||||||
// The phase at every message (one linear fold): the transcript draws a
|
// The phase at every message (one linear fold): the transcript draws a
|
||||||
// color-coded dotted divider at each boundary; the header chip reads the
|
// color-coded dotted divider at each boundary; the header chip reads the
|
||||||
@@ -1228,6 +1261,7 @@ export function Chat({
|
|||||||
<MermaidOverlay chart={mermaidFocus} onClose={() => setMermaidFocus(null)} />
|
<MermaidOverlay chart={mermaidFocus} onClose={() => setMermaidFocus(null)} />
|
||||||
)}
|
)}
|
||||||
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
|
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
|
||||||
|
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
resolveCorrect,
|
resolveCorrect,
|
||||||
seededRandom,
|
seededRandom,
|
||||||
shuffleOptions,
|
shuffleOptions,
|
||||||
|
toolQuestionKind,
|
||||||
parseAskInput,
|
parseAskInput,
|
||||||
parseQuizInput,
|
parseQuizInput,
|
||||||
parseExplainInput,
|
parseExplainInput,
|
||||||
@@ -208,7 +209,7 @@ function QuestionDockForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onEscape,
|
onEscape,
|
||||||
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
||||||
const kind = toolName === "quiz" ? ("quiz" as const) : toolName === "explain" ? ("explain" as const) : ("ask" as const);
|
const kind = toolQuestionKind(toolName);
|
||||||
|
|
||||||
// Canonical QuestionData, built once per question. Quiz options are
|
// Canonical QuestionData, built once per question. Quiz options are
|
||||||
// deterministically shuffled (seeded by toolCallId) so position never leaks
|
// deterministically shuffled (seeded by toolCallId) so position never leaks
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
## Confidence — reading the learner's self-assessment
|
## Confidence — reading the learner's self-assessment
|
||||||
|
|
||||||
Every quiz answer arrives as a compact JSON record; its `confidence` field is the learner's stated probability that his selected answer is correct, set on a snapped slider (0, 30, 50, 70, 90, 100). This is calibration data, not decoration. Read it against the verdict:
|
Every quiz answer carries `confidence`: the learner's stated probability, as a percentage, that the answer he picked is correct. Read it against the verdict — it changes what the outcome means:
|
||||||
|
|
||||||
- 50 is a genuine coin flip. 90–100 claims certainty; 0–30 claims real doubt.
|
- 50 is a genuine coin flip. 90–100 claims certainty; 0–30 claims real doubt.
|
||||||
- For an n-option question, chance is 1/n. Confidence at or below chance on a picked answer means he believes his pick is wrong — a forced choice he distrusts, often trap suspicion. Treat it as honesty, not error.
|
- For an n-option question, chance is 1/n. Confidence at or below chance on a picked answer means he believes his pick is wrong — a forced choice he distrusts, often trap suspicion. Treat it as honesty, not error.
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export interface PendingToolCall {
|
|||||||
/** Tools that render in the question dock and gate the composer. */
|
/** Tools that render in the question dock and gate the composer. */
|
||||||
export const QUESTION_TOOLS = ["quiz", "ask_user_question", "explain"] as const;
|
export const QUESTION_TOOLS = ["quiz", "ask_user_question", "explain"] as const;
|
||||||
|
|
||||||
|
/** The tool→kind map (the dock's own fold, named once — the dismissal
|
||||||
|
* path and the dock must never disagree on what a tool is). */
|
||||||
|
export function toolQuestionKind(tool: string): "quiz" | "ask" | "explain" {
|
||||||
|
return tool === "quiz" ? "quiz" : tool === "explain" ? "explain" : "ask";
|
||||||
|
}
|
||||||
|
|
||||||
/** Tools fulfilled by background sub-agent jobs (W2/W6). */
|
/** Tools fulfilled by background sub-agent jobs (W2/W6). */
|
||||||
export const JOB_TOOLS = ["research", "demo", "visual"] as const;
|
export const JOB_TOOLS = ["research", "demo", "visual"] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -615,6 +615,23 @@ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswe
|
|||||||
* note. The full graded QuestionAnswerData rides `details` for the record
|
* note. The full graded QuestionAnswerData rides `details` for the record
|
||||||
* and the UI; this is the JSON the agent actually reads.
|
* and the UI; this is the JSON the agent actually reads.
|
||||||
*/
|
*/
|
||||||
|
/** The dismissal record (ESC-abort from a pending question, owner
|
||||||
|
* 2026-10-02): the minimum honest answer. The model sees
|
||||||
|
* {kind, dismissed:true} via compactAnswerForModel; the answered card
|
||||||
|
* renders the question text + "skipped". Rides the same answer path —
|
||||||
|
* the teacher acknowledges the skip in its next turn, exactly like any
|
||||||
|
* answer. */
|
||||||
|
export function dismissedAnswer(kind: QuestionKind, questionText: string): QuestionAnswerData {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
questionText,
|
||||||
|
dontKnow: false,
|
||||||
|
answers: [],
|
||||||
|
dismissed: true,
|
||||||
|
answeredAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function compactAnswerForModel(ad: QuestionAnswerData): Record<string, unknown> {
|
export function compactAnswerForModel(ad: QuestionAnswerData): Record<string, unknown> {
|
||||||
if (ad.dismissed) return { kind: ad.kind, dismissed: true };
|
if (ad.dismissed) return { kind: ad.kind, dismissed: true };
|
||||||
const out: Record<string, unknown> = { kind: ad.kind };
|
const out: Record<string, unknown> = { kind: ad.kind };
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
askOptionProblems,
|
askOptionProblems,
|
||||||
buildAnswer,
|
buildAnswer,
|
||||||
compactAnswerForModel,
|
compactAnswerForModel,
|
||||||
|
dismissedAnswer,
|
||||||
coerceCorrectAnswer,
|
coerceCorrectAnswer,
|
||||||
CONFIDENCE_STOPS,
|
CONFIDENCE_STOPS,
|
||||||
DEFAULT_CONFIDENCE_INDEX,
|
DEFAULT_CONFIDENCE_INDEX,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
type QuestionData,
|
type QuestionData,
|
||||||
toClientQuestion,
|
toClientQuestion,
|
||||||
} from "../src/core/question-core.ts";
|
} from "../src/core/question-core.ts";
|
||||||
|
import { toolQuestionKind } from "../src/core/pending.ts";
|
||||||
|
|
||||||
// ─── the authoring contract (TypeBox schemas + execute-time refinements) ──
|
// ─── the authoring contract (TypeBox schemas + execute-time refinements) ──
|
||||||
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
|
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
|
||||||
@@ -458,6 +460,36 @@ describe("compactAnswerForModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── dismissedAnswer / toolQuestionKind (ESC-abort dismissal, 2026-10-02) ──
|
||||||
|
|
||||||
|
describe("dismissedAnswer (the ESC-abort record)", () => {
|
||||||
|
it("the minimum honest answer: kind, questionText, dismissed — nothing else claimed", () => {
|
||||||
|
const ad = dismissedAnswer("ask", "What drives you?");
|
||||||
|
assert.equal(ad.kind, "ask");
|
||||||
|
assert.equal(ad.questionText, "What drives you?");
|
||||||
|
assert.equal(ad.dismissed, true);
|
||||||
|
assert.equal(ad.dontKnow, false); // a skip is not a gap claim
|
||||||
|
assert.deepEqual(ad.answers, []);
|
||||||
|
assert.ok(ad.answeredAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compacts to the one-field model diet", () => {
|
||||||
|
assert.deepEqual(compactAnswerForModel(dismissedAnswer("quiz", "Pick one")), {
|
||||||
|
kind: "quiz",
|
||||||
|
dismissed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toolQuestionKind (tool→kind parity with the dock)", () => {
|
||||||
|
it("the three question tools map; unknown tools fall to ask", () => {
|
||||||
|
assert.equal(toolQuestionKind("quiz"), "quiz");
|
||||||
|
assert.equal(toolQuestionKind("explain"), "explain");
|
||||||
|
assert.equal(toolQuestionKind("ask_user_question"), "ask");
|
||||||
|
assert.equal(toolQuestionKind("anything-else"), "ask");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── toClientQuestion (leak safety) ─────────────────────────────────────────
|
// ─── toClientQuestion (leak safety) ─────────────────────────────────────────
|
||||||
|
|
||||||
describe("toClientQuestion", () => {
|
describe("toClientQuestion", () => {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* SSR pins — abort-turn-dialog (ESC-abort guard, owner 2026-10-02):
|
||||||
|
* the confirm popup EVERYWHERE ruling. AbortTurnBody is the pure seam
|
||||||
|
* (the DeleteNodeBody pattern); the wrapper owns effects + portal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { AbortTurnBody } from "@/components/abort-turn-dialog";
|
||||||
|
|
||||||
|
describe("AbortTurnBody", () => {
|
||||||
|
it("turn mode: names the turn, the partial-stays promise, Abort", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AbortTurnBody mode="turn" onClose={() => {}} onConfirm={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('data-testid="abort-turn-dialog"'));
|
||||||
|
assert.match(html, /Abort this turn\?/);
|
||||||
|
assert.match(html, /The partial answer stays in the transcript\./);
|
||||||
|
assert.match(html, /Cancel/);
|
||||||
|
assert.match(html, /Abort/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("question mode: names the dismissal, the skip, Dismiss", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AbortTurnBody mode="question" onClose={() => {}} onConfirm={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.match(html, /Dismiss this question\?/);
|
||||||
|
assert.match(html, /Skipped — the teacher moves on without it\./);
|
||||||
|
assert.match(html, /Dismiss/);
|
||||||
|
// the abort copy must not leak into the dismissal mode
|
||||||
|
assert.ok(!html.includes("Abort this turn"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the confirm action is the destructive pair, never the cancel", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AbortTurnBody mode="turn" onClose={() => {}} onConfirm={() => {}} />,
|
||||||
|
);
|
||||||
|
const confirmAt = html.indexOf('data-testid="abort-turn-confirm"');
|
||||||
|
const cancelAt = html.indexOf('data-testid="abort-turn-cancel"');
|
||||||
|
assert.ok(confirmAt > -1 && cancelAt > -1);
|
||||||
|
const confirmBtn = html.slice(confirmAt, html.indexOf("</button>", confirmAt));
|
||||||
|
assert.match(confirmBtn, /destructive/);
|
||||||
|
const cancelBtn = html.slice(cancelAt, html.indexOf("</button>", cancelAt));
|
||||||
|
assert.ok(!cancelBtn.includes("destructive"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user