diff --git a/src/App.tsx b/src/App.tsx index f931d0f..617cef2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { assessmentPrompt, gapMessage, goalText, parseAssessment, structuredGoal, upgradeCard } from "./model/goal"; +import { assessmentPrompt, cardMastered, gapMessage, goalText, knownIds, parseAssessment, structuredGoal, upgradeCard } from "./model/goal"; import { AgentEdits } from "./components/AgentEditProse"; import { planAgentEdits, editDuration, existingBodyChanges, type AgentEdit } from "./model/agent-edit"; import { @@ -1448,9 +1448,10 @@ export default function App() { let next: Board = { ...boardRef.current!, probe: state }; if (next.route?.length) next = applyFrontier(next); edit(() => next); - if (!next.route?.length && !next.plan) requestPlan(next); + // New question: 摸底 first, then the goal questions (kickoff); the plan follows the kickoff's ready:true. + if (!next.route?.length && !next.plan) startKickoff(); }, - [edit, requestPlan], + [edit, startKickoff], ); /** Run the machine until it needs an answer (ask) or is done; EXPAND / REFINE are generation calls. */ const advanceProbe = useCallback( @@ -1771,7 +1772,8 @@ export default function App() { } // A new question starts the guided kickoff (or leads to AI settings) instead of a silent map. if (kickoff) { - startKickoff(); + if (settings.configured) startProbe(); + else startKickoff(); } } async function importBoardFile(file: File) { @@ -1930,7 +1932,14 @@ export default function App() { }, [addNote, edit, showToast]); readCardRef.current = (id) => { const card = boardRef.current?.nodes.find(n => n.id === id); - if (!card || card.status === "explained") return; + if (!card || cardMastered(card)) return; + // 摸底 already showed this is known, so a reviewed 懂了 counts as mastery (Yaphet's call). + if (knownIds(boardRef.current!.nodes).has(id)) { + patchNode(id, { status: "explained", beat: "done", seal: "probe-reviewed", fails: 0 }); + void assessStop(id); + return; + } + if (card.status === "explained") return; // Reading is recorded separately from demonstrated mastery. patchNode(id, { status: "exploring", beat: "done" }); void assessStop(id); @@ -1944,15 +1953,17 @@ export default function App() { if (boardRef.current?.id !== initial.id || !noteId) return; const current = boardRef.current.nodes.find(n => n.id === id); if (!current || current.body !== card.body || current.weight !== card.weight) return; + // A miss on a 摸底「已会」card only revokes that card; probe-revoked keeps re-probing from re-sealing it. + const wasKnown = !!mark && !mark.pass && knownIds(boardRef.current.nodes).has(id); edit(b => ({ ...b, nodes: b.nodes.map(n => n.id === id ? { ...n, - ...(mark?.pass ? { status: "explained" as const, beat: "done" as const, seal: "test" as const, fails: 0 } : mark ? { ...upgradeCard(n), fails: Math.min(99, (n.fails ?? 0) + 1) } : {}), + ...(mark?.pass ? { status: "explained" as const, beat: "done" as const, seal: "test" as const, fails: 0 } : mark ? { ...(wasKnown ? { seal: "probe-revoked" as const, status: "exploring" as const } : {}), ...upgradeCard(n), fails: Math.min(99, (n.fails ?? 0) + 1) } : {}), explain: answer, } : n), notes: b.notes.map(n => n.id === noteId ? { ...n, messages: cacheMessages([...n.messages, { id: uid(), role: "user", text: answer, createdAt: now(), meta: { chat: true } }, - { id: uid(), role: "assistant", text: mark ? mark.pass ? "讲清楚了。" : `再想想:${mark.missing || "结合卡片里的依据说说为什么。"}` : "这次没判出来,稍后再提交一次。", createdAt: now(), meta: { chat: true } }, + { id: uid(), role: "assistant", text: mark ? mark.pass ? "讲清楚了。" : `${wasKnown ? "这张我之前估计你会,看来还值得再看一下,已经放回路线里。\n\n" : ""}再想想:${mark.missing || "结合卡片里的依据说说为什么。"}` : "这次没判出来,稍后再提交一次。", createdAt: now(), meta: { chat: true } }, ]) } : n), })); if (mark?.pass) void assessStop(id); @@ -2130,7 +2141,9 @@ export default function App() { addNote({ kind: "node", id }, { cards: false }); }; startFirstRef.current = () => { - const first = boardRef.current && routeOf(boardRef.current)[0]; + // Stations the 摸底 already sealed are skipped: start at the first one above the learner's edge. + const route = boardRef.current ? routeOf(boardRef.current) : []; + const first = route.find((c) => !c.seal) ?? route[0]; if (first) { patchNode(first.id, { status: "exploring", beat: first.weight === "background" ? "learn" : first.weight === "core" ? "test" : "explain" }); addNote({ kind: "node", id: first.id }, { cards: false }); @@ -2590,7 +2603,7 @@ export default function App() { }); api.ask(request).catch(() => requestMeta.current.delete(request.requestId)); }; - const kickoffReady = needsKickoff(board) && !board?.probe && !mapBusy && !study; + const kickoffReady = needsKickoff(board) && board?.probe?.status !== "asking" && !mapBusy && !study; const drawerNote = board?.notes.find((n) => n.id === drawer?.id); // Before 应用: the open thread's pending route edit marks the stops it would replace or remove. const routePreview = useMemo(() => { @@ -2677,8 +2690,20 @@ export default function App() { () => sectionNumbers(board?.nodes ?? []), [board?.nodes], ); + // 摸底「已会」only shows on weighted AI routes. + const knownSet = useMemo( + () => (board?.kind === "ai" ? knownIds(board.nodes) : new Set()), + [board?.kind, board?.nodes], + ); const nodes = useMemo(() => { if (!shown || !board) return []; + const knownStation = (id: string): number | undefined => { + for (let n = board.nodes.find((x) => x.id === id), hops = 0; n && hops < 64; n = board.nodes.find((x) => x.id === n!.parentId), hops++) { + const s = stationOf(board, n.id); + if (s !== undefined) return s; + } + return undefined; + }; const generatingCards = new Set(); for (const { request, anchor } of requestMeta.current.values()) { if ( @@ -2764,6 +2789,7 @@ export default function App() { : {}), onPreviewAdd: hoverAdd, station: stationOf(board, n.id), + ...(knownSet.has(n.id) ? { known: knownStation(n.id) } : {}), isNext: nextId === n.id, lit: !!board.wrap?.lit.includes(n.id), ...(routePreview.get(n.id) ? { preview: routePreview.get(n.id) } : {}), @@ -2805,6 +2831,7 @@ export default function App() { return out; }, [ routePreview, + knownSet, shown, board, layout, @@ -3209,7 +3236,7 @@ export default function App() { return (
{paused > 0 && setShowCanvasActivity(true)} />} - + (undefined); + useEffect(() => { + const was = prev.current; + prev.current = known; + if (known !== undefined) { + setLeaving(undefined); + return; + } + if (was === undefined || seal !== "probe-revoked") return; + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + setLeaving(was); + const t = setTimeout(() => setLeaving(undefined), KNOWN_EXIT_MS); + return () => clearTimeout(t); + }, [known, seal]); + return leaving; +} + export function ConceptCard({ data, selected }: NodeProps) { const n = data.concept; // Weighted AI cards (weightActions) only count core/hard answers as mastery; manual boards keep // the learner's own 「我能解释」 mark as the seal. const mastered = data.weightActions ? cardMastered(n) : n.status === "explained"; const edits = useContext(AgentEdits); + const leaving = useKnownExit(data.known, n.seal); + const markNo = data.known ?? leaving; // Stamp only when the status changes while mounted, not on first paint. const first = useRef(n.status); const animate = first.current !== n.status; @@ -506,7 +533,7 @@ export function ConceptCard({ data, selected }: NodeProps) { return (
{data.generating && } @@ -514,10 +541,15 @@ export function ConceptCard({ data, selected }: NodeProps) { {mastered && (!data.weightActions || data.station === undefined) && } {data.preview === "replace" && 待替换} - {data.station !== undefined && data.preview !== "remove" && ( - - {n.status !== "unseen" && } - 第 {data.station} 站 + {(data.station !== undefined || markNo !== undefined) && data.preview !== "remove" && ( + + {markNo !== undefined ? ( + + + 已会 + + ) : n.status !== "unseen" && !(data.weightActions && n.seal === "probe") && } + {markNo !== undefined ? "· " : ""}第 {data.station ?? markNo} 站 )} {data.isNext && 下一步} @@ -528,7 +560,7 @@ export function ConceptCard({ data, selected }: NodeProps) { {/* Compact face: the first sentence or two of the prose; the drawer shows all of it. */} {(n.body || edits.get(n.id)?.after === n.body) && } - {data.weightActions && } + {data.weightActions && } {!data.weightActions && data.isNext && data.onStartNext && ( + 可跳过 +
+ ) : cardMastered(card) || + (!known && weight === "background" && (card.beat === "done" || card.status === "explained")) ? ( {cardMastered(card) ? "已掌握" : "读过"} diff --git a/src/model/goal.ts b/src/model/goal.ts index ea5e4f3..2a6f4fa 100644 --- a/src/model/goal.ts +++ b/src/model/goal.ts @@ -25,8 +25,7 @@ export const structuredGoal = (board: Board) => typeof board.plan?.goal === "object" ? board.plan.goal : undefined; /** Background reading never demonstrates mastery, including legacy explained cards. */ -export const cardMastered = (card: Pick) => - (card.weight === "core" || card.weight === "hard") && card.status === "explained"; +export { cardMastered, knownIds } from "./progress"; /** A single promotion per learner action. Questions are prepared by generation, never invented facts. */ export function upgradeCard(card: ConceptNode): Partial { diff --git a/src/model/probe.ts b/src/model/probe.ts index d386a15..476fe63 100644 --- a/src/model/probe.ts +++ b/src/model/probe.ts @@ -388,7 +388,7 @@ export function applyFrontier(board: Board): Board { const f = probe.frontier ?? 0; const levels = stationLevels(board); const nodes = board.nodes.map((n): ConceptNode => { - if (n.seal === "test") return n; + if (n.seal === "test" || n.seal === "probe-revoked" || n.seal === "probe-reviewed") return n; const level = levels.get(n.id); const below = level !== undefined && level < f; if (below) return { ...n, seal: "probe", status: "explained", beat: "done" }; diff --git a/src/model/progress.ts b/src/model/progress.ts index 0bcdc36..c887c11 100644 --- a/src/model/progress.ts +++ b/src/model/progress.ts @@ -1,4 +1,4 @@ -import type { Board, BoardProgress } from "../shared/types.js"; +import type { Board, BoardProgress, ConceptNode } from "../shared/types.js"; /** * The sidebar's per-canvas progress, matching the 理解进度 pill: every card below the root is one @@ -9,7 +9,32 @@ export function boardProgress(board: Pick): BoardProgre const concepts = board.nodes.filter((n) => n.parentId !== null); const sealed = concepts.filter((n) => n.status === "explained").length; const total = concepts.length; - return { sealed, total, done: Boolean(board.wrap?.done) || (total > 0 && sealed === total) }; + const known = knownIds(board.nodes); + // 已会 cards are skippable: they never block done, but are not counted as sealed by the learner. + const settled = concepts.filter((n) => n.status === "explained" || known.has(n.id)).length; + return { sealed, total, ...(known.size ? { known: known.size } : {}), done: Boolean(board.wrap?.done) || (total > 0 && settled === total) }; +} + +/** Demonstrated mastery on a weighted card: core/hard explained (not merely inferred by 摸底), or any card re-checked after 摸底. */ +export const cardMastered = (card: Pick) => + card.status === "explained" && + (card.seal === "probe-reviewed" || ((card.weight === "core" || card.weight === "hard") && card.seal !== "probe")); + +/** + * 摸底「已会」on a weighted route: a probe-sealed station and every card under it, until the learner + * misses that card (seal "probe-revoked") or re-checks it into mastery. + */ +export function knownIds(nodes: ConceptNode[]): Set { + const byId = new Map(nodes.map((n) => [n.id, n])); + const out = new Set(); + for (const n of nodes) { + if (n.parentId === null || !n.weight || n.seal === "probe-revoked" || cardMastered(n)) continue; + for (let a: ConceptNode | undefined = n, hops = 0; a && hops < 64; a = a.parentId ? byId.get(a.parentId) : undefined, hops++) { + if (a.seal === "probe") { out.add(n.id); break; } + if (a !== n && a.seal === "probe-revoked") break; + } + } + return out; } /** Save-time metadata. listSessions currently includes every note, including saved annotations. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index f1d0f44..5a625db 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -71,8 +71,8 @@ export interface ConceptNode { /** The learner's own 先猜 and 讲 text. */ guess?: string; explain?: string; - /** How the A seal was earned: by passing 测, or inferred from 摸底. */ - seal?: "test" | "probe"; + /** 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 学). */ analogy?: string; /** Internal data, never rendered or exported. */ @@ -382,6 +382,8 @@ export interface BoardSummary { export interface BoardProgress { sealed: number; total: number; + /** 摸底「已会」cards on a weighted route, counted apart from sealed. */ + known?: number; done: boolean; } export type Selection = { diff --git a/src/shared/validation.ts b/src/shared/validation.ts index 8d5aeda..6469405 100644 --- a/src/shared/validation.ts +++ b/src/shared/validation.ts @@ -197,7 +197,7 @@ export const conceptNodeSchema = z fails: z.number().int().min(0).max(99).optional(), guess: z.string().max(2_000).optional(), explain: z.string().max(4_000).optional(), - seal: z.enum(["test", "probe"]).optional(), + seal: z.enum(["test", "probe", "probe-revoked", "probe-reviewed"]).optional(), analogy: z.string().max(2_000).optional(), meta: z .object({ @@ -520,7 +520,7 @@ export const boardSchema = z archivedAt: timestampSchema.optional(), sessionCount: z.number().int().min(0).optional(), progress: z - .object({ sealed: z.number().int().min(0), total: z.number().int().min(0), done: z.boolean() }) + .object({ sealed: z.number().int().min(0), total: z.number().int().min(0), known: z.number().int().min(0).optional(), done: z.boolean() }) .strict() .optional(), canvasVersion: idSchema.optional(), diff --git a/src/styles.css b/src/styles.css index 2b5d98f..da79294 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5577,3 +5577,26 @@ h2.workspace-section span { .card:has(.weight-footer) .no { display: inline; } .card:has(.weight-footer) p { color: var(--ink-2); } .card .weight-question { display: block; font-size: 13px; color: var(--ink); } + +/* 摸底「已会」on weighted routes (seal spec A): outlined stamp, one step quieter card, shared 复习一下 footer. */ +.seal.seal-known { width: 13px; height: 13px; position: static; transform: none; filter: none; z-index: auto; flex: 0 0 13px; color: var(--accent); opacity: 0.55; animation: none; } +.card .station.known { gap: 4px; } +.card .station .known-mark { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--ink-3); } +.card.known:has(.weight-footer) { background: color-mix(in srgb, var(--card) 70%, var(--paper)); box-shadow: none; } +.card.known:has(.weight-footer) h3 { color: var(--ink-2); } +.card.known .face-body { color: var(--ink-3); } +/* Revoked 已会: fade the mark and ease the quiet card back over 240ms (reduced motion: instant). */ +.card.known-out { transition: background-color 240ms var(--ease-out), box-shadow 240ms var(--ease-out); } +.card.known-out h3, .card.known-out .face-body { transition: color 240ms var(--ease-out); } +.card .station .known-mark.leaving { animation: known-out 240ms var(--ease-out) forwards; } +@keyframes known-out { to { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { + .card.known-out, .card.known-out h3, .card.known-out .face-body { transition: none; } + .card .station .known-mark.leaving { animation: none; opacity: 0; } +} +.known-footer { display: flex; align-items: center; justify-content: space-between; } +.known-footer .lab { color: var(--ink-3); font-size: 12px; } +.progress .bar i.s-known { border: 1.25px dashed var(--ink-3); background: repeating-linear-gradient(135deg, var(--rule) 0 2px, transparent 2px 5px); } +.progress .bar.long i.s-known { border: 0; background: repeating-linear-gradient(135deg, var(--rule) 0 2px, transparent 2px 5px); } +.known-footer .btn.quiet { background: transparent; border-color: transparent; box-shadow: none; color: var(--ink-2); } +.known-footer .btn.quiet:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); } diff --git a/tests/e2e/flow-p2.spec.ts b/tests/e2e/flow-p2.spec.ts index 49911a4..5c08669 100644 --- a/tests/e2e/flow-p2.spec.ts +++ b/tests/e2e/flow-p2.spec.ts @@ -22,12 +22,18 @@ async function newQuestion(page: Page, topic: string) { await page.getByRole("button", { name: "开始探索", exact: true }).click(); } -// spec-v2 (goal-weights): a new question now opens the chat kickoff (产出物/成功标准/约束 3–5 题), -// not the 摸底 probe, so this entry path is no longer reachable from 新问题. Kept for reference until the -// probe code is either rewired or removed. -test.skip("摸底 v2: brackets the edge without levels or marks; stations below the start get the light seal", async ({ page }) => { +// New question: 摸底 first, then the goal questions (spec-v2 kickoff), then the plan/route. +test("摸底 v2: brackets the edge without levels or marks, then goal questions, then the route; stations below the start get the light seal", async ({ page }) => { await installFakeAtlas(page); - await newQuestion(page, "重疾险有哪些重要条款?"); + await gotoApp(page); + await page.evaluate(() => { (window as any).__atlasChatAnswers = [ + '摸底完成。做完以后,你想拿到什么?\n```json\n{"chips":["一份投保核对清单"]}\n```', + '记下了,路线会帮你做出这份清单。\n```json\n{"goal":{"deliverable":"一份投保核对清单","criteria":[{"id":"check","label":"能逐条核对合同条款"}],"constraints":[]},"ready":true}\n```', + ]; }); + await page.keyboard.press("ControlOrMeta+k"); + await page.getByRole("button", { name: /新的问题/ }).click(); + await page.getByLabel("新白板问题").fill("重疾险有哪些重要条款?"); + await page.getByRole("button", { name: "开始探索", exact: true }).click(); const probe = page.locator(".drawer").getByRole("region", { name: "摸底" }); // No levels, scores or right/wrong marks: just 第 n 题 + dots and 正在找你的起点. await expect(probe).toContainText("第 1 题"); @@ -56,22 +62,32 @@ test.skip("摸底 v2: brackets the edge without levels or marks; stations below await expect(probe).toContainText("合同结构"); await expect(probe).toContainText("从「条款细读」开始,正好在你会的上面一层。"); await expect(probe.locator(".probe-known li")).toHaveCount(3); + // Then the goal questions (not the plan yet): no plan request before the goal is ready. + const drawerEl = page.locator(".drawer"); + await expect(drawerEl).toContainText("你想拿到什么?"); + expect(await page.evaluate(() => (window as any).__atlasPlans?.length ?? 0)).toBe(0); + await drawerEl.getByRole("button", { name: "一份投保核对清单", exact: true }).click(); // MCQ without a reason is graded locally: no mark calls. expect((await gates(page)).filter((r) => r.mode === "mark")).toHaveLength(0); - // The plan is written for this learner, and the map is asked for ladder levels. - const plan = page.locator(".drawer .plan-editor"); - await expect(plan).toContainText("学完后"); + // After the goal is ready, the plan is written for this learner and goal; the route starts from the drawer. + const start = page.locator(".drawer").getByRole("button", { name: "开始第 1 站", exact: true }); + await expect(start).toBeEnabled(); // Plain prose: no question-type chip (机制/决策/…) anywhere in the probe or plan UI. await expect(page.locator(".drawer .plan-kind")).toHaveCount(0); await expect(page.locator(".drawer")).not.toContainText(/概念:是什么|机制:如何运作|决策:怎么选|操作:怎么做|学习路径:怎么学|对比:有什么区别/); const plans = await page.evaluate(() => (window as any).__atlasPlans); + expect(plans[0].prompt).toContain("一份投保核对清单"); expect(plans[0].prompt).toContain("摸底结果:学习者已经懂:「生病了保险给钱」、「给付型 vs 报销型」、「合同结构」。路线从「条款细读」开始"); - await plan.getByRole("button", { name: "确认计划,铺成第一层" }).click(); + await start.click(); await expect(page.locator(".react-flow__node-concept")).toHaveCount(3); - const first = page.locator(".react-flow__node-concept").first(); - await expect(first.locator(".seal.light")).toHaveCount(1); - // Without card levels, the 3 stations spread over the 5-point chain (0, 2, 4): two fall before 条款细读. - await expect(page.locator(".react-flow__node-concept .seal.light")).toHaveCount(2); + // Without card levels, the 3 stations spread over the 5-point chain (0, 2, 4): two fall before 条款细读 + // and get the probe seal. (Weighted AI stations show 读过/掌握 dots instead of the light stamp.) + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("fake-atlas-disk")!).find((b: any) => b.probe)?.nodes.filter((n: any) => n.seal === "probe").length)) + .toBe(2); + // …and those two stations show the outlined 已会 stamp in place of the 读过/掌握 dot. + await expect(page.locator(".card .station .known-mark")).toHaveCount(2); + await expect(page.locator(".progress .bar i.s-known")).toHaveCount(2); await page.locator(".drawer").getByLabel("关闭").click(); const bar = page.getByRole("region", { name: "下一步" }); await expect(bar).toContainText("下一步 · 第 3 站"); @@ -91,6 +107,7 @@ test.skip("摸底 v2: brackets the edge without levels or marks; stations below await expect(drawer.getByRole("region", { name: "摸底" })).toContainText("共 3 站"); await drawer.getByRole("button", { name: "我想从头学" }).click(); await expect(page.locator(".seal.light")).toHaveCount(0); + await expect(page.locator(".card .known-mark")).toHaveCount(0); await drawer.getByLabel("关闭").click(); await expect(bar).toContainText("下一步 · 第 1 站"); }); diff --git a/tests/e2e/goal-weights.spec.ts b/tests/e2e/goal-weights.spec.ts index 11572f7..0b7a6fe 100644 --- a/tests/e2e/goal-weights.spec.ts +++ b/tests/e2e/goal-weights.spec.ts @@ -356,3 +356,54 @@ test("wrong core choice promotes exactly once; background questions add a choice ), ).toHaveCount(2); }); + +// Seal spec A: 摸底「已会」shows as an outlined stamp, a quieter card and one 复习一下 footer. +for (const mode of ["light", "dark"] as const) { + test(`${mode}: 摸底已会 stamp beside 读过/掌握, and a reviewed background card counts as mastered`, async ({ page }) => { + const b = goalBoard(); + const base = b.nodes[1]; + const known = { ...base, id: "goal-known", title: "先认清交付对象", order: -1, seal: "probe" as const, status: "explained" as const, beat: "done" as const, weight: "core" as const }; + const kid = (id: string, title: string, weight: "background" | "core") => ({ ...base, id, title, parentId: "goal-known", order: 0, status: "unseen" as const, beat: undefined, seal: undefined, weight }); + b.nodes.push(known, kid("goal-known-bg", "背景:什么是交付", "background"), kid("goal-known-core", "核心:验收清单", "core")); + b.route = ["goal-known", ...b.route!]; + await theme(page, mode); + await installFakeAtlas(page, true, "web", [b]); + await gotoApp(page); + // Station 1 and both cards under it inherit 已会; station 2 keeps its 读过 dot, nothing else. + await expect(page.locator(".card .known-mark")).toHaveCount(3); + await expect(card(page, "goal-known").locator(".station")).toContainText("已会· 第 1 站"); + await expect(card(page, "goal-known").getByRole("img", { name: "你已经会了(摸底推断),可跳过或复习" })).toBeVisible(); + await expect(card(page, "goal-known")).toContainText("可跳过"); + await expect(card(page, "goal-known").locator(".weight-options")).toHaveCount(0); + await expect(card(page, "goal-bg").locator(".station-dot.read")).toHaveCount(1); + await expect(page.locator(".progress")).toContainText("已会 3"); + await screenshot(page, `seal-route-${mode}`); + // 复习一下 restores the card's own footer; a background 懂了 after 摸底 counts as mastery. + await card(page, "goal-known-bg").getByRole("button", { name: "复习一下" }).click(); + await card(page, "goal-known-bg").getByRole("button", { name: "懂了", exact: true }).click(); + await expect(card(page, "goal-known-bg")).toContainText("已掌握"); + await expect(card(page, "goal-known-bg").locator(".known-mark")).toHaveCount(0); + await expect.poll(async () => (await disk(page)).nodes.find((n: any) => n.id === "goal-known-bg").seal).toBe("probe-reviewed"); + await expect(page.locator(".progress")).toContainText("已会 2"); + await screenshot(page, `seal-reviewed-${mode}`); + }); +} + +// Seal spec A: a miss on a 已会 card fades the mark out over 240ms; reduced motion drops it at once. +for (const motion of ["no-preference", "reduce"] as const) { + test(`revoked 已会 mark fades out (${motion})`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: motion }); + const b = goalBoard(); + const core = b.nodes.find((n: any) => n.id === "goal-core")!; + Object.assign(core, { seal: "probe", status: "unseen", beat: undefined }); + await installFakeAtlas(page, true, "web", [b]); + await gotoApp(page); + await expect(card(page, "goal-core").locator(".known-mark")).toHaveCount(1); + await card(page, "goal-core").getByRole("button", { name: "复习一下" }).click(); + await card(page, "goal-core").getByRole("button", { name: "所有图片使用相同说明", exact: true }).click(); + await expect.poll(async () => (await disk(page)).nodes.find((n: any) => n.id === "goal-core").seal).toBe("probe-revoked"); + if (motion === "reduce") await expect(card(page, "goal-core").locator(".known-mark.leaving")).toHaveCount(0); + await expect(card(page, "goal-core").locator(".known-mark")).toHaveCount(0); + await expect(card(page, "goal-core")).not.toHaveClass(/known-out/); + }); +} diff --git a/tests/e2e/kickoff.spec.ts b/tests/e2e/kickoff.spec.ts index c0e9284..5451c21 100644 --- a/tests/e2e/kickoff.spec.ts +++ b/tests/e2e/kickoff.spec.ts @@ -22,6 +22,8 @@ test("a new question starts ordinary goal questions; a chip sends its answer", a await page.getByLabel("新白板问题").fill("彩虹是怎么形成的?"); await page.getByRole("button", { name: "开始探索", exact: true }).click(); const drawer = page.locator(".drawer"); + // 摸底 comes first; skipping it goes on to the goal questions. + await drawer.getByRole("button", { name: "跳过摸底" }).click(); await expect(drawer).toContainText("你想拿到什么?"); await drawer.getByRole("button", { name: "一份说明图", exact: true }).click(); await expect(drawer).toContainText("怎样算讲清楚了?"); @@ -30,13 +32,13 @@ test("a new question starts ordinary goal questions; a chip sends its answer", a expect(asks.map((a: any) => a.mode)).toEqual(["chat", "chat"]); expect(asks[0].prompt).toContain("3–5 题"); expect(asks[1].prompt).toContain("产出物"); - await expect(drawer.getByRole("region", { name: "摸底" })).toHaveCount(0); }); test("closing and reopening goal questions preserves the conversation without another call", async ({ page }) => { await installFakeAtlas(page); await newQuestion(page, "彩虹是怎么形成的?"); const drawer = page.locator(".drawer"); + await drawer.getByRole("button", { name: "跳过摸底" }).click(); await expect(drawer.locator(".msg.assistant.chat")).toHaveCount(1); const text = await drawer.locator(".msg.assistant.chat").textContent(); await drawer.getByLabel("关闭").click(); diff --git a/tests/goal.test.ts b/tests/goal.test.ts index b193806..b15b4ba 100644 --- a/tests/goal.test.ts +++ b/tests/goal.test.ts @@ -9,6 +9,7 @@ import { import { createEmptyBoard, buildMapFromAI } from "../src/model/boards"; import { cardMastered, + knownIds, gapMessage, upgradeCard, parseAssessment, @@ -297,3 +298,22 @@ describe("chat and card rendering", () => { expect(hard).toContain(cards[2].questionPrompt); }); }); + +describe("摸底「已会」on weighted routes", () => { + const base = (over: Partial[]) => + over.map((o, i) => ({ id: `n${i}`, parentId: i === 0 ? null : "n0", title: `t${i}`, body: "", status: "unseen", weight: "background", ...o }) as import("../src/shared/types").ConceptNode); + it("inherits from a probe-sealed station, never counts as mastery, and stops at revoked or reviewed cards", () => { + const nodes = base([ + { weight: undefined }, + { id: "s1", seal: "probe", status: "explained", beat: "done", weight: "core" }, + { id: "c1", parentId: "s1" }, + { id: "c2", parentId: "s1", seal: "probe-revoked", status: "exploring" }, + { id: "c3", parentId: "s1", seal: "probe-reviewed", status: "explained" }, + { id: "s2", status: "explained", seal: "test", weight: "core" }, + ]); + expect([...knownIds(nodes)].sort()).toEqual(["c1", "s1"]); + expect(cardMastered(nodes[1])).toBe(false); + expect(cardMastered(nodes[4])).toBe(true); // background 懂了 after 摸底 counts as mastery + expect(cardMastered(nodes[5])).toBe(true); + }); +});