From 90d63e1cdff9f9f6880da43eda351570b3368058 Mon Sep 17 00:00:00 2001 From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:19:54 +0800 Subject: [PATCH] fix(webui): render the questionnaire's goal window honestly and survive a missing presentation block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, both reproduced first against the built client before any change: 1. A multi-step questionnaire whose presentation block is absent threw 'Cannot read properties of undefined (reading showProgress)' and replaced the whole interaction surface with the error boundary (a white page before Q-1). The wire type declares presentation required, but the runtime's view builder materialises it with a spread — {} for a payload that never had one — and every producer-side normaliser (DEFAULT_PRESENTATION in local-runtime, the TUI's event-normalizer) defaults the three fields to true. The progress row now reads them through '?? true', the same convention the replaceComposer read two siblings up already followed. 2. The expiry countdown was shown for ANY questionnaire carrying expiresAt, titled 'will auto-submit in N seconds', and parked at a bare zero when the window closed. All three claims were dishonest: the runtime's QuestionnaireAutoReplyScheduler replies only for purpose === 'goal' (applying each step's recommended option, with a CAS so a concurrent manual answer wins), and the renderer is display-only by that module's own contract. The countdown is now gated to the goal window, titled 'automatically apply the recommended option', marks that option (推荐) while the window is open — the TUI's '(Recommended)' affordance — and says the window is over at zero instead of parking at 0s. No client-side auto-submit is added on purpose: the scheduler owns it, and a second submit from the renderer would race the CAS that exists to let a manual answer win. webui-round3-acceptance's countdown fixture gains purpose: 1 so it still exercises the window it always meant to (its request carried an expiry with no purpose, which is exactly the dishonest shape removed). Tests: questionnaire-goal-autoreply.test.tsx (7 cases — goal gating, honest title, recommended mark, zero state, missing-presentation render, explicit showProgress:false honoured) and questionnaire.spec.mjs (5 browser cases — required-gated stepping forward/back, multi-select, custom answer, submit payload on the wire, close/skip routing, goal countdown contract, missing-presentation render). The browser fixture gains setQuestionnaire so a request can be staged for the composer's poll. --- .../client/components/InteractionPanel.tsx | 65 +++++- .../questionnaire-goal-autoreply.test.tsx | 160 +++++++++++++ .../unit/webui-round3-acceptance.test.tsx | 5 + release/public-source.json | 4 +- test/vitest-suites.json | 1 + test/webui-browser/fixture.mjs | 12 + test/webui-browser/questionnaire.spec.mjs | 219 ++++++++++++++++++ 7 files changed, 456 insertions(+), 10 deletions(-) create mode 100644 packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx create mode 100644 test/webui-browser/questionnaire.spec.mjs diff --git a/packages/webui/src/client/components/InteractionPanel.tsx b/packages/webui/src/client/components/InteractionPanel.tsx index dc1201585..00fead4ff 100644 --- a/packages/webui/src/client/components/InteractionPanel.tsx +++ b/packages/webui/src/client/components/InteractionPanel.tsx @@ -55,10 +55,20 @@ export function WebuiInteractionPanel({ ); const [submitting, setSubmitting] = useState(false); const [currentStep, setCurrentStep] = useState(0); + // The expiry countdown is the GOAL flow's auto-reply window, not a property + // of questionnaires in general: the runtime's + // `QuestionnaireAutoReplyScheduler` fires only for `purpose === 'goal'` + // (`QuestionnairePurpose.Goal === 1` on this wire), applying each step's + // recommended option. An ordinary questionnaire that happens to carry + // `expiresAt` gets no runtime reply, so showing it a countdown would be a + // promise nothing keeps — the TUI gates the same way + // (`goalAutoReplyDeadline` in `questionnaire-picker.ts`). + const goalAutoReplyWindow = + questionnaire?.purpose === 1 && questionnaire.expiresAt !== undefined; const [remainingSeconds, setRemainingSeconds] = useState(() => - questionnaire?.expiresAt === undefined - ? undefined - : Math.max(0, Math.ceil((questionnaire.expiresAt - Date.now()) / 1000)), + goalAutoReplyWindow + ? Math.max(0, Math.ceil((questionnaire!.expiresAt! - Date.now()) / 1000)) + : undefined, ); useEffect(() => { setSelections({}); @@ -67,15 +77,18 @@ export function WebuiInteractionPanel({ setCurrentStep(0); }, [questionnaire?.id]); useEffect(() => { - if (!questionnaire?.expiresAt) { + if (!goalAutoReplyWindow) { setRemainingSeconds(undefined); return undefined; } - const update = () => setRemainingSeconds(Math.max(0, Math.ceil((questionnaire.expiresAt! - Date.now()) / 1000))); + const update = () => setRemainingSeconds(Math.max(0, Math.ceil((questionnaire!.expiresAt! - Date.now()) / 1000))); update(); const timer = window.setInterval(update, 1_000); return () => window.clearInterval(timer); - }, [questionnaire?.expiresAt]); + // `id` rides along so replacing one goal questionnaire with another at the + // same deadline re-derives the window instead of leaving the old interval + // comparing against a deadline that no longer exists. + }, [questionnaire?.id, questionnaire?.purpose, questionnaire?.expiresAt]); const visiblePermissions = permissions.filter( (permission) => permission.sessionId === sessionId, ); @@ -202,9 +215,19 @@ export function WebuiInteractionPanel({ > × - {questionnaire.steps.length > 1 && questionnaire.presentation.showProgress ? ( + {/* `presentation` is optional at the reader: the wire type marks it + * required, but the runtime's view builder materialises it with a + * spread (`{...request.presentation}`), which yields `{}` — not a + * defaulted block — for a payload that never had one. Every + * producer-side normaliser defaults these three fields to true + * (`DEFAULT_PRESENTATION` in local-runtime, the TUI's + * event-normalizer), so `?? true` here is the same convention the + * `replaceComposer` read two siblings up already follows. The bare + * reads used to throw for a multi-step request with no presentation + * block — the render died before the boundary existed. */} + {questionnaire.steps.length > 1 && (questionnaire.presentation?.showProgress ?? true) ? ( - + {currentStep + 1}/{questionnaire.steps.length} @@ -216,7 +239,21 @@ export function WebuiInteractionPanel({ > 智能体需要你的回答

- {remainingSeconds !== undefined ? ⏱ {remainingSeconds}s : null} + {remainingSeconds !== undefined ? ( + 0 ? `将在 ${remainingSeconds} 秒后自动采用推荐选项` : "已到期,正在采用推荐选项"} + > + {remainingSeconds > 0 + ? `⏱ ${remainingSeconds}s` + // The runtime's scheduler owns the actual reply (and a manual + // answer beats it via its CAS arbitration), so zero is a + // "hold on" state, not an error — the TUI shows the same + // "Time is up · applying the recommended option…" beat. + : "⏱ 时间到 · 正在采用推荐选项…"} + + ) : null}
{questionnaire.steps.slice(currentStep, currentStep + 1).map((step) => { const selected = optionIdsForStep(selections, step.id); @@ -276,6 +313,16 @@ export function WebuiInteractionPanel({ {option.label} + {/* The TUI appends "(Recommended)" while the goal + auto-reply countdown runs, because that is the + option the runtime will apply if the reader does + nothing — the countdown alone does not say which + one it is. */} + {option.recommended && remainingSeconds !== undefined ? ( + + (推荐) + + ) : null} {option.description ? ( {option.description} diff --git a/packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx b/packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx new file mode 100644 index 000000000..d03acd112 --- /dev/null +++ b/packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx @@ -0,0 +1,160 @@ +// Unit tests for the questionnaire panel's goal auto-reply window and its +// optional-presentation hardening. +// +// Two contracts, both verified broken-then-fixed in a real browser against +// the built client (see the I-1 PR for the click-by-click record): +// +// 1. The expiry countdown belongs to the GOAL flow alone. The runtime's +// `QuestionnaireAutoReplyScheduler` (local-runtime) fires only for +// `purpose === 'goal'`, applying each step's recommended option with a +// CAS so a concurrent manual answer wins; the renderer is display-only +// by that module's own contract. Before the fix the WebUI showed the +// countdown for ANY request carrying `expiresAt` — a promise nothing +// keeps for an ordinary questionnaire — and its title said 自动提交, +// which is not what the runtime does (it applies the recommended +// option), and marked nothing on the options the TUI labels +// "(Recommended)". +// +// 2. `presentation` is declared required by the wire type, but the +// runtime's view builder materialises it with `{...request. +// presentation}` — `{}` for a payload that never had one — and every +// producer-side normaliser defaults the three fields to true. A +// multi-step request whose presentation block is absent entirely used +// to throw `Cannot read properties of undefined (reading +// 'showProgress')` and kill the whole interaction surface (the error +// boundary catches it now; before Q-1 it was a white page). +// +// `renderToStaticMarkup` runs the `useState` initialiser, so the countdown's +// first paint is assertable without a DOM; the interval itself only matters +// over time, which is the browser spec's half of the coverage. + +import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { WebuiInteractionPanel } from "../../src/client/components/InteractionPanel.js"; +import type { WebuiQuestionnaireRequest } from "../../src/server/port.js"; + +function request(over: Partial = {}): WebuiQuestionnaireRequest { + return { + schemaVersion: 2, + id: "q-goal", + title: "目标确认", + presentation: { replaceComposer: true, showProgress: true, allowBackNavigation: true }, + steps: [ + { + id: "s1", + question: "继续吗?", + selectionMode: 0, + allowOther: false, + otherPlaceholder: "", + required: true, + options: [ + { id: "rec", label: "推荐继续", recommended: true }, + { id: "alt", label: "换个方案" }, + ], + }, + ], + ...over, + } as WebuiQuestionnaireRequest; +} + +function render(questionnaire: WebuiQuestionnaireRequest): string { + return renderToStaticMarkup( + createElement(WebuiInteractionPanel, { + sessionId: "s1", + permissions: [], + questionnaire, + onPermission: vi.fn(async () => undefined), + onQuestionnaire: vi.fn(async () => undefined), + onDismiss: vi.fn(async () => undefined), + }), + ); +} + +describe("the goal auto-reply window", () => { + it("shows the countdown only for a goal questionnaire", () => { + const future = Date.now() + 30_000; + const goal = render(request({ purpose: 1, expiresAt: future })); + expect(goal).toContain('data-testid="questionnaire-auto-reply-countdown"'); + + // The other half: an ordinary questionnaire carrying the same expiry + // renders NO countdown — the runtime's auto-reply scheduler is goal-only, + // so a visible countdown here would promise a reply nothing keeps. + const ordinary = render(request({ expiresAt: future })); + expect(ordinary).not.toContain("questionnaire-auto-reply-countdown"); + }); + + it("titles the countdown with what the runtime actually does", () => { + const html = render(request({ purpose: 1, expiresAt: Date.now() + 10_000 })); + // 自动采用推荐选项, not 自动提交: the runtime applies each step's + // recommended option (`GoalQuestionnaireService.runAutoReply`), it does + // not submit the reader's half-filled form. + expect(html).toContain("自动采用推荐选项"); + expect(html).not.toContain("自动提交"); + }); + + it("marks the recommended option only while the window is open", () => { + const goal = render(request({ purpose: 1, expiresAt: Date.now() + 10_000 })); + expect(goal).toContain('data-testid="questionnaire-recommended-rec"'); + expect(goal).toContain("(推荐)"); + // A different option on the same step carries no mark, and the same + // option carries none once no window is open — the mark answers "what + // happens if I do nothing", which only a live window makes true. + expect(goal).not.toContain('data-testid="questionnaire-recommended-alt"'); + const unmarked = render(request({ expiresAt: Date.now() + 10_000 })); + expect(unmarked).not.toContain("questionnaire-recommended-"); + }); + + it("says the window is over rather than parking at a bare zero", () => { + const html = render(request({ purpose: 1, expiresAt: Date.now() - 1 })); + // The scheduler owns the actual reply, so zero is a "hold on" state — + // the same beat the TUI renders ("Time is up · applying the recommended + // option…"), not an error and not a stuck ⏱ 0s with no explanation. + expect(html).toContain("时间到 · 正在采用推荐选项…"); + expect(html).not.toMatch(/⏱\s*0s/u); + }); +}); + +describe("a presentation block that never arrived", () => { + const twoSteps: WebuiQuestionnaireRequest = { + ...request(), + steps: [ + { id: "m1", question: "一", selectionMode: 0, allowOther: false, otherPlaceholder: "", required: false }, + { id: "m2", question: "二", selectionMode: 0, allowOther: false, otherPlaceholder: "", required: false }, + ], + }; + + it("renders a multi-step request with no presentation block at all", () => { + // `presentation` entirely absent is the throw case: the progress row's + // guard reads `presentation.showProgress` after `steps.length > 1` + // short-circuits for single-step requests, so only the multi-step shape + // reaches the read. This render used to throw. + const { presentation, ...without } = twoSteps; + expect(presentation).toBeDefined(); + const html = render(without as WebuiQuestionnaireRequest); + expect(html).toContain('data-testid="questionnaire-composer"'); + }); + + it("defaults the progress row and back navigation to the producers' default", () => { + // `{...request.presentation}` in the runtime yields `{}` when the block + // was absent — and DEFAULT_PRESENTATION (local-runtime) plus the TUI's + // event-normalizer both default these fields to true, so the reader-side + // `?? true` matches every producer's notion of "unspecified". + const { presentation, ...without } = twoSteps; + const html = render(without as WebuiQuestionnaireRequest); + expect(html).toContain('data-testid="questionnaire-progress"'); + // The prev button is disabled on the FIRST step either way; the contract + // under test is that the reader did not throw and the progress row is + // shown, which the assertion above pins. + expect(html).toContain("1/2"); + }); + + it("honours an explicit showProgress: false when the block does arrive", () => { + const html = render({ + ...twoSteps, + presentation: { replaceComposer: true, showProgress: false, allowBackNavigation: true }, + }); + expect(html).not.toContain('data-testid="questionnaire-progress"'); + }); +}); diff --git a/packages/webui/test/unit/webui-round3-acceptance.test.tsx b/packages/webui/test/unit/webui-round3-acceptance.test.tsx index 3e8725a4e..6479f9060 100644 --- a/packages/webui/test/unit/webui-round3-acceptance.test.tsx +++ b/packages/webui/test/unit/webui-round3-acceptance.test.tsx @@ -357,6 +357,11 @@ describe("round-3 goal and questionnaire behavior", () => { id: "q-1", title: "Choose", presentation: { replaceComposer: false, showProgress: true, allowBackNavigation: true }, + // `purpose: 1` (QuestionnairePurpose.Goal): the countdown renders only + // inside the goal auto-reply window — the runtime's scheduler replies + // only for goal questionnaires, so any other expiry would be a promise + // nothing keeps. + purpose: 1, expiresAt: Date.now() + 10_000, steps: [ { id: "one", question: "One", selectionMode: 0, required: true, allowOther: false, otherPlaceholder: "" , options: [{ id: "a", label: "A" }] }, diff --git a/release/public-source.json b/release/public-source.json index 72573aa82..f02612a7c 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3692,6 +3692,7 @@ "packages/webui/test/unit/outside-close.test.ts", "packages/webui/test/unit/plugin-market-catalogue.test.tsx", "packages/webui/test/unit/projections.test.ts", + "packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx", "packages/webui/test/unit/queue-panel.test.ts", "packages/webui/test/unit/rail-buckets.test.ts", "packages/webui/test/unit/rail-context-menu.test.ts", @@ -3732,8 +3733,8 @@ "packages/webui/test/unit/webui-plan-mode.test.ts", "packages/webui/test/unit/webui-round3-acceptance.test.tsx", "packages/webui/test/unit/webui-runtime-environment.test.ts", + "packages/webui/test/unit/webui-scheduled-task-upstream-probe.test.ts", "packages/webui/test/unit/webui-scheduled-task.test.ts", - "packages/webui/test/unit/webui-scheduled-task-upstream-probe.test.ts", "packages/webui/test/unit/webui-service.test.ts", "packages/webui/test/unit/webui-shell.test.ts", "packages/webui/test/unit/webui-startup-fallback.test.tsx", @@ -3823,6 +3824,7 @@ "test/webui-browser/connection-status.spec.mjs", "test/webui-browser/fixture.mjs", "test/webui-browser/harness.mjs", + "test/webui-browser/questionnaire.spec.mjs", "test/webui-browser/rail-context-menu.spec.mjs", "test/webui-browser/rail-pin.spec.mjs", "test/webui-browser/rail-search.spec.mjs", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 5b62dd893..19fb291f4 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -287,6 +287,7 @@ "packages/webui/test/unit/outside-close.test.ts", "packages/webui/test/unit/plugin-market-catalogue.test.tsx", "packages/webui/test/unit/webui-plan-mode.test.ts", + "packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx", "packages/webui/test/unit/transcript-scroll.test.ts", "packages/webui/test/unit/markdown-codeblock-c1-highlight.test.tsx", "packages/webui/test/unit/markdown-codeblock-c2-line-numbers.test.tsx", diff --git a/test/webui-browser/fixture.mjs b/test/webui-browser/fixture.mjs index 22c666205..3c09129b7 100644 --- a/test/webui-browser/fixture.mjs +++ b/test/webui-browser/fixture.mjs @@ -58,6 +58,12 @@ export function installFixtureTransport() { const pending = []; const delayed = []; const held = []; + // The questionnaire the pending-questionnaire poll will answer. Mutable for + // the same reason `setPage` is: the composer polls `getPendingQuestionnaire` + // on mount and on every session switch, so a test that wants to stage a + // questionnaire has no other way to say what the server answers on the NEXT + // poll. `undefined` keeps the old "no pending questionnaire" answer. + let questionnaire; let activeTurn; const requests = []; const sockets = new Set(); @@ -76,6 +82,7 @@ export function installFixtureTransport() { if (operation === "listSkills") return { skills: [] }; if (operation === "listPendingPermissions") return { requests: [] }; if (operation === "getActiveTurn") return activeTurn; + if (operation === "getPendingQuestionnaire") return questionnaire ? { request: questionnaire } : {}; if (operation === "dismissQuestionnaire") return { ok: true }; if (operation === "replyQuestionnaire") return { ok: true }; if (operation === "listQueueMessages") return { items: [] }; @@ -200,6 +207,11 @@ export function installFixtureTransport() { // The server's authoritative view of what is running right now. A // client whose `session.start` was missed reads this to recover. setActiveTurn(turn) { activeTurn = turn ? clone(turn) : undefined; }, + // The pending questionnaire the next poll answers. The goal auto-reply + // scheduler lives in the real runtime, not here: the fixture's transport + // is inert on purpose, so a staged questionnaire stays pending until the + // test answers, dismisses, or replaces it. + setQuestionnaire(request) { questionnaire = request ? clone(request) : undefined; }, resolve(operation, condition, result) { const index = pending.findIndex((entry) => entry.operation === operation && matches(entry.body, condition)); if (index < 0) throw new Error(`No pending fixture request: ${operation} ${JSON.stringify(condition)}`); diff --git a/test/webui-browser/questionnaire.spec.mjs b/test/webui-browser/questionnaire.spec.mjs new file mode 100644 index 000000000..c5c0437de --- /dev/null +++ b/test/webui-browser/questionnaire.spec.mjs @@ -0,0 +1,219 @@ +// The multi-step questionnaire, end to end against the built client. +// +// The unit suite covers the pure helpers and the panel's static markup; what +// only a real browser proves is the interaction itself — the click-by-click +// contract the roadmap's ➖ stood for: +// +// 1. stepping forward and back with the progress row, and the required +// gate: 下一步 and 提交 stay disabled until a required step is answered; +// 2. multi-select toggling and the 可多选 hint; +// 3. the custom answer row; +// 4. 提交 shipping the accumulated answers over the wire; +// 5. 关闭 and 跳过 both routing through dismissQuestionnaire; +// 6. the goal auto-reply countdown: shown only for goal questionnaires, +// titled with what the runtime actually does (采用推荐选项, not 提交), +// marking the recommended option, and saying the window is over at zero +// instead of parking at a bare ⏱ 0s; +// 7. a multi-step request with no presentation block renders instead of +// throwing the whole surface away. +// +// Countdown boundary, stated once here because it shapes test 6: the runtime +// owns the auto-reply (QuestionnaireAutoReplyScheduler fires for +// purpose === 'goal' and applies each step's recommended option; a manual +// answer beats it through its CAS). The fixture transport has no runtime, so +// "the scheduler really submits" is not assertable in this suite — what is +// assertable is that the client renders the window honestly and never +// promises one for a questionnaire the runtime would not answer. + +import { expect } from "@playwright/test"; + +import { openApp, test } from "./harness.mjs"; + +test.beforeEach(async ({ page }) => { + page.on("pageerror", (error) => console.error("BROWSER_PAGE_ERROR", error.stack ?? error.message)); + page.on("console", (message) => { if (message.type() === "error") console.error("BROWSER_CONSOLE_ERROR", message.text()); }); +}); + +function step(id, question, over = {}) { + return { id, question, selectionMode: 0, allowOther: false, otherPlaceholder: "", required: false, ...over }; +} + +function request(steps, over = {}) { + return { + schemaVersion: 2, + id: "q-spec", + title: "浏览器实测问卷", + presentation: { replaceComposer: true, showProgress: true, allowBackNavigation: true }, + steps, + ...over, + }; +} + +/** + * Stages the questionnaire before the app boots; the composer's first poll + * picks it up. Registered on the harness's `__WEBUI_FIXTURE_SETUP__` queue + * (which runs after the fixture transport installs) through `addInitScript` + * with the payload as an argument — `configureFixture` serialises the setup + * with `toString()`, so a closed-over request would arrive as a ReferenceError. + */ +async function stageQuestionnaire(page, questionnaire) { + await page.addInitScript((value) => { + window.__WEBUI_FIXTURE_SETUP__ ??= []; + window.__WEBUI_FIXTURE_SETUP__.push(() => window.__fixture.setQuestionnaire(value)); + }, questionnaire); +} + +const card = (page) => page.locator('[data-testid="questionnaire-composer"]'); + +test("steps forward and back, gating every required step", async ({ page }) => { + await stageQuestionnaire(page, request([ + step("s1", "第一问(必填单选)", { required: true, options: [{ id: "o1a", label: "选项A", recommended: true }, { id: "o1b", label: "选项B" }] }), + step("s2", "第二问(多选)", { selectionMode: 1, options: [{ id: "o2a", label: "甲" }, { id: "o2b", label: "乙" }, { id: "o2c", label: "丙" }] }), + step("s3", "第三问(自定义)", { allowOther: true, otherPlaceholder: "说说你的想法" }), + ])); + await openApp(page, "#session=A"); + + await expect(card(page)).toBeVisible(); + await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/3›"); + + // Required, unanswered: both ways forward are locked. This is the gate the + // roadmap's 必填校验 row claimed — asserted on the real buttons. + const next = page.locator('[data-testid="questionnaire-next"]'); + const submit = page.locator('button:has-text("提交")'); + await expect(next).toBeDisabled(); + await expect(submit).toBeDisabled(); + + await page.locator('[data-webui-questionnaire-option="o1a"]').click(); + await expect(next).toBeEnabled(); + await next.click(); + await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹2/3›"); + + // Multi-select: the hint is the only signal the row offers that Space-like + // toggling, not replacement, is the semantics. + await expect(page.locator('[data-testid="questionnaire-multi-hint-s2"]')).toBeVisible(); + await page.locator('[data-webui-questionnaire-option="o2a"]').click(); + await page.locator('[data-webui-questionnaire-option="o2c"]').click(); + await expect(page.locator('[data-webui-questionnaire-option="o2a"]')).toHaveAttribute("data-selected", "true"); + await expect(page.locator('[data-webui-questionnaire-option="o2c"]')).toHaveAttribute("data-selected", "true"); + + // Back: the first step's answer survives the round trip. + await page.locator('[data-testid="questionnaire-progress-prev"]').click(); + await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/3›"); + await expect(page.locator('[data-webui-questionnaire-option="o1a"]')).toHaveAttribute("data-selected", "true"); + await page.locator('[data-testid="questionnaire-next"]').click(); + + // Custom answer on the final step, then the gate opens for submit. + await page.locator('[data-testid="questionnaire-next"]').click(); + const other = page.locator('[data-webui-questionnaire-other="s3"] input[type="text"]'); + await other.click(); + await other.fill("我的自定义答案"); + await expect(page.locator('[data-webui-questionnaire-other="s3"]')).toHaveAttribute("data-selected", "true"); + await expect(submit).toBeEnabled(); +}); + +test("提交 ships the accumulated answers over the wire", async ({ page }) => { + await stageQuestionnaire(page, request([ + step("s1", "必填单选", { required: true, options: [{ id: "a", label: "A" }] }), + step("s2", "多选", { selectionMode: 1, options: [{ id: "b", label: "B" }, { id: "c", label: "C" }] }), + ])); + await openApp(page, "#session=A"); + + await page.locator('[data-webui-questionnaire-option="a"]').click(); + await page.locator('[data-testid="questionnaire-next"]').click(); + await page.locator('[data-webui-questionnaire-option="b"]').click(); + await page.locator('button:has-text("提交")').click(); + + // The payload is the point: one answer per step, in step order, with the + // multi-select carrying both ids and the untouched step carrying none. + await expect.poll(() => + page.evaluate(() => window.__fixture.requests.filter((r) => r.operation === "replyQuestionnaire").at(-1)?.body), + ).toEqual({ + name: "synthetic-A", + requestId: "q-spec", + schemaVersion: 2, + answers: [ + { stepId: "s1", selectedOptionIds: ["a"] }, + { stepId: "s2", selectedOptionIds: ["b"] }, + ], + }); +}); + +test("关闭 and 跳过 both route through dismissQuestionnaire", async ({ page }) => { + await stageQuestionnaire(page, request([ + step("s1", "先测关闭", { options: [{ id: "x", label: "选项" }] }), + ])); + await openApp(page, "#session=A"); + await expect(card(page)).toBeVisible(); + + await page.locator('[data-testid="questionnaire-close"]').click(); + await expect.poll(() => + page.evaluate(() => window.__fixture.requests.filter((r) => r.operation === "dismissQuestionnaire").length), + ).toBe(1); + + // 跳过 on a fresh card — same wire destination, different affordance. The + // two must not be conflated into one button, and neither may submit + // answers. (Direct evaluate, not `stageQuestionnaire`: init scripts do not + // re-run on a hash change.) + await page.evaluate((value) => window.__fixture.setQuestionnaire(value), request([ + step("s1", "再测跳过", { required: true, options: [{ id: "y", label: "必填项" }] }), + ])); + await page.evaluate(() => { window.location.hash = "session=B"; }); + await expect(card(page)).toBeVisible(); + await page.locator('button:has-text("跳过")').click(); + await expect.poll(() => + page.evaluate(() => window.__fixture.requests.filter((r) => r.operation === "dismissQuestionnaire").length), + ).toBe(2); + expect(await page.evaluate(() => window.__fixture.requests.some((r) => r.operation === "replyQuestionnaire"))).toBe(false); +}); + +test("the goal countdown renders its window honestly, and only for goal requests", async ({ page }) => { + // The ordinary half first: same expiry, no purpose — no countdown, because + // the runtime's scheduler would never answer it. + await stageQuestionnaire(page, request([ + step("s1", "普通问卷", { options: [{ id: "n", label: "普通选项" }] }), + ], { expiresAt: Date.now() + 4_000 })); + await openApp(page, "#session=A"); + await expect(card(page)).toBeVisible(); + await expect(page.locator('[data-testid="questionnaire-auto-reply-countdown"]')).toHaveCount(0); + await expect(page.locator('[data-testid="questionnaire-recommended-n"]')).toHaveCount(0); + + // Now the goal half, with a recommended option to apply. (Direct evaluate — + // `stageQuestionnaire`'s init script does not re-run on a hash change.) + await page.evaluate((value) => window.__fixture.setQuestionnaire(value), request([ + step("sg", "目标确认", { options: [{ id: "rec", label: "推荐继续", recommended: true }, { id: "alt", label: "换个方案" }] }), + ], { purpose: 1, expiresAt: Date.now() + 4_000 })); + await page.evaluate(() => { window.location.hash = "session=B"; }); + await expect(card(page)).toBeVisible(); + + const countdown = page.locator('[data-testid="questionnaire-auto-reply-countdown"]'); + await expect(countdown).toBeVisible(); + // Says what the runtime does — 采用推荐选项, not a bare 自动提交 — and marks + // the option it would apply (only that one). + await expect(countdown).toContainText(/⏱ [1-4]s/u); + expect(await countdown.getAttribute("title")).toContain("自动采用推荐选项"); + await expect(page.locator('[data-testid="questionnaire-recommended-rec"]')).toHaveText("(推荐)"); + await expect(page.locator('[data-testid="questionnaire-recommended-alt"]')).toHaveCount(0); + + // At zero: the window says it is over. The fixture has no runtime, so no + // reply fires here — the real submission is the scheduler's, source-verified + // in GoalQuestionnaireService.runAutoReply (see the spec header). + await expect(countdown).toHaveText("⏱ 时间到 · 正在采用推荐选项…", { timeout: 8_000 }); +}); + +test("a multi-step request with no presentation block renders instead of throwing", async ({ page }) => { + const withPresentation = request([ + step("m1", "无展示配置第一问"), + step("m2", "无展示配置第二问"), + ]); + const { presentation, ...without } = withPresentation; + await stageQuestionnaire(page, without); + await openApp(page, "#session=A"); + + // The throw shape: `steps.length > 1` un-shortcircuits the progress guard, + // which used to read `.showProgress` off the absent block and kill the + // whole surface. The defaults come from the producers' own + // DEFAULT_PRESENTATION: progress shown, back navigation allowed. + await expect(page.locator('[data-testid="webui-error-boundary"]')).toHaveCount(0); + await expect(card(page)).toBeVisible(); + await expect(page.locator('[data-testid="questionnaire-progress"]')).toHaveText("‹1/2›"); +});