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›");
+});