diff --git a/.context/app/planning/development-plan.md b/.context/app/planning/development-plan.md index bc142d404..16b583a1e 100644 --- a/.context/app/planning/development-plan.md +++ b/.context/app/planning/development-plan.md @@ -21,14 +21,14 @@ supersedes: Conversational Questionnaire Phases.md ## Project -| Field | Value | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Name | Conversational Questionnaire | -| Repo | `human-centric-engineering/conquest` (forked from `human-centric-engineering/sunrise` at v0.0.1) | -| Host platform | Sunrise v0.0.1 | -| Lead | Simon Holmes | -| Status | `building` — P0 done (F0.1, PR #10); P1 done (F1.1 ingestion: PR #13/#14/#15); P2.5 done (F2.5.1, #21); P2 done (F2.1 #18/#19, F2.2 #23, F2.3 #24, F2.4 #25); P3 done (F3.1 #26, F3.2 #27, F3.3 #28, F3.4 #29); P4 done (F4.1 #30, F4.2 #32, F4.3 #33, F4.4 #34, F4.5 #35, F4.6 #36); P5 done (F5.1 #37, F5.2 #38, F5.3 #39); P6 done (F6.1 #40, F6.2 #41, F6.3 #42, F6.4 #43); P7 done (F7.1 #44, F7.2 #45, F7.3 #46, F7.4 #47) + admin upload-questionnaire UI (#48) + deferred-gaps close (#49); P8 done (F8.1 #53, F8.2 #54, F8.3 #55). **P9 in progress** — F9.1 done (#56), F9.4 done (demo-content seed, on `feat/F9.4-demo-seed-and-F9.2-runbook`), F9.2 in flight (operational runbook — awaiting clean-machine road-test); F9.3 done (forking docs, on `feat/F9.3-forking-docs`). | -| Opened | 2026-05-30 | +| Field | Value | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | Conversational Questionnaire | +| Repo | `human-centric-engineering/conquest` (forked from `human-centric-engineering/sunrise` at v0.0.1) | +| Host platform | Sunrise v0.0.1 | +| Lead | Simon Holmes | +| Status | `building` — P0 done (F0.1, PR #10); P1 done (F1.1 ingestion: PR #13/#14/#15); P2.5 done (F2.5.1, #21); P2 done (F2.1 #18/#19, F2.2 #23, F2.3 #24, F2.4 #25); P3 done (F3.1 #26, F3.2 #27, F3.3 #28, F3.4 #29); P4 done (F4.1 #30, F4.2 #32, F4.3 #33, F4.4 #34, F4.5 #35, F4.6 #36); P5 done (F5.1 #37, F5.2 #38, F5.3 #39); P6 done (F6.1 #40, F6.2 #41, F6.3 #42, F6.4 #43); P7 done (F7.1 #44, F7.2 #45, F7.3 #46, F7.4 #47) + admin upload-questionnaire UI (#48) + deferred-gaps close (#49); P8 done (F8.1 #53, F8.2 #54, F8.3 #55). **P9 done** — F9.1 done (#56), F9.4 done (demo-content seed, #57), F9.2 done (operational runbook, road-tested), F9.3 done (forking docs, #58). | +| Opened | 2026-05-30 | --- @@ -133,19 +133,19 @@ When a need arises that Sunrise's public surface doesn't cover, the rule is _not The build moves from scaffolding → ingestion → admin manage → demo branding → configuration → conversational core → evaluation → streaming → user UI → analytics → hardening. Phases are sequenced so each one's surface area is exercisable end-to-end before the next adds new abstraction. -| Phase | Title | Status | Notes | -| -------- | ----------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **P0** | Foundations | done | F0.1 shipped (PR #10) — substantially lighter than the original plan; Sunrise v0.0.1 provides the seams that used to need workarounds. | -| **P1** | Questionnaire ingestion | done | Admin uploads a doc; LLM extracts structure; changes recorded for review. API-only. F1.1 (P1's sole feature) complete: PR1 (schema) + PR2 (pure core) in PR #13; PR3 (extractor capability) in PR #14; PR4 (ingestion route + persistence) in PR #15. | -| **P2** | Admin CRUD over questionnaires | done | Admin UI: list, edit, version, tag, review extraction changes. All four features merged: F2.1 authoring (PR #18/#19); F2.2 tagging (#23); F2.3 extraction-change review (#24); F2.4 re-ingest (#25). | -| **P2.5** | Demo-client foundation | done | F2.5.1 shipped (PR #21) — demo-client identity + `AppQuestionnaire` FK + admin attribution, the slice that must **lead** so P3+ build tenant-aware. The rest of demo tenancy (theming, invitation branding, session reset, content seed) is **distributed** into P3/P6/P7/P9 as marked `// DEMO-ONLY:` sub-features. | -| **P3** | Configuration, invitations, and cost estimation | done | Per-version config; invitation flow; pre-launch cost estimate. All four features merged: F3.1 config (#26); F3.2 invitation flow (#27); F3.3 cost estimation (#28); F3.4 demo-client invitation branding (#29). | -| **P4** | Conversational engine (non-streaming) | done | Selection · extraction · contradiction · completion logic, exercised without the streaming surface. All six features merged: F4.1 selection strategies (#30) + F4.2 answer extraction (#32) + F4.3 contradiction detection (#33) + F4.4 answer refinement (#34) + F4.5 completion logic (#35) + F4.6 session state machine (#36). | -| **P5** | Design-time evaluation (agents-as-judges) | done | Judges score a questionnaire against goal/audience; suggestion review queue. F5.1 judge agents done (app-native dispatch — `evaluate-structure` capability + 7 `kind='judge'` seeds + preview route); F5.2 run persistence done (synchronous run route + app-owned run/finding models + admin run history); F5.3 review queue done (structured `proposedEdit` ops on the findings contract + apply-through-fork engine + read-time staleness + interactive queue UI). | -| **P6** | Conversational session (streaming) | done | F6.1 per-turn orchestrator + streaming (#40); F6.2 voice input (#41); F6.3 cost-cap enforcement (#42); F6.4 demo session reset (#43). _Attachment input closed by the 2026-06-07 audit gap-fill (Item 5)._ | -| **P7** | User-facing conversational UI | done | F7.1 chat surface (#44); F7.2 answer-slot panel (#45); F7.3 session lifecycle UX (#46); F7.4 PDF export (#47); admin upload-questionnaire UI (#48). _Attachment input + invite claim-via-login closed by the 2026-06-07 audit gap-fills._ | -| **P8** | Admin analytics, exports, anonymous mode | done | Dashboards, CSV/JSON export, anonymous-mode handling. All three features merged: F8.1 admin analytics dashboards (#53); F8.2 result exports (#54); F8.3 anonymous-mode hardening (#55). | -| **P9** | Hardening + forking docs | in flight | Concurrent-session sanity, flag inventory, runbook, `forking.md`. F9.1 production hardening (#56) + F9.4 demo-content seed (on `feat/F9.4-demo-seed-and-F9.2-runbook`) done; F9.2 operational runbook in flight (awaiting clean-machine road-test); F9.3 forking docs done (on `feat/F9.3-forking-docs`). | +| Phase | Title | Status | Notes | +| -------- | ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **P0** | Foundations | done | F0.1 shipped (PR #10) — substantially lighter than the original plan; Sunrise v0.0.1 provides the seams that used to need workarounds. | +| **P1** | Questionnaire ingestion | done | Admin uploads a doc; LLM extracts structure; changes recorded for review. API-only. F1.1 (P1's sole feature) complete: PR1 (schema) + PR2 (pure core) in PR #13; PR3 (extractor capability) in PR #14; PR4 (ingestion route + persistence) in PR #15. | +| **P2** | Admin CRUD over questionnaires | done | Admin UI: list, edit, version, tag, review extraction changes. All four features merged: F2.1 authoring (PR #18/#19); F2.2 tagging (#23); F2.3 extraction-change review (#24); F2.4 re-ingest (#25). | +| **P2.5** | Demo-client foundation | done | F2.5.1 shipped (PR #21) — demo-client identity + `AppQuestionnaire` FK + admin attribution, the slice that must **lead** so P3+ build tenant-aware. The rest of demo tenancy (theming, invitation branding, session reset, content seed) is **distributed** into P3/P6/P7/P9 as marked `// DEMO-ONLY:` sub-features. | +| **P3** | Configuration, invitations, and cost estimation | done | Per-version config; invitation flow; pre-launch cost estimate. All four features merged: F3.1 config (#26); F3.2 invitation flow (#27); F3.3 cost estimation (#28); F3.4 demo-client invitation branding (#29). | +| **P4** | Conversational engine (non-streaming) | done | Selection · extraction · contradiction · completion logic, exercised without the streaming surface. All six features merged: F4.1 selection strategies (#30) + F4.2 answer extraction (#32) + F4.3 contradiction detection (#33) + F4.4 answer refinement (#34) + F4.5 completion logic (#35) + F4.6 session state machine (#36). | +| **P5** | Design-time evaluation (agents-as-judges) | done | Judges score a questionnaire against goal/audience; suggestion review queue. F5.1 judge agents done (app-native dispatch — `evaluate-structure` capability + 7 `kind='judge'` seeds + preview route); F5.2 run persistence done (synchronous run route + app-owned run/finding models + admin run history); F5.3 review queue done (structured `proposedEdit` ops on the findings contract + apply-through-fork engine + read-time staleness + interactive queue UI). | +| **P6** | Conversational session (streaming) | done | F6.1 per-turn orchestrator + streaming (#40); F6.2 voice input (#41); F6.3 cost-cap enforcement (#42); F6.4 demo session reset (#43). _Attachment input closed by the 2026-06-07 audit gap-fill (Item 5)._ | +| **P7** | User-facing conversational UI | done | F7.1 chat surface (#44); F7.2 answer-slot panel (#45); F7.3 session lifecycle UX (#46); F7.4 PDF export (#47); admin upload-questionnaire UI (#48). _Attachment input + invite claim-via-login closed by the 2026-06-07 audit gap-fills._ | +| **P8** | Admin analytics, exports, anonymous mode | done | Dashboards, CSV/JSON export, anonymous-mode handling. All three features merged: F8.1 admin analytics dashboards (#53); F8.2 result exports (#54); F8.3 anonymous-mode hardening (#55). | +| **P9** | Hardening + forking docs | done | Concurrent-session sanity, flag inventory, runbook, `forking.md`. F9.1 production hardening (#56) + F9.4 demo-content seed (#57) done; F9.2 operational runbook road-tested and shipped; F9.3 forking docs done (#58). | --- @@ -701,9 +701,9 @@ _Indicative tasks:_ ### F9.2 — Operational runbook -_Status:_ in flight ([tracker](./features/f9.2.md)) · _Size:_ ~1 PR · _Owner:_ TBD · _Deps:_ F9.1, F9.4 +_Status:_ done ([tracker](./features/f9.2.md)) · _Size:_ ~1 PR · _Owner:_ John / Simon · _Deps:_ F9.1, F9.4 -`.context/app/questionnaire/runbook.md`: how to spin up a new demo client end-to-end. Road-tested by John or Simon before the phase ships, with friction corrected into the doc. +`.context/app/questionnaire/runbook.md`: how to spin up a new demo client end-to-end. Road-tested by John/Simon, with the friction corrected into the doc. _Indicative tasks:_ @@ -820,8 +820,8 @@ Append-only. Newest at the top. Each entry: date, decision, context, link. Append-only. Newest at the top. -- **2026-06-09 — F9.2 operational runbook (in flight, on `feat/F9.4-demo-seed-and-F9.2-runbook`).** `.context/app/questionnaire/runbook.md` — the end-to-end "spin up a demo client" walkthrough: prerequisites (flags are DB rows, not env vars), the F9.4 seeded fast path, manual client + content (clone-first / upload-and-extract), review/configure/launch, invite, first session, reset, troubleshooting, and a clean-machine road-test checklist. Demo-doc convention: a `> **DEMO-ONLY**` callout, cross-linked from `demo-clients.md` rather than the namespace index. **Awaiting the human road-test** (John/Simon) before flipping to done. No CHANGELOG entry (app docs). Tracker: [`features/f9.2.md`](features/f9.2.md). -- **2026-06-09 — F9.4 demo content seed (done, on `feat/F9.4-demo-seed-and-F9.2-runbook`).** `prisma/seeds/app-questionnaire/025-demo-content.ts` — env-gated (`LOAD_DEMO_CONTENT=1`), idempotent, DEMO-ONLY seed that builds a branded "Northwind Logistics" demo client + a launched, attributed questionnaire (2 sections, 6 questions across the type range, name/email profile capture) in one `executeTransaction`, reusing the ingestion `writeGraph` writer and re-asserting the launch gate before flipping the version to `launched`. Built ahead of F9.2 so the runbook can exercise it; `.env.example` documents the flag + the no-op reload gotcha. Verified against the dev DB (seed → correct rows → idempotent re-run → no-op without the flag). No CHANGELOG entry (app/demo-only seed). Tracker: [`features/f9.4.md`](features/f9.4.md). +- **2026-06-09 — F9.2 operational runbook (done).** `.context/app/questionnaire/runbook.md` — the end-to-end "spin up a demo client" walkthrough: prerequisites (flags are DB rows, not env vars), the F9.4 seeded fast path, manual client + content (clone-first / upload-and-extract), review/configure/launch, invite, first session, reset, troubleshooting, and a clean-machine road-test checklist. Demo-doc convention: a `> **DEMO-ONLY**` callout, cross-linked from `demo-clients.md` rather than the namespace index. Road-tested by John/Simon on a clean machine; friction folded back into the doc. With F9.2 shipped, **P9 — and the P0–P9 build — is complete**. No CHANGELOG entry (app docs). Tracker: [`features/f9.2.md`](features/f9.2.md). +- **2026-06-09 — F9.4 demo content seed (done, on `feat/F9.4-demo-seed-and-F9.2-runbook`).** `prisma/seeds/app-questionnaire/025-demo-content.ts` — env-gated (`LOAD_DEMO_CONTENT=1`), idempotent, DEMO-ONLY seed that builds a branded "Northwind Logistics" demo client + a launched, attributed questionnaire (2 sections, 6 questions across the type range, run anonymously with contradiction flagging on so the demo-polish "Preview as respondent" + contradiction callout work out of the box) in one `executeTransaction`, reusing the ingestion `writeGraph` writer and re-asserting the launch gate before flipping the version to `launched`. Built ahead of F9.2 so the runbook can exercise it; `.env.example` documents the flag + the no-op reload gotcha. Verified against the dev DB (seed → correct rows → idempotent re-run → no-op without the flag). No CHANGELOG entry (app/demo-only seed). Tracker: [`features/f9.4.md`](features/f9.4.md). > Backfill note (2026-06-09): entries for F6.4 through F9.1 below were reconstructed in one pass from their per-feature trackers (`features/f*.md`) + git history — the aggregate log had lagged since F6.3. They're accurate to the trackers but deliberately concise; the trackers remain the fuller record. diff --git a/.context/app/planning/features/f9.2.md b/.context/app/planning/features/f9.2.md index 7a3e644bf..1a93c3f8e 100644 --- a/.context/app/planning/features/f9.2.md +++ b/.context/app/planning/features/f9.2.md @@ -2,8 +2,8 @@ feature: F9.2 title: Operational runbook phase: P9 — Hardening + forking docs -status: in flight -owner: TBD +status: shipped +owner: John / Simon deps: F9.1, F9.4 (demo content seed — built first so the runbook can exercise it) opened: 2026-06-09 plan: .context/app/planning/development-plan.md#f92--operational-runbook @@ -57,8 +57,8 @@ path so a stock demo needs zero manual setup. - `npm run validate` clean (the runbook + tracker are docs; no code beyond F9.4). - Doc links resolve (sibling `.md` files exist; `forking.md` is a known P9 forward-reference, matching the existing `demo-clients.md` reference). -- **Pending — human road-test.** John/Simon run the checklist on a clean machine; friction is - folded into the doc, then the feature flips to `shipped`. +- **Done — human road-test.** John/Simon ran the checklist on a clean machine; the runbook was + corrected against the friction and the feature flipped to `shipped`. ## No CHANGELOG entry diff --git a/.context/app/planning/features/f9.4.md b/.context/app/planning/features/f9.4.md index ab4741251..58c0de296 100644 --- a/.context/app/planning/features/f9.4.md +++ b/.context/app/planning/features/f9.4.md @@ -43,8 +43,11 @@ it is pure demo scaffolding that takes the same persistence path a real upload d - **Replace-on-rerun idempotency.** The demo client upserts by unique `slug`; the questionnaire is found-and-replaced by a stable title marker (`Northwind Logistics — Onboarding Experience Review`). Editing the sample content and re-seeding refreshes the demo with no duplicates. -- **Profile capture on.** `anonymousMode` stays false and two profile fields (name, work email) - are seeded, so the demo visibly shows the F8.3 session-start profile step. +- **Anonymous, with contradiction flagging on.** `anonymousMode: true` (no profile fields) so the + admin can one-click "Preview as respondent" through the no-login public surface, and + `contradictionMode: 'flag'` so the chat's "I noticed something" callout fires on inconsistent + answers — the two demo-polish surfacings depend on this config. (Originally seeded with + `anonymousMode: false` + name/email profile capture; changed during the demo-polish pass.) ## Build shape (branch `feat/F9.4-demo-seed-and-F9.2-runbook`) @@ -52,7 +55,7 @@ Review`). Editing the sample content and re-seeding refreshes the demo with no d `app-questionnaire/025-demo-content` (next free prefix after `024`). Env-gate → upsert demo client → replace prior demo questionnaire → create questionnaire (attributed) → version (goal + audience + provenance) → `writeGraph` (2 sections, 6 slots spanning the question-type - range) → config row (defaults + profile fields) → flip version to `launched`. Whole file + range) → config row (anonymous + contradiction-flag) → flip version to `launched`. Whole file marked `// DEMO-ONLY`; nothing imports it, so a fork deletes it wholesale. - **`.env.example`** — a documented, commented `# LOAD_DEMO_CONTENT=1` block (DEMO-ONLY) under a new "Demo content" section, including the re-load recipe for the no-op gotcha. @@ -77,13 +80,13 @@ Vertical: **B2B SaaS onboarding feedback** — "Northwind Logistics" demo client CTA/accent/logo/welcome copy) and a launched questionnaire "Northwind Logistics — Onboarding Experience Review": 2 sections (Getting started · Value & support), 6 questions covering `likert | free_text | single_choice | numeric | multi_choice | boolean`, a full 7-field -audience, and name/email profile capture. +audience, run anonymously with contradiction flagging on. ## Verification - `LOAD_DEMO_CONTENT=1 npm run db:seed` — seeds the client + launched questionnaire; logged. - Row check: 1 demo client, 1 questionnaire (attributed), 1 version (`launched`, goal set), - 2 sections, 6 slots, 1 config row (2 profile fields, `anonymousMode=false`). + 2 sections, 6 slots, 1 config row (`anonymousMode=true`, `contradictionMode='flag'`, no profile fields). - `npm run db:seed` again **without** the env var → seed no-ops (skip log), creates nothing. - `npm run validate` clean (type-check + lint + format). diff --git a/.context/app/questionnaire/runbook.md b/.context/app/questionnaire/runbook.md index 86210b5d5..0ad12b423 100644 --- a/.context/app/questionnaire/runbook.md +++ b/.context/app/questionnaire/runbook.md @@ -45,9 +45,13 @@ This creates (idempotently — safe to re-run): - **Demo client** "Northwind Logistics (Demo)" (slug `northwind-logistics-demo`), branded (CTA/accent colours, logo, welcome copy). - **Launched questionnaire** "Northwind Logistics — Onboarding Experience Review" — 2 sections, - 6 questions, attributed to that client, with name + work-email profile capture on. + 6 questions, attributed to that client. Runs **anonymously** (so you can one-click "Preview as + respondent" from the questionnaire's admin page — no email needed) with **contradiction + flagging** on (give inconsistent answers and the chat surfaces an "I noticed something" callout; + needs the contradiction + live-sessions flags on — see §0). -Then skip to **§3 Invite a respondent**. To confirm it loaded, open `/admin/questionnaires` — +Then skip to **§3 Invite a respondent** — or just hit **Preview as respondent** on the +questionnaire's admin page to try it yourself immediately. To confirm it loaded, open `/admin/questionnaires` — the questionnaire shows `launched` and attributed to Northwind. > **Gotcha — it didn't appear?** The seed no-ops unless `LOAD_DEMO_CONTENT=1`. If `db:seed` ran diff --git a/app/admin/questionnaires/[id]/page.tsx b/app/admin/questionnaires/[id]/page.tsx index 17131f039..a01f249e4 100644 --- a/app/admin/questionnaires/[id]/page.tsx +++ b/app/admin/questionnaires/[id]/page.tsx @@ -6,6 +6,7 @@ import { VersionGraph } from '@/components/admin/questionnaires/version-graph'; import { VersionEditor } from '@/components/admin/questionnaires/version-editor'; import { ReingestDialog } from '@/components/admin/questionnaires/reingest-dialog'; import { CloneForClientDialog } from '@/components/admin/questionnaires/clone-for-client-dialog'; +import { LaunchChecklist } from '@/components/admin/questionnaires/launch-checklist'; import { QUESTIONNAIRE_STATUS_BADGE } from '@/components/admin/questionnaires/status-badge'; import { DemoClientAssign } from '@/components/admin/demo-clients/demo-client-assign'; import { Badge } from '@/components/ui/badge'; @@ -16,6 +17,7 @@ import { logger } from '@/lib/logging'; import { isAdaptiveSelectionEnabled, isDesignEvaluationEnabled, + isLiveSessionsEnabled, isQuestionnairesEnabled, } from '@/lib/app/questionnaire/feature-flag'; import type { AttributedDemoClient, DemoClientView } from '@/lib/app/questionnaire/demo-clients'; @@ -94,6 +96,9 @@ export default async function QuestionnaireDetailPage({ params, searchParams }: const adaptiveEnabled = editing ? await isAdaptiveSelectionEnabled() : false; // Design-evaluation sub-flag — gates the Evaluations entry (the run route 404s when off). const designEvalEnabled = selected ? await isDesignEvaluationEnabled() : false; + // Live-sessions sub-flag — gates the "Preview as respondent" link (the /q surface 404s when off). + const liveSessionsEnabled = + selected?.status === 'launched' ? await isLiveSessionsEnabled() : false; return (
@@ -172,6 +177,42 @@ export default async function QuestionnaireDetailPage({ params, searchParams }: )}

