From b139eae3e741e9d1c5f22a7902828f329354aafc Mon Sep 17 00:00:00 2001 From: Yaphet Y Date: Mon, 2 Nov 2026 14:11:12 +0800 Subject: [PATCH] =?UTF-8?q?Remove=20dead=20stuck=20exits=20(=E5=BE=80?= =?UTF-8?q?=E4=B8=8B=E6=8B=86/=E6=8D=A2=E4=B8=AA=E7=B1=BB=E6=AF=94/?= =?UTF-8?q?=E7=A8=8D=E5=90=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- evals/run.ts | 1 - src/App.tsx | 59 ++------------------------- src/components/StopBeats.tsx | 11 +---- src/components/StuckExits.tsx | 34 ---------------- src/model/next-step.ts | 30 ++------------ src/model/stuck.ts | 57 -------------------------- src/shared/types.ts | 8 ++-- src/styles.css | 55 +------------------------ tests/e2e/flow-p2.spec.ts | 72 --------------------------------- tests/e2e/shots-flow-p2.spec.ts | 21 +--------- tests/flow-p2.test.ts | 41 ------------------- tests/flow.test.ts | 6 --- 12 files changed, 14 insertions(+), 381 deletions(-) delete mode 100644 src/components/StuckExits.tsx delete mode 100644 src/model/stuck.ts diff --git a/evals/run.ts b/evals/run.ts index 017ea06..4d56ab4 100644 --- a/evals/run.ts +++ b/evals/run.ts @@ -19,7 +19,6 @@ import { import { buildMapFromAI, createEmptyBoard, newNote } from "../src/model/boards"; import { contextForNote, leanForGeneration } from "../src/model/context"; import { answerProbe, applyExpand, applyRefine, chainPrompt, newProbe, nextStep, parsePoints, probeOutcome, refinePrompt, localGrade } from "../src/model/probe"; -import { analogyPrompt, parseAnalogy } from "../src/model/stuck"; import { parseWrap, wrapPrompt } from "../src/model/wrap"; import { firstLevelPrompt, diff --git a/src/App.tsx b/src/App.tsx index 617cef2..f084824 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -154,10 +154,8 @@ import { applyGate } from "./model/gate"; import { learnerProfile, setLearnerProfile } from "./model/profile-context"; import { profileText } from "./shared/profile"; import { answerProbe, applyExpand, applyFrontier, applyRefine, bounds as probeBounds, cacheChain, cachedChain, chainKey, chainPrompt, failRecheck, gradePrompt, localGrade, newProbe, nextStep as probeStep, parseGrade, parsePoints, probeLearner, refinePrompt, restartProbe, leaveConfirmation, skipProbe } from "./model/probe"; -import { afterChildSealed, analogyPrompt, isStuck, parseAnalogy, putOff, splitNote } from "./model/stuck"; import { parseWrap, wrapPrompt } from "./model/wrap"; import { ProbePanel } from "./components/ProbePanel"; -import { StuckExits } from "./components/StuckExits"; import { WrapPanel } from "./components/WrapPanel"; import { explainPoints, localMark, markPrompt, parseMark, testPoints, type Mark } from "./model/mark"; import { questionNode } from "./shared/canvas"; @@ -352,8 +350,6 @@ export default function App() { hintSignature?: string; /** enrich: the new cards it decorates. */ newIds?: string[]; - /** expand from a stuck stop (往下拆): the new cards are its split children. */ - split?: boolean; /** A picked ghost hint: select the card it becomes. */ selectNew?: boolean; /** 重做下层 from a chat proposal: the old cards below the anchor are archived when new ones land. */ @@ -887,10 +883,6 @@ export default function App() { const born = applied.added[0]; setTimeout(() => setSelection({ kind: "node", id: born }), 0); } - if (meta.split && applied.added.length) { - const born = new Set(applied.added); - next = { ...next, nodes: next.nodes.map((n) => (born.has(n.id) ? { ...n, meta: { ...n.meta, split: true } } : n)) }; - } if (mode === "map" && !target.route?.length && anchorId === questionNode(target)?.id) { const route = defaultRoute(next); // 摸底 done: stations below the frontier get the light A seal. @@ -1056,7 +1048,7 @@ export default function App() { [edit, openDrawer], ); const sendAI = useCallback( - async (request: AIRequest, anchor?: string, extra?: { selectNew?: boolean; split?: boolean; reExpand?: ReExpandJob }) => { + async (request: AIRequest, anchor?: string, extra?: { selectNew?: boolean; reExpand?: ReExpandJob }) => { requestMeta.current.set(request.requestId, { request, anchor, ...extra }); setPending((p) => ({ ...p, @@ -1179,7 +1171,7 @@ export default function App() { [settings.configured, edit], ); const expand = useCallback( - (id: string, angle?: DigAngle, promptOverride?: string, extra?: { selectNew?: boolean; split?: boolean; reExpand?: ReExpandJob }): boolean => { + (id: string, angle?: DigAngle, promptOverride?: string, extra?: { selectNew?: boolean; reExpand?: ReExpandJob }): boolean => { if (locked()) return false; if ( [...requestMeta.current.values()].some( @@ -1968,38 +1960,6 @@ export default function App() { })); if (mark?.pass) void assessStop(id); }; - const [stuckBusy, setStuckBusy] = useState<{ id: string; kind: "split" | "analogy" } | null>(null); - /** 往下拆: one gated expansion into exactly two smaller children under the stuck card. */ - const splitStuck = useCallback( - (id: string) => { - const b = boardRef.current; - const n = b?.nodes.find((x) => x.id === id); - if (!b || !n) return; - if (b.nodes.some((c) => c.parentId === id && c.meta?.split)) return; - expand(id, undefined, expandPrompt(b, id) + splitNote(n), { split: true }); - }, - [expand], - ); - const analogyStuck = useCallback( - async (id: string) => { - const n = boardRef.current?.nodes.find((x) => x.id === id); - if (!n) return; - setStuckBusy({ id, kind: "analogy" }); - const prompt = analogyPrompt(n); - try { - const reply = await askQuietRef.current("probe", prompt, `analogy-${id}`); - if (import.meta.env.DEV) console.info(`[stuck] analogy ~${roughTokens(prompt, reply)} tokens`); - const analogy = parseAnalogy(reply); - if (analogy) patchNode(id, { analogy, beat: "learn", fails: 0 }); - else showToast("这次没想出类比,换个办法试试?"); - } catch (e) { - showToast(errorText(e)); - } finally { - setStuckBusy(null); - } - }, - [patchNode, showToast], - ); /** 合起来讲: one short call says which stations the answer reached. */ const submitWrap = useCallback( async (text: string): Promise => { @@ -2135,7 +2095,6 @@ export default function App() { } if (!("card" in next)) return; const id = next.card.id; - if (next.card.meta?.later) patchNode(id, { meta: { ...next.card.meta, later: false } }); if (!next.card.beat && next.card.status !== "explained") patchNode(id, { beat: "guess" }); setSelection({ kind: "node", id }); addNote({ kind: "node", id }, { cards: false }); @@ -2195,7 +2154,6 @@ export default function App() { const stop = routeOf(b).find((n) => n.id === p.stopId); if (!stop || stop.status === "explained") return setProposal(noteId, messageId, { state: "expired" }); setProposal(noteId, messageId, { state: "applied" }); - if (stop.meta?.later) patchNode(stop.id, { meta: { ...stop.meta, later: false } }); if (!stop.beat) patchNode(stop.id, { beat: "guess" }); setSelection({ kind: "node", id: stop.id }); // A stop that only has a title gets its content through the usual expand flow. @@ -3497,7 +3455,7 @@ export default function App() { }, onPatch: (patch: Partial) => { if (patch.seal === "test") { - edit((b) => afterChildSealed({ ...b, nodes: b.nodes.map((x) => (x.id === card.id ? { ...x, ...patch } : x)) }, card.id)); + patchNode(card.id, patch); void assessStop(card.id); return; } @@ -3507,17 +3465,6 @@ export default function App() { edit((b) => failRecheck(b, card.id, nextId ?? undefined)); showToast("这一站排在下一步之后,再学一遍。"); }, - stuck: isStuck(card) ? ( - splitStuck(card.id)} - onAnalogy={() => void analogyStuck(card.id)} - onLater={() => { - edit((b) => putOff(b, card.id)); - closeDrawer(); - }} - /> - ) : undefined, onMark: (kind: "explain" | "test", answer: string, choice: number | null) => markCard(card.id, kind, answer, choice), }; diff --git a/src/components/StopBeats.tsx b/src/components/StopBeats.tsx index 6110325..a0f37a4 100644 --- a/src/components/StopBeats.tsx +++ b/src/components/StopBeats.tsx @@ -14,8 +14,6 @@ export interface StudyProps { onPatch: (patch: Partial) => void; /** Mark a 讲 or 测 answer; null when marking failed (AI unavailable). */ onMark: (kind: "explain" | "test", answer: string, choice: number | null) => Promise; - /** Two wrong 测 answers: the stuck exits (phase 2 fills these in). */ - stuck?: React.ReactNode; /** A light (摸底) seal failed its recheck: drop it and queue the stop after the current one. */ onRecheckFail?: () => void; } @@ -83,7 +81,7 @@ export function StopBeats({ card, study }: { card: ConceptNode; study: StudyProp return; } const fails = (card.fails ?? 0) + 1; - setFeedback({ ok: false, text: fails >= 2 ? "没关系,这说明这里藏着更小的概念。" : `还没对:${mark.missing || "再想想"}。再试一次?` }); + setFeedback({ ok: false, text: `还没对:${mark.missing || "再想想"}。再试一次?` }); study.onPatch({ fails }); }; if (card.weight && study.weightActions) return
; @@ -133,12 +131,6 @@ export function StopBeats({ card, study }: { card: ConceptNode; study: StudyProp {beat === "learn" && (
{card.guess &&

你猜的是:{card.guess}

} - {card.analogy && ( -

- 换个类比 - {card.analogy} -

- )}
- - -

拆出的小问题会以虚线草稿出现在卡片正下方,学完自动回到这一站重新测。

-
- ); -} diff --git a/src/model/next-step.ts b/src/model/next-step.ts index 6bb9fc9..210e972 100644 --- a/src/model/next-step.ts +++ b/src/model/next-step.ts @@ -1,10 +1,9 @@ /** * The one next step (spec §4.2), as a pure function. Rules, first hit wins: * 1 a stop with its four beats half done → continue that beat; - * 3 children split off a stuck stop are unlearned → learn them first; * 4 the first unsealed stop in route order; * 6 every stop sealed → 合起来讲 (then "wrapped"). - * Before all of them: an unfinished 摸底 continues. Stops put off with 稍后 (meta.later) go last. + * Before all of them: an unfinished 摸底 continues. * (Rule 2, spaced review, is out of scope; rule 5 is offered only when a stop missed a point.) */ import type { Beat, Board, ConceptNode } from "../shared/types"; @@ -14,7 +13,6 @@ const sealed = (card: ConceptNode) => mastered(card) || card.weight === "backgro export type NextStep = | { kind: "continue"; card: ConceptNode; station?: number; beat: Beat; why: string } - | { kind: "child"; card: ConceptNode; parent: ConceptNode; station?: number; why: string } | { kind: "station"; card: ConceptNode; station: number; why: string } | { kind: "deeper"; card: ConceptNode; station: number; why: string } | { kind: "wrap"; why: string } @@ -57,13 +55,10 @@ export function nextStep(board: Board, skip: ReadonlySet = new Set()): N } return undefined; }; - const unlearnedSplit = (n: ConceptNode) => - board.nodes.some((c) => c.parentId === n.id && !sealed(c)); - // 1. A stop in progress (not one waiting for its split children, nor one put off for later). + // 1. A stop in progress. const inProgress = board.nodes.find( (n) => - n.beat && n.beat !== "done" && !sealed(n) && !skip.has(n.id) && !n.meta?.later && - !((n.fails ?? 0) >= 2 && unlearnedSplit(n)), + n.beat && n.beat !== "done" && !sealed(n) && !skip.has(n.id), ); if (inProgress) return { @@ -71,28 +66,11 @@ export function nextStep(board: Board, skip: ReadonlySet = new Set()): N card: inProgress, station: stationFor(inProgress), beat: inProgress.beat!, - why: board.nodes.some((c) => c.parentId === inProgress.id && c.meta?.split) - ? "拆出来的小问题学完了,回到这一站重新测。" - : "这一站做到一半,接着把它走完。", + why: "这一站做到一半,接着把它走完。", }; - // 3. Children split off a stuck stop. - for (const parent of board.nodes.filter((n) => (n.fails ?? 0) >= 2 && !sealed(n))) { - const child = board.nodes - .filter((n) => n.parentId === parent.id && !sealed(n) && !skip.has(n.id)) - .sort((a, b) => a.order - b.order)[0]; - if (child) - return { - kind: "child", - card: child, - parent, - station: stationFor(parent), - why: `「${parent.title}」卡住了,先学拆出来的小问题,学完回去重新测。`, - }; - } // 4. The first unsealed stop. const open = route.filter((n) => !sealed(n)); const pick = - open.find((n) => !skip.has(n.id) && !n.meta?.later) ?? open.find((n) => !skip.has(n.id)) ?? open[0]; if (pick) { diff --git a/src/model/stuck.ts b/src/model/stuck.ts deleted file mode 100644 index 5eea28a..0000000 --- a/src/model/stuck.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** The stuck exits (§4.4 / 05-stuck): two wrong 测 answers offer 往下拆 / 换个类比 / 稍后. */ -import type { Board, ConceptNode } from "../shared/types"; -import { routeOf } from "./route"; - -export const STUCK_AFTER = 2; -export const isStuck = (n: ConceptNode) => (n.fails ?? 0) >= STUCK_AFTER && n.status !== "explained"; - -/** Appended to the expand prompt: exactly two smaller, more basic sub-questions under the stuck card. */ -export function splitNote(n: ConceptNode): string { - const missed = n.meta?.missed ? `(漏掉的要点:${n.meta.missed})` : ""; - return ` -学习者两次没答上这张卡的检查题${missed}。只拆出 2 个更小、更基础的子问题,都以 anchor 为 parent,帮他跨过这一步;每张仍要有父卡里没有的新知识点和自己的检查题。`; -} - -/** 换个类比: one short call; the analogy is shown in 学 and the learner goes round again. */ -export function analogyPrompt(n: ConceptNode): string { - const missed = n.meta?.missed ? `学习者卡在:${n.meta.missed}。` : ""; - return `卡片「${n.title}」:${(n.body ?? "").slice(0, 400)} -${missed}学习者两次没答上检查题。换一个日常生活里的类比,把这张卡的核心讲清楚:2–3 句自然的话,先说类比,再点回卡片里的机制;不要列表,不要重复原文。只输出 JSON:{"analogy":"…"}`; -} -export function parseAnalogy(reply: string): string | null { - try { - const j = JSON.parse(reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1)); - const a = typeof j.analogy === "string" ? j.analogy.trim() : ""; - return a ? a.slice(0, 600) : null; - } catch { - return null; - } -} - -/** 稍后: back to 探索中, put off to the end of the route; the next step moves on. */ -export function putOff(board: Board, id: string): Board { - const route = routeOf(board).map((n) => n.id); - const nextRoute = route.includes(id) ? [...route.filter((r) => r !== id), id] : route; - return { - ...board, - route: board.route ? nextRoute : board.route, - nodes: board.nodes.map((n) => - n.id === id ? { ...n, status: "exploring", fails: 0, beat: "test", meta: { ...n.meta, later: true } } : n, - ), - }; -} - -/** - * After a split child is sealed: when every split child of the parent is sealed, the parent goes - * back to 测 with a clean slate (fails 0), so the stuck exits do not show again. - */ -export function afterChildSealed(board: Board, childId: string): Board { - const child = board.nodes.find((n) => n.id === childId); - if (!child?.meta?.split || !child.parentId) return board; - const siblings = board.nodes.filter((n) => n.parentId === child.parentId && n.meta?.split); - if (!siblings.every((n) => n.status === "explained")) return board; - return { - ...board, - nodes: board.nodes.map((n) => (n.id === child.parentId ? { ...n, fails: 0, beat: "test" } : n)), - }; -} diff --git a/src/shared/types.ts b/src/shared/types.ts index 5a625db..24becb5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -66,14 +66,14 @@ export interface ConceptNode { check?: CardCheck; /** Where the learner is in this stop's four beats (先猜 → 学 → 讲 → 测). */ beat?: Beat; - /** Wrong 测 answers so far (two open the stuck exits). */ + /** Wrong 测 answers so far. */ fails?: number; /** The learner's own 先猜 and 讲 text. */ guess?: string; explain?: string; /** How the A seal was earned: by passing 测, or inferred from 摸底. probe-revoked: a 摸底 guess the learner later missed; never re-sealed by re-probing. probe-reviewed: a 已会 card re-checked after 摸底 (mastered, any weight). */ seal?: "test" | "probe" | "probe-revoked" | "probe-reviewed"; - /** 换个类比: a second explanation the learner asked for when stuck (shown in 学). */ + /** Legacy (removed stuck exits): kept so old boards still parse; unused. */ analogy?: string; /** Internal data, never rendered or exported. */ meta?: CardMeta; @@ -112,9 +112,9 @@ export interface CardMeta { missed?: string; /** The depth-ladder level this station teaches (摸底 maps the frontier onto it). */ level?: number; - /** Split from a stuck stop (往下拆): learning it sends the learner back to the parent's 测. */ + /** Legacy (removed stuck exits): kept so old boards still parse; unused. */ split?: boolean; - /** 稍后: the learner put this stuck stop off; it goes to the end of the queue. */ + /** Legacy (removed stuck exits): kept so old boards still parse; unused. */ later?: boolean; } /** The depth ladder: what → why → how → edge → apply; compare sits beside it. */ diff --git a/src/styles.css b/src/styles.css index da79294..2be7e3d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4577,7 +4577,7 @@ kbd, display: none; } -/* Flow phase 2: 摸底 ladder, stuck exits, 合起来讲 */ +/* Flow phase 2: 摸底 ladder, 合起来讲 */ .probe-head { display: flex; align-items: baseline; @@ -4647,59 +4647,6 @@ kbd, font: 400 14px/1.6 var(--kai); color: var(--ink); } -.stuck { - display: flex; - flex-direction: column; - gap: 6px; -} -.stuck-k { - margin: 0; - font: 700 13.5px var(--kai); - color: var(--ink); -} -.stuck-opt { - display: flex; - align-items: center; - gap: 8px; - padding: 9px 12px; - text-align: left; - font: 400 14px/1.4 var(--kai); - color: var(--ink); - background: var(--paper); - border: 1px solid var(--border); - border-radius: var(--r-md); - cursor: pointer; -} -.stuck-opt span { - flex: 1; -} -.stuck-opt em { - font: 700 11.5px var(--kai); - font-style: normal; - color: var(--accent); -} -.stuck-opt.rec { - border-color: var(--accent); -} -.stuck-opt:hover:not(:disabled) { - background: color-mix(in srgb, var(--accent) 6%, var(--card)); -} -.stuck-opt:disabled { - opacity: 0.6; - cursor: default; -} -.beat-analogy { - margin: 0; - padding: 8px 10px; - border-left: 3px solid var(--accent); - font: 400 14px/1.7 var(--kai); - color: var(--ink); - background: color-mix(in srgb, var(--accent) 5%, var(--card)); -} -.beat-analogy .lab { - display: block; - margin-bottom: 2px; -} .stop .beat-sealed .btn.quiet { margin-left: auto; } diff --git a/tests/e2e/flow-p2.spec.ts b/tests/e2e/flow-p2.spec.ts index 5c08669..465903c 100644 --- a/tests/e2e/flow-p2.spec.ts +++ b/tests/e2e/flow-p2.spec.ts @@ -5,12 +5,6 @@ import { gotoApp } from "./ready"; const card = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"]`); const gates = (page: Page) => page.evaluate(() => (window as any).__atlasGates as any[]); -/** The routed board without the fixture's own child under 第 2 站, so a stuck stop has none yet. */ -const bare = () => { - const b = routedBoard(); - b.nodes = b.nodes.filter((n: any) => n.parentId !== "c-when-not"); - return b; -}; const disk = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("fake-atlas-disk")!)[0]); @@ -139,72 +133,6 @@ test("a light-sealed stop can be rechecked; failing it removes the seal and queu .toEqual(["none", "c-when-not,c-what,c-how,c-money"]); }); -// spec-v2 (goal-weights): the first wrong answer now promotes the card one weight level and swaps in -// the choice footer (see flow.spec.ts), so the old two-wrong-answers 卡住了 exits are no longer reached. -test.skip("stuck after two wrong answers: three exits; 换个类比 goes back to 学, 往下拆 drafts two children", async ({ page }) => { - const b = bare(); - Object.assign(b.nodes.find((n: any) => n.id === "c-when-not"), { status: "exploring", beat: "test", fails: 1 }); - await installFakeAtlas(page, true, "web", [b]); - await page.addInitScript(() => { - (window as any).__atlasExpansionAnswers = [ - JSON.stringify({ - nodes: [ - { id: "s1", parent: "anchor", title: "观察期从哪天开始算", body: "从合同生效日起算,不是从交钱那天。续保的保单通常不再重新计算。" }, - { id: "s2", parent: "anchor", title: "健康告知没问到的不用说", body: "保险公司只就书面询问的事项要求如实回答,问卷外的情况不构成隐瞒。" }, - ], - relations: [], - }), - ]; - }); - await gotoApp(page); - const bar = page.getByRole("region", { name: "下一步" }); - await bar.getByRole("button", { name: /开始/ }).click(); - const stop = page.getByRole("region", { name: "这一站" }); - await stop.getByRole("radio", { name: /疾病定义/ }).click(); - await stop.getByRole("button", { name: /提交/ }).click(); - const exits = stop.getByRole("group", { name: "卡住了" }); - await expect(stop).toContainText("没关系,这说明这里藏着更小的概念。"); - await expect(exits.getByRole("button")).toHaveCount(3); - await expect(exits.getByRole("button").first()).toContainText("推荐"); - // 换个类比: one short call; back to 学 with the analogy shown. - await exits.getByRole("button", { name: /换个类比/ }).click(); - await expect(stop.locator(".beat.on")).toHaveText("学"); - await expect(stop.locator(".beat-analogy")).toContainText("健身卡"); - expect((await gates(page)).filter((r) => r.mode === "probe")).toHaveLength(1); - // Stuck again → 往下拆. - await stop.getByRole("button", { name: /读完了/ }).click(); - await stop.getByLabel("用我的话讲").fill("等待期里确诊一般不赔"); - await stop.getByRole("button", { name: /提交/ }).click(); - for (let i = 0; i < 2; i++) { - await stop.getByRole("radio", { name: /疾病定义/ }).click(); - await stop.getByRole("button", { name: /提交/ }).click(); - } - await exits.getByRole("button", { name: /往下拆/ }).click(); - await expect(page.locator(".react-flow__node", { hasText: "观察期从哪天开始算" })).toBeVisible(); - const asks = await page.evaluate(() => (window as any).__atlasAsks); - expect(asks.at(-1).mode).toBe("expand"); - expect(asks.at(-1).prompt).toContain("只拆出 2 个更小、更基础的子问题"); - await expect - .poll(async () => (await disk(page)).nodes.filter((n: any) => n.meta?.split).map((n: any) => n.parentId)) - .toEqual(["c-when-not", "c-when-not"]); - await page.locator(".drawer").getByLabel("关闭").click(); - await expect(bar).toContainText("卡住了,先学拆出来的小问题"); - await expect(bar).toContainText("观察期从哪天开始算"); -}); - -test("稍后 puts the stuck stop off to the end of the route", async ({ page }) => { - const b = bare(); - Object.assign(b.nodes.find((n: any) => n.id === "c-when-not"), { status: "exploring", beat: "test", fails: 2 }); - await installFakeAtlas(page, true, "web", [b]); - await gotoApp(page); - const bar = page.getByRole("region", { name: "下一步" }); - await bar.getByRole("button", { name: /开始/ }).click(); - await page.getByRole("group", { name: "卡住了" }).getByRole("button", { name: /稍后/ }).click(); - await expect(bar).toContainText("下一步 · 第 2 站"); - await expect(bar).not.toContainText("等待期"); - await expect(card(page, "c-when-not").locator(".station")).toHaveText("第 4 站"); -}); - test("合起来讲: with every stop sealed, the root asks for the wrap-up; the stations it reaches light up", async ({ page }) => { const b = routedBoard(); for (const n of b.nodes) if (n.parentId && n.parentId !== null) Object.assign(n, { status: "explained", seal: "test", beat: "done" }); diff --git a/tests/e2e/shots-flow-p2.spec.ts b/tests/e2e/shots-flow-p2.spec.ts index c166006..b16fbd8 100644 --- a/tests/e2e/shots-flow-p2.spec.ts +++ b/tests/e2e/shots-flow-p2.spec.ts @@ -5,7 +5,7 @@ import { routedBoard } from "./fixtures"; import { gotoApp } from "./ready"; /** - * Flow redesign screenshots, phase 2 (摸底, stuck, 合起来讲). Not a regression test. + * Flow redesign screenshots, phase 2 (摸底, 合起来讲). Not a regression test. * Run with SHOTS_DIR=/path npx playwright test shots-flow-p2 */ const dir = process.env.SHOTS_DIR; @@ -172,25 +172,6 @@ for (const mode of ["light", "dark"] as const) { await page.screenshot({ path: `${dir}/probe-expert-result-${mode}.png` }); }); - test(`卡住 · ${mode}`, async ({ page }) => { - await setup(page, mode); - const b = routedBoard(); - b.nodes = b.nodes.filter((n: any) => n.parentId !== "c-when-not"); - Object.assign(b.nodes.find((n: any) => n.id === "c-when-not"), { status: "exploring", beat: "test", fails: 1, guess: "怕带病投保", explain: "等待期内确诊一般不赔" }); - await installFakeAtlas(page, true, "web", [b]); - await gotoApp(page); - await theme(page, mode); - await page.getByRole("region", { name: "下一步" }).getByRole("button", { name: /开始/ }).click(); - const stop = page.getByRole("region", { name: "这一站" }); - await stop.getByRole("radio", { name: /疾病定义/ }).click(); - await stop.getByLabel("再用一句话说说为什么").fill("因为已经交过保费了"); - await stop.getByRole("button", { name: /提交/ }).click(); - await expect(stop.getByRole("group", { name: "卡住了" })).toBeVisible(); - await page.setViewportSize({ width: 1440, height: 1240 }); - await page.waitForTimeout(400); - await page.screenshot({ path: `${dir}/05-stuck-${mode}.png` }); - }); - test(`合起来讲 · ${mode}`, async ({ page }) => { await setup(page, mode); const b = routedBoard(); diff --git a/tests/flow-p2.test.ts b/tests/flow-p2.test.ts index 28b5f24..a76f02e 100644 --- a/tests/flow-p2.test.ts +++ b/tests/flow-p2.test.ts @@ -21,7 +21,6 @@ import { skipProbe, MAX_QUESTIONS, } from "../src/model/probe"; -import { afterChildSealed, isStuck, parseAnalogy, putOff } from "../src/model/stuck"; import { parseWrap, sentences } from "../src/model/wrap"; import { nextStep } from "../src/model/next-step"; import { boardSchema } from "../src/shared/validation"; @@ -274,46 +273,6 @@ describe("frontier on the route", () => { }); }); -describe("stuck exits", () => { - const stuck = () => - board( - [ - node("q", "起点", null, 0, { question: true }), - node("a", "等待期", "q", 0, { status: "exploring", beat: "test", fails: 2 }), - node("b", "赔多少", "q", 1), - ], - { route: ["a", "b"] }, - ); - it("two wrong answers make a stop stuck", () => { - expect(isStuck(stuck().nodes[1])).toBe(true); - expect(isStuck({ ...stuck().nodes[1], fails: 1 })).toBe(false); - }); - it("往下拆: split children come first, then the parent is tested again with a clean slate", () => { - let b = stuck(); - b.nodes.push(node("a1", "观察期", "a", 0, { meta: { split: true } }), node("a2", "带病投保", "a", 1, { meta: { split: true } })); - expect(nextStep(b)).toMatchObject({ kind: "child", card: { id: "a1" }, station: 1 }); - b = { ...b, nodes: b.nodes.map((n) => (n.id === "a1" ? { ...n, status: "explained" as const, seal: "test" as const } : n)) }; - expect(afterChildSealed(b, "a1").nodes.find((n) => n.id === "a")!.fails).toBe(2); - b = { ...b, nodes: b.nodes.map((n) => (n.id === "a2" ? { ...n, status: "explained" as const, seal: "test" as const } : n)) }; - b = afterChildSealed(b, "a2"); - expect(b.nodes.find((n) => n.id === "a")).toMatchObject({ fails: 0, beat: "test" }); - const s = nextStep(b); - expect(s).toMatchObject({ kind: "continue", card: { id: "a" }, beat: "test" }); - expect("why" in s && s.why).toContain("回到这一站重新测"); - }); - it("稍后: back to 探索中, moved to the end; the next step moves on", () => { - const b = putOff(stuck(), "a"); - expect(b.route).toEqual(["b", "a"]); - expect(b.nodes.find((n) => n.id === "a")).toMatchObject({ status: "exploring", fails: 0, meta: { later: true } }); - expect(nextStep(b)).toMatchObject({ kind: "station", card: { id: "b" } }); - expect(boardSchema.parse(b)).toBeTruthy(); - }); - it("换个类比 parses one short JSON reply", () => { - expect(parseAnalogy('{"analogy":"像新办的健身卡有冷静期。"}')).toBe("像新办的健身卡有冷静期。"); - expect(parseAnalogy("nope")).toBeNull(); - }); -}); - describe("合起来讲", () => { const sealed = () => board( diff --git a/tests/flow.test.ts b/tests/flow.test.ts index d14dc5a..3e88e60 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -256,12 +256,6 @@ describe("next step", () => { expect(nextStep(b)).toMatchObject({ kind: "continue", beat: "explain" }); expect(nextStep(b, new Set(["c"]))).toMatchObject({ kind: "station", station: 2 }); }); - it("rule 3: children of a stuck stop come first", () => { - const b = routed(); - b.nodes[2] = { ...b.nodes[2], fails: 2 }; - b.nodes.push(node("b1", "小问题", "b", 0)); - expect(nextStep(b)).toMatchObject({ kind: "child", parent: { id: "b" }, station: 2 }); - }); it("rule 6: all sealed → 合起来讲", () => { const b = routed(); b.nodes = b.nodes.map((n) => (n.parentId ? { ...n, status: "explained" as const } : n));