diff --git a/packages/webui/src/client/components/InteractionPanel.tsx b/packages/webui/src/client/components/InteractionPanel.tsx index dc120158..00fead4f 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 00000000..d03acd11 --- /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 3e8725a4..6479f906 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 72573aa8..f02612a7 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 5b62dd89..19fb291f 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 22c66620..3c09129b 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 00000000..c5c0437d --- /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›"); +});