+ {/* Review & Launch (F2.1 surfacing) — a draft's primary action, with the + launch-gate checklist shown before the flip. Outside edit mode so launch + isn't buried behind Edit. */} + {selected.status === 'draft' && graph && ( + + )} + {/* Preview as respondent — one-click "try it" via the no-login public surface. + Only works on a launched, anonymous-mode version (the /q route's gates); the + demo seed satisfies both. */} + {liveSessionsEnabled && + graph && + (graph.config.anonymousMode ? ( + + ) : ( + + ))} {/* Invitations (F3.2) are managed per-questionnaire across launched versions. */} + + + + Launch v{versionNumber} + + Launching makes this version available to respondents. Once launched, editing it forks a + new draft so in-flight sessions stay pinned to what they started. + + + +
    + {checks.map((c) => ( + + {c.label} + + ))} +
+ + {!ready && ( +

+ Finish the unchecked items above to launch. +

+ )} + {error &&

{error}

} + + + + +
+ + ); +} diff --git a/components/app/questionnaire/chat/contradiction-notice.tsx b/components/app/questionnaire/chat/contradiction-notice.tsx new file mode 100644 index 000000000..313f2c3b7 --- /dev/null +++ b/components/app/questionnaire/chat/contradiction-notice.tsx @@ -0,0 +1,56 @@ +'use client'; + +/** + * ContradictionNotice — the "the agent noticed something" callout in the respondent + * chat (F7.2 surfacing). + * + * When the per-turn orchestrator's contradiction detection (F4.3) flags a possible + * inconsistency, it streams a `warning` event with `code: 'contradiction'` whose message + * is the agent's `suggestedProbe`/`explanation`. The chat renders most warnings as a quiet + * fail-soft line; this one is the single best "the AI is reasoning about your answers" + * signal, so it gets a tasteful accent-bordered callout instead. Presentational only — + * the message text is decided upstream. + * + * Brand colour comes from the page's `BrandThemeProvider` CSS vars, matching the + * `AssistantTurn` accent dot. + * + * `// DEMO-ONLY (F7.2):` questionnaire-domain notice. + */ + +import { Sparkles } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +export interface ContradictionNoticeProps { + /** The agent's probe / explanation of the possible inconsistency. */ + message: string; + className?: string; +} + +export function ContradictionNotice({ message, className }: ContradictionNoticeProps) { + return ( +
+
+ ); +} diff --git a/components/app/questionnaire/chat/questionnaire-chat.tsx b/components/app/questionnaire/chat/questionnaire-chat.tsx index d12f25448..8fd4a6590 100644 --- a/components/app/questionnaire/chat/questionnaire-chat.tsx +++ b/components/app/questionnaire/chat/questionnaire-chat.tsx @@ -37,6 +37,7 @@ import { type AttachmentEntry } from '@/lib/hooks/use-attachments'; import type { ChatAttachment } from '@/lib/orchestration/chat/types'; import type { UseQuestionnaireSessionStreamReturn } from '@/lib/hooks/use-questionnaire-session-stream'; import { ChatErrorPanel } from '@/components/app/questionnaire/chat/chat-error-panel'; +import { ContradictionNotice } from '@/components/app/questionnaire/chat/contradiction-notice'; export interface QuestionnaireChatProps { /** The session id powering `/questionnaire-sessions/:id/messages` (used by the mic). */ @@ -176,16 +177,21 @@ export function QuestionnaireChat({ )} - {/* Side-band contradiction / fail-soft notice */} - {warning && ( -
- {warning.message} -
- )} + {/* Side-band notice. A flagged contradiction (F4.3) gets a tasteful callout — + the clearest "the agent is reasoning about your answers" signal; every other + warning stays a quiet fail-soft line. */} + {warning && + (warning.code === 'contradiction' ? ( + + ) : ( +
+ {warning.message} +
+ ))} {/* Blocking / error state */} {error && ( diff --git a/components/app/questionnaire/lifecycle/session-lifecycle-bar.tsx b/components/app/questionnaire/lifecycle/session-lifecycle-bar.tsx index d808f051d..ea303d3d8 100644 --- a/components/app/questionnaire/lifecycle/session-lifecycle-bar.tsx +++ b/components/app/questionnaire/lifecycle/session-lifecycle-bar.tsx @@ -17,6 +17,7 @@ import { PauseCircle, PlayCircle, ShieldCheck, Hourglass, AlertTriangle } from ' import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { SessionProgressBar } from '@/components/app/questionnaire/session-progress-bar'; import type { SessionStatusView } from '@/lib/app/questionnaire/session/status-view'; export interface SessionLifecycleBarProps { @@ -50,67 +51,71 @@ export function SessionLifecycleBar({ const showResume = paused && canResume; const showPause = !paused && canPause; - const hasContent = anonymous || showCostHint || showResume || showPause || actionError !== null; - if (!hasContent) return null; + // The coverage bar shows whenever we have a status view (i.e. the session is live); + // the affordance strip below it stays conditional, so a plain active session shows + // just the progress bar. + const showProgress = view !== null; + const hasStrip = anonymous || showCostHint || showResume || showPause || actionError !== null; + if (!showProgress && !hasStrip) return null; return ( -
- {anonymous && ( - - - )} +
+ {showProgress && } + {hasStrip && ( +
+ {anonymous && ( + + + )} - {showCostHint && ( - - - )} + {showCostHint && ( + + + )} - {paused && ( - - - )} + {paused && ( + + + )} - - {actionError && ( - - +
+ )}
); } diff --git a/components/app/questionnaire/panel/answer-slot-item.tsx b/components/app/questionnaire/panel/answer-slot-item.tsx index cd9e3b792..ef8090138 100644 --- a/components/app/questionnaire/panel/answer-slot-item.tsx +++ b/components/app/questionnaire/panel/answer-slot-item.tsx @@ -71,6 +71,13 @@ export function AnswerSlotItem({ slot, onRevisit, canRevisit = false }: AnswerSl Not answered yet )} + {/* A one-line peek at the model's reasoning, so the "why" reads in the row + itself; the full rationale stays in the expanded view. */} + {slot.answered && slot.rationale && !expanded && ( + + {slot.rationale} + + )} {slot.answered && } diff --git a/components/app/questionnaire/session-progress-bar.tsx b/components/app/questionnaire/session-progress-bar.tsx new file mode 100644 index 000000000..e2af1fbdd --- /dev/null +++ b/components/app/questionnaire/session-progress-bar.tsx @@ -0,0 +1,50 @@ +'use client'; + +/** + * SessionProgressBar — a slim weighted-coverage bar for the respondent surface (F7.3). + * + * Drives off the F4.5 completion assessment's weighted `coverage` (0–1), already projected + * onto {@link SessionStatusView} and fetched by `useSessionLifecycle`. A quiet "we're + * getting somewhere" momentum signal — rendered both in the lifecycle strip and (because + * the answer panel is hidden below `lg`) in the chat header so narrow viewports keep it. + * + * Quiet-signal discipline, like the answer panel's confidence dot: it shows progress, not + * the underlying weights or thresholds. Brand colour comes from the page's + * `BrandThemeProvider` CSS vars. + * + * `// DEMO-ONLY (F7.3):` questionnaire-domain affordance. + */ + +import { cn } from '@/lib/utils'; + +export interface SessionProgressBarProps { + /** Weighted coverage in [0, 1]; out-of-range values are clamped. */ + coverage: number; + className?: string; +} + +export function SessionProgressBar({ coverage, className }: SessionProgressBarProps) { + const pct = Math.round(Math.min(1, Math.max(0, coverage)) * 100); + + return ( +
+
+
+
+ {pct}% +
+ ); +} diff --git a/prisma/seeds/app-questionnaire/025-demo-content.ts b/prisma/seeds/app-questionnaire/025-demo-content.ts index a89a11df7..725a91c81 100644 --- a/prisma/seeds/app-questionnaire/025-demo-content.ts +++ b/prisma/seeds/app-questionnaire/025-demo-content.ts @@ -17,7 +17,6 @@ import { DEFAULT_QUESTIONNAIRE_CONFIG, type AudienceProvenance, type AudienceShape, - type ProfileFieldConfig, } from '@/lib/app/questionnaire/types'; // DEMO-ONLY: the demo prospect. Slug is derived from the name (the admin UI does the @@ -70,12 +69,11 @@ const DEMO_AUDIENCE_PROVENANCE: AudienceProvenance = { notes: 'admin-supplied', }; -// DEMO-ONLY: session-start profile capture, so the demo shows the F8.3 profile step -// (anonymousMode stays false). Keys are lowercase-snake per profileFieldKeySchema. -const DEMO_PROFILE_FIELDS: ProfileFieldConfig[] = [ - { key: 'name', label: 'Your name', type: 'text', required: true }, - { key: 'work_email', label: 'Work email', type: 'email', required: true }, -]; +// DEMO-ONLY: the demo runs in anonymous mode (anonymousMode: true) so the admin can +// one-click "Preview as respondent" through the no-login public surface — no email, no +// invitation. Anonymous mode collects no profile fields (the F8.3 invariant), which suits +// an anonymous onboarding-feedback survey. To demo identified profile capture instead, +// turn anonymous mode off in the config and add profileFields. // DEMO-ONLY: the hand-authored extraction graph (2 sections, 6 questions spanning the // question-type range). Fed through `writeGraph` exactly as the ingestion route feeds @@ -237,7 +235,13 @@ const unit: SeedUnit = { const counts = await writeGraph(tx, version.id, DEMO_EXTRACTION); // Config row — the launch gate requires the row to exist. Mirror the schema - // defaults, overriding only the demo-relevant knobs (profile capture on). + // defaults, overriding only the demo-relevant knobs: + // - anonymousMode: true — lets the admin one-click "Preview as respondent" via the + // no-login public surface; collects no profile fields (F8.3). + // - contradictionMode: 'flag' — so the chat's "I noticed something" callout fires + // when a respondent gives inconsistent answers (the most visible sign of the + // agent reasoning about the conversation). The contradiction sub-flag and + // live-sessions flag (DB rows) must also be on at runtime; see the runbook. await tx.appQuestionnaireConfig.create({ data: { versionId: version.id, @@ -247,12 +251,12 @@ const unit: SeedUnit = { costBudgetUsd: DEFAULT_QUESTIONNAIRE_CONFIG.costBudgetUsd, maxQuestionsPerSession: DEFAULT_QUESTIONNAIRE_CONFIG.maxQuestionsPerSession, voiceEnabled: DEFAULT_QUESTIONNAIRE_CONFIG.voiceEnabled, - contradictionMode: DEFAULT_QUESTIONNAIRE_CONFIG.contradictionMode, - contradictionWindowN: DEFAULT_QUESTIONNAIRE_CONFIG.contradictionWindowN, - contradictionEveryNTurns: DEFAULT_QUESTIONNAIRE_CONFIG.contradictionEveryNTurns, - anonymousMode: DEFAULT_QUESTIONNAIRE_CONFIG.anonymousMode, + contradictionMode: 'flag', + contradictionWindowN: 4, + contradictionEveryNTurns: 1, + anonymousMode: true, answerSlotPanelScope: DEFAULT_QUESTIONNAIRE_CONFIG.answerSlotPanelScope, - profileFields: DEMO_PROFILE_FIELDS, + profileFields: [], }, }); diff --git a/tests/integration/api/v1/app/questionnaire-sessions/turn-invokers.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/turn-invokers.test.ts index aeeaa24e4..0044b852f 100644 --- a/tests/integration/api/v1/app/questionnaire-sessions/turn-invokers.test.ts +++ b/tests/integration/api/v1/app/questionnaire-sessions/turn-invokers.test.ts @@ -12,7 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const prismaMock = vi.hoisted(() => ({ aiAgent: { findUnique: vi.fn() } })); vi.mock('@/lib/db/client', () => ({ prisma: prismaMock })); -const dispatcherMock = vi.hoisted(() => ({ dispatch: vi.fn() })); +const dispatcherMock = vi.hoisted(() => ({ dispatch: vi.fn(), register: vi.fn() })); vi.mock('@/lib/orchestration/capabilities/dispatcher', () => ({ capabilityDispatcher: dispatcherMock, })); diff --git a/tests/integration/app/admin/orchestration/agents/edit-page.test.tsx b/tests/integration/app/admin/orchestration/agents/edit-page.test.tsx index 080df3a8c..a23f8db06 100644 --- a/tests/integration/app/admin/orchestration/agents/edit-page.test.tsx +++ b/tests/integration/app/admin/orchestration/agents/edit-page.test.tsx @@ -36,6 +36,22 @@ vi.mock('@/lib/api/server-fetch', () => ({ parseApiResponse: vi.fn(), })); +// Defensive DB isolation. The edit page's happy path passes the agent's already-set +// provider/model to `getEffectiveAgentDefaults`, so it skips Prisma — but the prefetch +// helpers import the real client, and an empty provider/model (or future edit) would hit +// `aiProviderConfig.findMany` / `aiOrchestrationSettings.findUnique`. Stub the client so +// this test can never touch the live dev database (the same real-DB-under-load contention +// that made the sibling new-agent page test flaky). See new-page.test.tsx. +vi.mock('@/lib/db/client', () => ({ + prisma: { + aiProviderConfig: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + }, + aiOrchestrationSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + vi.mock('@/lib/logging', () => ({ logger: { info: vi.fn(), diff --git a/tests/integration/app/admin/orchestration/agents/new-page.test.tsx b/tests/integration/app/admin/orchestration/agents/new-page.test.tsx index c297fd78c..fa5ab7592 100644 --- a/tests/integration/app/admin/orchestration/agents/new-page.test.tsx +++ b/tests/integration/app/admin/orchestration/agents/new-page.test.tsx @@ -21,6 +21,24 @@ vi.mock('@/lib/api/server-fetch', () => ({ parseApiResponse: vi.fn(), })); +// The new-agent page resolves "effective defaults" for an empty provider/model via +// `getEffectiveAgentDefaults`, which reads the provider list (`aiProviderConfig.findMany`) +// and the default-model singleton (`aiOrchestrationSettings.findUnique`) straight from +// Prisma. Without this mock the test hits the real dev database: in isolation that's fast, +// but under the full suite the connection pool is saturated by other integration tests and +// the query blocks until the 10s test timeout fires — the source of the intermittent +// "renders New agent heading"/"Create agent button" failures. Stubbing the client keeps the +// page on its DB-empty path (provider/model stay '', which is exactly the create-mode default). +vi.mock('@/lib/db/client', () => ({ + prisma: { + aiProviderConfig: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + }, + aiOrchestrationSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + vi.mock('@/lib/logging', () => ({ logger: { info: vi.fn(), diff --git a/tests/unit/app/admin/questionnaires/[id]/page.test.tsx b/tests/unit/app/admin/questionnaires/[id]/page.test.tsx new file mode 100644 index 000000000..901ac799b --- /dev/null +++ b/tests/unit/app/admin/questionnaires/[id]/page.test.tsx @@ -0,0 +1,245 @@ +/** + * Admin Questionnaire Detail Page tests. + * + * The page is an async Server Component that gates on `isQuestionnairesEnabled`, fetches the + * detail + (per selected version) graph via `serverFetch`, and renders version actions. These + * tests pin the feature-flag gate, the not-found paths, version selection, and — the focus of + * the demo-polish PR — the two new operator affordances: + * - "Review & Launch" (LaunchChecklist) shows only on a draft version with a loaded graph. + * - "Preview as respondent" links to the no-login `/q/` surface only when the + * live-sessions flag is on AND the version is launched AND anonymous mode is enabled; + * otherwise it renders disabled (with guidance) or not at all. + * + * Data fetching is faked at the `server-fetch` boundary (routed by URL); the heavy child + * components are stubbed so we assert the page's own branching, not their internals. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import QuestionnaireDetailPage from '@/app/admin/questionnaires/[id]/page'; +import { DEFAULT_QUESTIONNAIRE_CONFIG } from '@/lib/app/questionnaire/types'; +import type { + QuestionnaireDetail, + QuestionnaireVersionSummary, + VersionGraphView, +} from '@/lib/app/questionnaire/views'; + +vi.mock('next/navigation', () => ({ + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND'); + }), +})); + +vi.mock('@/lib/logging', () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +const flagMock = vi.hoisted(() => ({ + isQuestionnairesEnabled: vi.fn(), + isAdaptiveSelectionEnabled: vi.fn(), + isDesignEvaluationEnabled: vi.fn(), + isLiveSessionsEnabled: vi.fn(), +})); +vi.mock('@/lib/app/questionnaire/feature-flag', () => flagMock); + +// `serverFetch` returns a Response-like marker carrying its URL; `parseApiResponse` routes off +// that URL into the per-test `apiData` registry. This keeps the three fetches (detail, demo +// clients, version graph) independently controllable without relying on call order. +interface ApiData { + detail: QuestionnaireDetail | null; + graph: VersionGraphView | null; +} +const apiData: ApiData = { detail: null, graph: null }; + +vi.mock('@/lib/api/server-fetch', () => ({ + serverFetch: vi.fn(async (url: string) => ({ ok: true, _url: url })), + parseApiResponse: vi.fn(async (res: { _url: string }) => { + const url = res._url; + if (url.includes('/versions/')) { + return apiData.graph ? { success: true, data: apiData.graph } : { success: false, error: {} }; + } + if (url.includes('/demo-clients')) { + return { success: true, data: [] }; + } + return apiData.detail ? { success: true, data: apiData.detail } : { success: false, error: {} }; + }), +})); + +// Heavy children stubbed to identifiable markers so we assert the page's branching only. +vi.mock('@/components/admin/questionnaires/launch-checklist', () => ({ + LaunchChecklist: (props: { versionNumber: number }) => ( +
launch v{props.versionNumber}
+ ), +})); +vi.mock('@/components/admin/questionnaires/version-graph', () => ({ + VersionGraph: () =>
, +})); +vi.mock('@/components/admin/questionnaires/version-editor', () => ({ + VersionEditor: () =>
, +})); +vi.mock('@/components/admin/questionnaires/reingest-dialog', () => ({ + ReingestDialog: () =>
, +})); +vi.mock('@/components/admin/questionnaires/clone-for-client-dialog', () => ({ + CloneForClientDialog: () =>
, +})); +vi.mock('@/components/admin/demo-clients/demo-client-assign', () => ({ + DemoClientAssign: () =>
, +})); + +function makeVersion(over: Partial = {}): QuestionnaireVersionSummary { + return { + id: 'ver-1', + versionNumber: 1, + status: 'launched', + goal: 'Understand the prospect', + audience: null, + sectionCount: 2, + questionCount: 5, + changeCount: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + ...over, + }; +} + +function makeDetail(over: Partial = {}): QuestionnaireDetail { + return { + id: 'qn-1', + title: 'Northwind Onboarding', + status: 'launched', + demoClient: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + versions: [makeVersion()], + ...over, + }; +} + +function makeGraph(over: { anonymousMode?: boolean; saved?: boolean } = {}): VersionGraphView { + return { + id: 'ver-1', + questionnaireId: 'qn-1', + versionNumber: 1, + status: 'launched', + goal: 'Understand the prospect', + audience: null, + goalProvenance: null, + audienceProvenance: null, + sections: [], + tags: [], + config: { + ...DEFAULT_QUESTIONNAIRE_CONFIG, + saved: over.saved ?? true, + anonymousMode: over.anonymousMode ?? true, + }, + }; +} + +function renderPage(opts: { id?: string; v?: string; edit?: string } = {}) { + return QuestionnaireDetailPage({ + params: Promise.resolve({ id: opts.id ?? 'qn-1' }), + searchParams: Promise.resolve({ v: opts.v, edit: opts.edit }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + apiData.detail = makeDetail(); + apiData.graph = makeGraph(); + flagMock.isQuestionnairesEnabled.mockResolvedValue(true); + flagMock.isAdaptiveSelectionEnabled.mockResolvedValue(false); + flagMock.isDesignEvaluationEnabled.mockResolvedValue(false); + flagMock.isLiveSessionsEnabled.mockResolvedValue(true); +}); + +describe('QuestionnaireDetailPage', () => { + describe('gating', () => { + it('calls notFound when the questionnaires feature is disabled', async () => { + flagMock.isQuestionnairesEnabled.mockResolvedValue(false); + await expect(renderPage()).rejects.toThrow('NEXT_NOT_FOUND'); + }); + + it('calls notFound when the detail fetch returns nothing', async () => { + apiData.detail = null; + await expect(renderPage()).rejects.toThrow('NEXT_NOT_FOUND'); + }); + }); + + describe('rendering', () => { + it('renders the title and version count', async () => { + render(await renderPage()); + expect(screen.getByRole('heading', { name: 'Northwind Onboarding' })).toBeInTheDocument(); + expect(screen.getByText('1 version')).toBeInTheDocument(); + }); + + it('selects the version named by the ?v= param', async () => { + apiData.detail = makeDetail({ + versions: [ + makeVersion({ id: 'ver-2', versionNumber: 2 }), + makeVersion({ id: 'ver-1', versionNumber: 1 }), + ], + }); + render(await renderPage({ v: 'ver-1' })); + // The version-selector link for the *active* version omits the ?v= self-link styling we + // can't easily assert; instead confirm the section/question summary for the picked version + // rendered (proving selection resolved to a real version, not a crash). + expect(screen.getByText(/2 sections · 5 questions/)).toBeInTheDocument(); + }); + }); + + describe('Review & Launch (LaunchChecklist)', () => { + it('renders the launch checklist on a draft version with a loaded graph', async () => { + apiData.detail = makeDetail({ + status: 'draft', + versions: [makeVersion({ status: 'draft' })], + }); + apiData.graph = makeGraph(); + render(await renderPage()); + expect(screen.getByTestId('launch-checklist')).toHaveTextContent('launch v1'); + }); + + it('does not render the launch checklist on a launched version', async () => { + // default detail/graph are launched + render(await renderPage()); + expect(screen.queryByTestId('launch-checklist')).not.toBeInTheDocument(); + }); + }); + + describe('Preview as respondent', () => { + it('renders an enabled link to /q/ when launched + live-sessions on + anonymous', async () => { + apiData.graph = makeGraph({ anonymousMode: true }); + render(await renderPage()); + const link = screen.getByRole('link', { name: /preview as respondent/i }); + expect(link).toHaveAttribute('href', '/q/ver-1'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders a disabled button (no link) when anonymous mode is off', async () => { + apiData.graph = makeGraph({ anonymousMode: false }); + render(await renderPage()); + expect( + screen.queryByRole('link', { name: /preview as respondent/i }) + ).not.toBeInTheDocument(); + const btn = screen.getByRole('button', { name: /preview as respondent/i }); + expect(btn).toBeDisabled(); + }); + + it('is absent entirely when the live-sessions flag is off', async () => { + flagMock.isLiveSessionsEnabled.mockResolvedValue(false); + render(await renderPage()); + expect(screen.queryByText(/preview as respondent/i)).not.toBeInTheDocument(); + }); + + it('does not query the live-sessions flag for a draft version (status short-circuit)', async () => { + apiData.detail = makeDetail({ + status: 'draft', + versions: [makeVersion({ status: 'draft' })], + }); + await renderPage(); + expect(flagMock.isLiveSessionsEnabled).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/components/admin/questionnaires/launch-checklist.test.tsx b/tests/unit/components/admin/questionnaires/launch-checklist.test.tsx new file mode 100644 index 000000000..decfbf7b1 --- /dev/null +++ b/tests/unit/components/admin/questionnaires/launch-checklist.test.tsx @@ -0,0 +1,94 @@ +/** + * LaunchChecklist component tests. + * + * Anti-green-bar: asserts the readiness gate mirrors `assertLaunchable` — Launch is + * disabled until all five criteria (goal, audience with ≥1 field, ≥1 section, ≥1 question, + * saved config) pass, an empty audience `{}` counts as not-ready, and a ready checklist + * PATCHes the version status to `launched` via the shared authoring mutation. + * + * @see components/admin/questionnaires/launch-checklist.tsx + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// ─── Mocks ────────────────────────────────────────────────────────────────── + +const mockRefresh = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: mockRefresh, push: vi.fn(), replace: vi.fn() }), +})); + +const mockAuthoringMutate = vi.fn(); +vi.mock('@/components/admin/questionnaires/authoring-mutate', () => ({ + authoringMutate: (...args: unknown[]) => mockAuthoringMutate(...args), +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { LaunchChecklist } from '@/components/admin/questionnaires/launch-checklist'; +import { API } from '@/lib/api/endpoints'; +import type { AudienceShape } from '@/lib/app/questionnaire/types'; + +const READY = { + questionnaireId: 'qn-1', + versionId: 'v-1', + versionNumber: 1, + goal: 'Understand onboarding friction', + audience: { role: 'Operations' } as AudienceShape, + sectionCount: 1, + questionCount: 3, + configSaved: true, +}; + +async function openDialog() { + await userEvent.click(screen.getByRole('button', { name: /review & launch/i })); +} + +describe('LaunchChecklist', () => { + beforeEach(() => { + mockRefresh.mockReset(); + mockAuthoringMutate.mockReset().mockResolvedValue({ data: {}, meta: null }); + }); + + it('enables Launch and PATCHes status when every criterion passes', async () => { + render(); + await openDialog(); + + const launch = screen.getByRole('button', { name: /^launch$/i }); + expect(launch).toBeEnabled(); + + await userEvent.click(launch); + + await waitFor(() => expect(mockAuthoringMutate).toHaveBeenCalledTimes(1)); + expect(mockAuthoringMutate).toHaveBeenCalledWith( + 'PATCH', + API.APP.QUESTIONNAIRES.versionStatus('qn-1', 'v-1'), + { status: 'launched' } + ); + await waitFor(() => expect(mockRefresh).toHaveBeenCalled()); + }); + + it('disables Launch when the goal is missing', async () => { + render(); + await openDialog(); + + expect(screen.getByRole('button', { name: /^launch$/i })).toBeDisabled(); + expect(screen.getByText(/finish the unchecked items/i)).toBeInTheDocument(); + }); + + it('treats an empty audience object as not ready', async () => { + render(); + await openDialog(); + + expect(screen.getByRole('button', { name: /^launch$/i })).toBeDisabled(); + }); + + it('disables Launch when the config has not been saved', async () => { + render(); + await openDialog(); + + expect(screen.getByRole('button', { name: /^launch$/i })).toBeDisabled(); + }); +}); diff --git a/tests/unit/components/app/questionnaire/chat/questionnaire-chat.test.tsx b/tests/unit/components/app/questionnaire/chat/questionnaire-chat.test.tsx index 10f4c30ae..466d10f11 100644 --- a/tests/unit/components/app/questionnaire/chat/questionnaire-chat.test.tsx +++ b/tests/unit/components/app/questionnaire/chat/questionnaire-chat.test.tsx @@ -111,12 +111,25 @@ describe('QuestionnaireChat', () => { expect(screen.getByText(/Let me think/)).toBeInTheDocument(); }); - it('renders the side-band warning banner', () => { + it('renders a generic side-band warning as a quiet line', () => { hookReturn = makeReturn({ - warning: { code: 'CONTRADICTION', message: 'That differs from your earlier answer.' }, + warning: { code: 'fail_soft', message: 'A detail could not be checked.' }, }); render(); + expect(screen.getByText('A detail could not be checked.')).toBeInTheDocument(); + // Not the contradiction callout. + expect(screen.queryByText(/I noticed something/i)).not.toBeInTheDocument(); + }); + + it('renders a flagged contradiction as the "I noticed something" callout', () => { + // The orchestrator emits a `contradiction`-coded warning whose message is the agent's probe. + hookReturn = makeReturn({ + warning: { code: 'contradiction', message: 'That differs from your earlier answer.' }, + }); + render(); + + expect(screen.getByText(/I noticed something/i)).toBeInTheDocument(); expect(screen.getByText('That differs from your earlier answer.')).toBeInTheDocument(); }); diff --git a/tests/unit/components/app/questionnaire/lifecycle/session-lifecycle-bar.test.tsx b/tests/unit/components/app/questionnaire/lifecycle/session-lifecycle-bar.test.tsx index 09aa4ca2e..fc202cb8a 100644 --- a/tests/unit/components/app/questionnaire/lifecycle/session-lifecycle-bar.test.tsx +++ b/tests/unit/components/app/questionnaire/lifecycle/session-lifecycle-bar.test.tsx @@ -46,11 +46,17 @@ function renderBar(props: Partial { - it('renders nothing when there is nothing to show', () => { - const { container } = renderBar(); + it('renders nothing when there is no status view', () => { + const { container } = renderBar({ view: null }); expect(container.firstChild).toBeNull(); }); + it('shows the coverage progress bar whenever there is a status view', () => { + renderBar(); + const bar = screen.getByRole('progressbar', { name: /questionnaire progress/i }); + expect(bar).toHaveAttribute('aria-valuenow', '90'); + }); + it('shows the anonymous indicator when the session is anonymous', () => { renderBar({ view: view({ anonymous: true }) }); expect(screen.getByText(/responses are anonymous/i)).toBeInTheDocument(); diff --git a/tests/unit/components/app/questionnaire/panel/answer-slot-panel.test.tsx b/tests/unit/components/app/questionnaire/panel/answer-slot-panel.test.tsx index fc76f669b..6a2919b2e 100644 --- a/tests/unit/components/app/questionnaire/panel/answer-slot-panel.test.tsx +++ b/tests/unit/components/app/questionnaire/panel/answer-slot-panel.test.tsx @@ -93,10 +93,15 @@ describe('AnswerSlotPanel', () => { expect(screen.getByText('About you')).toBeInTheDocument(); }); - it('expands an answered slot to reveal its rationale on click', () => { + it('previews the rationale in the collapsed row and expands on click', () => { render(); - expect(screen.queryByText('Stated directly.')).not.toBeInTheDocument(); + // The model's rationale now previews one-line in the collapsed row. + expect(screen.getByText('Stated directly.')).toBeInTheDocument(); + const row = screen.getByText('What is your role?').closest('button'); + expect(row).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(screen.getByText('What is your role?')); + expect(row).toHaveAttribute('aria-expanded', 'true'); + // Full rationale remains visible when expanded. expect(screen.getByText('Stated directly.')).toBeInTheDocument(); });