-
+
title={t("past_symptoms")}
structuredTypes={[
diff --git a/src/components/QuestionnaireV2/README.md b/src/components/QuestionnaireV2/README.md
index 2525e4e1a54..0161878b4d3 100644
--- a/src/components/QuestionnaireV2/README.md
+++ b/src/components/QuestionnaireV2/README.md
@@ -156,7 +156,7 @@ pseudo-questionnaires), `QuestionnaireSearch` (the fill picker state), the
`QuestionTypes/*` structured components — exclusively via
`structured/definitions/*`, whose typed adapters replaced the renderer's
old "one permitted `any`" — and `OrgSelector`. Everything else in that
-directory (`QuestionLabel`, `FieldError`, `EntitySelectionDrawer`,
+directory (`FieldError`, `EntitySelectionDrawer`,
`ValueSetSearchContent`, the response-template sheets) exists only because
those structured components use it; nothing in v2 may import it directly.
A new legacy dependency needs an allowlist entry here, not an ad-hoc
diff --git a/src/components/QuestionnaireV2/manage/questionnaireFormSchema.ts b/src/components/QuestionnaireV2/manage/questionnaireFormSchema.ts
index fc73461842c..052fc5ea1f0 100644
--- a/src/components/QuestionnaireV2/manage/questionnaireFormSchema.ts
+++ b/src/components/QuestionnaireV2/manage/questionnaireFormSchema.ts
@@ -12,7 +12,7 @@ import {
* clone dialog's `-copy` suffix clamp, so a bound change lands everywhere.
*/
export const SLUG_MIN_LENGTH = 5;
-export const SLUG_MAX_LENGTH = 25;
+export const SLUG_MAX_LENGTH = 50;
/**
* The `title`/`slug`/`description`/`status` validation shared by the three
diff --git a/tests/facility/patient/encounter/fill/fillAutosave.spec.ts b/tests/facility/patient/encounter/fill/fillAutosave.spec.ts
index a013cb3aed4..95e7fc9fabf 100644
--- a/tests/facility/patient/encounter/fill/fillAutosave.spec.ts
+++ b/tests/facility/patient/encounter/fill/fillAutosave.spec.ts
@@ -141,6 +141,50 @@ test.describe("Fill page local autosave", () => {
await expect(textInput).toHaveValue("");
});
+ test("Discard drops only the STORED draft — answers typed while the prompt was pending survive it", async ({
+ page,
+ }) => {
+ // Persistence stands down while the restore prompt is unanswered, so
+ // work typed in the meantime exists nowhere but the live store.
+ // Discard used to reset the covered forms to a pristine seed, which
+ // destroyed exactly that un-persisted work on a button that only
+ // promises to drop an old draft.
+ const questionnaireId = await getQuestionnaireIdBySlug(
+ "respiratory_status-v3",
+ );
+ const fillUrl = `/facility/${getFacilityId()}/patient/${getPatientId()}/encounter/${getEncounterId()}/questionnaire/${questionnaireId}`;
+ const staleNote = faker.lorem.sentence();
+ const freshNote = `fresh-${faker.string.alphanumeric(10)}`;
+
+ await page.goto(fillUrl);
+ const textInput = questionBlock(
+ page,
+ "Note on Bilateral Air Entry",
+ ).getByRole("textbox");
+ await textInput.fill(staleNote);
+ await expect(
+ page.getByRole("tab", { name: /Questionnaire/ }),
+ ).toContainText("Draft");
+
+ await page.reload();
+ await expect(page.getByText(/unsaved entry from/i)).toBeVisible();
+
+ // Ignore the prompt and type fresh work.
+ await textInput.fill(freshNote);
+
+ await page.getByRole("button", { name: "Discard", exact: true }).click();
+ await expect(page.getByText(/unsaved entry from/i)).not.toBeVisible();
+ // The fresh work is still on screen…
+ await expect(textInput).toHaveValue(freshNote);
+
+ // …and Discard re-armed persistence immediately, so the fresh work is
+ // itself recoverable: the old draft is gone, the new one restores it.
+ await page.reload();
+ await expect(page.getByText(/unsaved entry from/i)).toBeVisible();
+ await page.getByRole("button", { name: /resume/i }).click();
+ await expect(textInput).toHaveValue(freshNote);
+ });
+
test("an untouched clinical form writes no draft, however much it prefetches", async ({
page,
}) => {
diff --git a/tests/facility/patient/encounter/fill/fillMultiForm.spec.ts b/tests/facility/patient/encounter/fill/fillMultiForm.spec.ts
index 168a641d9ae..026d7ed63c8 100644
--- a/tests/facility/patient/encounter/fill/fillMultiForm.spec.ts
+++ b/tests/facility/patient/encounter/fill/fillMultiForm.spec.ts
@@ -375,4 +375,49 @@ test.describe("Fill page multi-questionnaire sessions", () => {
await expect(page.getByText(noteA)).toBeVisible();
await expect(page.getByText(noteB)).toHaveCount(0);
});
+
+ test("Resume applies a drafted added form even after it was re-added by hand", async ({
+ page,
+ }) => {
+ // The collision case: the clinician re-adds the drafted questionnaire
+ // from the picker BEFORE pressing Resume. addQuestionnaire dedupes by
+ // key, so Resume used to drop the snapshot silently — and the next
+ // persist erased those answers from the stored draft for good. Resume
+ // now merges the snapshot into the already-mounted form, the same
+ // overlay rule the primary form uses.
+ const noteB = `B-${faker.string.alphanumeric(10)}`;
+
+ await questionBlock(page, "Note on Bilateral Air Entry")
+ .getByRole("textbox")
+ .fill(`A-${faker.string.alphanumeric(10)}`);
+ await addQuestionnaire(page, ADDED_TITLE);
+ await questionBlock(page, "Any Suggestions for Improvement")
+ .getByRole("textbox")
+ .fill(noteB);
+ await expect.poll(() => draftFormCount(page)).toBe(2);
+
+ await page.reload();
+ await expect(
+ questionBlock(page, "Is bilateral air entry present?"),
+ ).toBeVisible();
+ await expect(page.getByText(/unsaved entry from/i)).toBeVisible();
+
+ // Re-add form B by hand while the prompt is still up — it mounts
+ // empty.
+ await addQuestionnaire(page, ADDED_TITLE);
+ await expect(page.locator("[data-form-key]")).toHaveCount(2);
+ await expect(
+ questionBlock(page, "Any Suggestions for Improvement").getByRole(
+ "textbox",
+ ),
+ ).toHaveValue("");
+
+ // Resume must land the drafted answers in the already-open form.
+ await page.getByRole("button", { name: /resume/i }).click();
+ await expect(
+ questionBlock(page, "Any Suggestions for Improvement").getByRole(
+ "textbox",
+ ),
+ ).toHaveValue(noteB);
+ });
});
diff --git a/tests/facility/patient/encounter/fill/fillOutlineNav.spec.ts b/tests/facility/patient/encounter/fill/fillOutlineNav.spec.ts
new file mode 100644
index 00000000000..1cd86b4358f
--- /dev/null
+++ b/tests/facility/patient/encounter/fill/fillOutlineNav.spec.ts
@@ -0,0 +1,263 @@
+import type { Locator, Page } from "@playwright/test";
+import { expect, test } from "@playwright/test";
+import {
+ KITCHEN_SINK_FACILITY_SLUG,
+ getQuestionnaireIdBySlug,
+ questionBlock,
+} from "tests/helper/questionnaireV2";
+import { getEncounterId } from "tests/support/encounterId";
+import { getFacilityId } from "tests/support/facilityId";
+import { getPatientId } from "tests/support/patientId";
+
+test.use({ storageState: "tests/.auth/user.json" });
+
+/**
+ * The fill page's outline overlay (reference: Care Master, "Patient
+ * Encounter questionnaire"): a slim tick rail on the canvas' left edge,
+ * and a panel that floats OVER the full-width canvas on hover/click/focus
+ * instead of reserving a column. These specs pin the interaction model
+ * (open/close paths), the scroll-spy, the completion adornments and the
+ * enable_when + multi-form behavior.
+ *
+ * Fixture: e2e-kitchen-sink-facility (encounter subject) — 22 top-level
+ * questions of which 6 are enable_when-hidden by default, one group with
+ * nested children, repeats and a protected question. See the care repo's
+ * questionnaire_e2e_fixtures.py.
+ */
+
+/** Top-level rows visible before any answer: 22 authored minus the 6
+ * enable_when-hidden ones (the `disabled_display: "protected"` question
+ * stays visible). */
+const DEFAULT_VISIBLE_TOP_LEVEL = 16;
+
+function outlineToggle(page: Page): Locator {
+ return page.getByRole("button", { name: "Questions outline" });
+}
+
+function outlinePanel(page: Page): Locator {
+ return page.locator("#fill-outline-panel");
+}
+
+function outlineNav(page: Page): Locator {
+ return page.getByRole("navigation", { name: "Questions" });
+}
+
+function railTicks(page: Page): Locator {
+ return page.locator("[data-question-tick]");
+}
+
+function activeTick(page: Page): Locator {
+ return page.locator("[data-question-tick][data-active]");
+}
+
+async function openOutline(page: Page): Promise {
+ const toggle = outlineToggle(page);
+ if ((await toggle.getAttribute("aria-expanded")) !== "true") {
+ await toggle.click();
+ }
+ await expect(toggle).toHaveAttribute("aria-expanded", "true");
+ await expect(outlinePanel(page)).toHaveCSS("opacity", "1");
+}
+
+test.describe("Fill outline overlay", () => {
+ test.beforeEach(async ({ page }) => {
+ const questionnaireId = await getQuestionnaireIdBySlug(
+ KITCHEN_SINK_FACILITY_SLUG,
+ );
+ await page.goto(
+ `/facility/${getFacilityId()}/patient/${getPatientId()}/encounter/${getEncounterId()}/questionnaire/${questionnaireId}`,
+ );
+ await expect(questionBlock(page, "Primary symptom")).toBeVisible();
+ });
+
+ test("collapsed by default: the canvas takes the full width behind a tick rail", async ({
+ page,
+ }) => {
+ await test.step("the panel starts closed", async () => {
+ await expect(outlineToggle(page)).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ await expect(outlinePanel(page)).toHaveCSS("opacity", "0");
+ await expect(outlinePanel(page)).toHaveCSS("pointer-events", "none");
+ });
+
+ await test.step("no reserved outline column — the canvas starts at the shell's left edge", async () => {
+ const canvas = await page
+ .getByRole("region", { name: "Form canvas" })
+ .boundingBox();
+ // The old fixed aside was 288px wide; the canvas now starts inside
+ // the card border (single-digit-to-few-px offset, not a column).
+ expect(canvas).not.toBeNull();
+ expect(canvas!.x).toBeLessThan(60);
+ });
+
+ await test.step("one tick per visible top-level question", async () => {
+ await expect(railTicks(page)).toHaveCount(DEFAULT_VISIBLE_TOP_LEVEL);
+ // Exactly one tick tracks the question currently in view.
+ await expect(activeTick(page)).toHaveCount(1);
+ });
+ });
+
+ test("click opens the panel; a row scrolls its question into view and takes the active state", async ({
+ page,
+ }) => {
+ await openOutline(page);
+
+ await test.step("rows are numbered and land on their question", async () => {
+ const nav = outlineNav(page);
+ await expect(nav).toBeVisible();
+ const row = nav.getByRole("button", {
+ name: /Medications taken \(repeats\)/,
+ });
+ await expect(row).toContainText("21.");
+ await row.click();
+ await expect(
+ questionBlock(page, "Medications taken (repeats)"),
+ ).toBeInViewport();
+ });
+
+ await test.step("scroll-spy follows: the clicked row becomes current, the rail tick moves with it", async () => {
+ await expect(
+ outlineNav(page).getByRole("button", {
+ name: /Medications taken \(repeats\)/,
+ }),
+ ).toHaveAttribute("aria-current", "true");
+ await expect(activeTick(page)).toHaveCount(1);
+ });
+
+ await test.step("Escape closes and returns focus to the rail", async () => {
+ await page.keyboard.press("Escape");
+ await expect(outlineToggle(page)).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ await expect(outlineToggle(page)).toBeFocused();
+ });
+ });
+
+ test("hover opens; leaving both rail and panel closes after the grace delay", async ({
+ page,
+ }) => {
+ await outlineToggle(page).hover();
+ await expect(outlineToggle(page)).toHaveAttribute("aria-expanded", "true");
+
+ // Crossing from the rail into the panel must not close it.
+ await outlineNav(page)
+ .getByRole("button", { name: /Detailed history/ })
+ .hover();
+ await expect(outlineToggle(page)).toHaveAttribute("aria-expanded", "true");
+
+ // Leaving for the canvas closes it (200ms grace + transition).
+ await page.mouse.move(900, 400);
+ await expect(outlineToggle(page)).toHaveAttribute("aria-expanded", "false");
+ });
+
+ test("focusing the rail opens the panel for keyboard users", async ({
+ page,
+ }) => {
+ await outlineToggle(page).focus();
+ await expect(outlineToggle(page)).toHaveAttribute("aria-expanded", "true");
+ await expect(outlineNav(page)).toBeVisible();
+ });
+
+ test("group children indent under their section and navigate the nested block", async ({
+ page,
+ }) => {
+ await openOutline(page);
+ const nav = outlineNav(page);
+ await expect(
+ nav.getByRole("button", { name: /Examination findings/ }),
+ ).toBeVisible();
+ const child = nav.getByRole("button", { name: /General appearance/ });
+ await expect(child).toContainText("12.1");
+ await child.click();
+ await expect(questionBlock(page, "General appearance")).toBeInViewport();
+ });
+
+ test("completion adornments: answering a question flips its dot to the double-check", async ({
+ page,
+ }) => {
+ await openOutline(page);
+ const row = outlineNav(page).getByRole("button", {
+ name: /Primary symptom/,
+ });
+ await expect(row.locator("svg.lucide-check-check")).toHaveCount(0);
+
+ // The panel overlays the canvas — close it before typing so the click
+ // lands on the input, then reopen to read the adornment.
+ await page.keyboard.press("Escape");
+ await questionBlock(page, "Primary symptom")
+ .getByRole("textbox")
+ .fill("Persistent cough");
+
+ await openOutline(page);
+ await expect(row.locator("svg.lucide-check-check")).toHaveCount(1);
+ });
+
+ test("enable_when: rows and ticks appear exactly when their condition turns true", async ({
+ page,
+ }) => {
+ await openOutline(page);
+ const nav = outlineNav(page);
+ await expect(
+ nav.getByRole("button", { name: /Stability notes/ }),
+ ).toHaveCount(0);
+ await expect(railTicks(page)).toHaveCount(DEFAULT_VISIBLE_TOP_LEVEL);
+
+ await test.step('answer "Is the patient stable?" = Yes', async () => {
+ await page.keyboard.press("Escape");
+ const block = questionBlock(page, "Is the patient stable?");
+ await block.scrollIntoViewIfNeeded();
+ await block.getByRole("radio", { name: "Yes", exact: true }).click();
+ });
+
+ await test.step("the dependent question gains a row and a tick", async () => {
+ await openOutline(page);
+ await expect(
+ nav.getByRole("button", { name: /Stability notes/ }),
+ ).toBeVisible();
+ await expect(railTicks(page)).toHaveCount(DEFAULT_VISIBLE_TOP_LEVEL + 2);
+ });
+ });
+});
+
+test.describe("Fill outline overlay — multi-questionnaire sessions", () => {
+ test("each form contributes its own outline section and rail segment", async ({
+ page,
+ }) => {
+ const questionnaireId = await getQuestionnaireIdBySlug(
+ "respiratory_status-v3",
+ );
+ await page.goto(
+ `/facility/${getFacilityId()}/patient/${getPatientId()}/encounter/${getEncounterId()}/questionnaire/${questionnaireId}`,
+ );
+ await expect(
+ questionBlock(page, "Is bilateral air entry present?"),
+ ).toBeVisible();
+ const primaryTicks = await railTicks(page).count();
+
+ await test.step("add a second questionnaire to the session", async () => {
+ await page.getByRole("button", { name: "Add questionnaire" }).click();
+ await page.getByPlaceholder("Search Forms").fill("Feedback");
+ await page.getByRole("option", { name: /Feedback Form/ }).click();
+ await expect(page.locator("[data-form-key]")).toHaveCount(2);
+ });
+
+ await test.step("the panel stacks one titled nav per form", async () => {
+ await outlineToggle(page).click();
+ // With several forms each outline landmark takes its form's title,
+ // so the stacked navs stay distinguishable to a screen reader.
+ await expect(
+ page.getByRole("navigation", { name: /Respiratory/ }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("navigation", { name: /Feedback Form/ }),
+ ).toBeVisible();
+ });
+
+ await test.step("the rail carries both forms' ticks", async () => {
+ expect(await railTicks(page).count()).toBeGreaterThan(primaryTicks);
+ });
+ });
+});
diff --git a/tests/facility/patient/encounter/fill/fillPage.spec.ts b/tests/facility/patient/encounter/fill/fillPage.spec.ts
index fb642c05c5c..8bf751720f1 100644
--- a/tests/facility/patient/encounter/fill/fillPage.spec.ts
+++ b/tests/facility/patient/encounter/fill/fillPage.spec.ts
@@ -31,8 +31,12 @@ test.describe("Fill page shell", () => {
// Fill routes opt out of the app sidebar (fullscreen shell).
await expect(page.locator('[data-sidebar="sidebar"]')).toHaveCount(0);
- // The ≥lg outline lists the questions with the shared tree nav;
- // selecting a row scrolls its block into view.
+ // The ≥lg outline is an overlay (per the reference): a tick rail on
+ // the canvas' left edge, the panel floats over the canvas on demand.
+ // Selecting a row scrolls its block into view.
+ const toggle = page.getByRole("button", { name: "Questions outline" });
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await toggle.click();
const outline = page.getByRole("navigation", { name: "Questions" });
await expect(outline).toBeVisible();
await outline.getByRole("button", { name: /FiO2/ }).click();
@@ -117,4 +121,52 @@ test.describe("Fill page value serialization", () => {
await expectToast(page, "Questionnaire submitted successfully");
await page.waitForURL(/\/updates$/);
});
+
+ test("a repeats answer whose first row was cleared in place still submits its later rows", async ({
+ page,
+ }) => {
+ // Clearing a repeats row writes `value: undefined` at that index. The
+ // compose gate used to look only at values[0], dropping the WHOLE
+ // answer — later rows silently never submitted while the required
+ // check (which scans every entry) reported the question answered.
+ const questionnaireId = await getQuestionnaireIdBySlug(
+ KITCHEN_SINK_FACILITY_SLUG,
+ );
+ await page.goto(
+ `/facility/${getFacilityId()}/patient/${getPatientId()}/encounter/${getEncounterId()}/questionnaire/${questionnaireId}`,
+ );
+
+ const block = questionBlock(page, "Medications taken (repeats)");
+ await block.scrollIntoViewIfNeeded();
+ await block.getByRole("textbox").first().fill("Paracetamol");
+ await block.getByRole("button", { name: "Add another" }).click();
+ await block.getByRole("textbox").nth(1).fill("Ibuprofen");
+ // Clear the FIRST row in place — the second must still submit.
+ await block.getByRole("textbox").first().fill("");
+
+ const batchRequest = page.waitForRequest(
+ (request) =>
+ request.url().includes("/api/v1/batch_requests/") &&
+ request.method() === "POST",
+ );
+ await page.getByRole("button", { name: "Save Changes" }).click();
+
+ const body = JSON.parse((await batchRequest).postData() ?? "{}") as {
+ requests: {
+ url: string;
+ body: { results?: { values: { value?: string }[] }[] };
+ }[];
+ };
+ const submit = body.requests.find((request) =>
+ request.url.includes(`/questionnaire/${questionnaireId}/submit/`),
+ );
+ const submittedValues = (submit?.body.results ?? []).flatMap((result) =>
+ result.values.map((value) => value.value),
+ );
+ expect(submittedValues).toContain("Ibuprofen");
+ expect(submittedValues).not.toContain("Paracetamol");
+
+ await expectToast(page, "Questionnaire submitted successfully");
+ await page.waitForURL(/\/updates$/);
+ });
});
diff --git a/tests/facility/patient/encounter/fill/fillServerDraft.spec.ts b/tests/facility/patient/encounter/fill/fillServerDraft.spec.ts
index 7df37b39274..6d04b8042f0 100644
--- a/tests/facility/patient/encounter/fill/fillServerDraft.spec.ts
+++ b/tests/facility/patient/encounter/fill/fillServerDraft.spec.ts
@@ -1,6 +1,8 @@
import { faker } from "@faker-js/faker";
import { type Page, expect, test } from "@playwright/test";
import {
+ adminApiHeaders,
+ apiBaseUrl,
getQuestionnaireIdBySlug,
questionBlock,
} from "tests/helper/questionnaireV2";
@@ -202,4 +204,47 @@ test.describe("Fill page server draft", () => {
page.getByRole("heading", { name: "Draft Forms" }),
).not.toBeVisible();
});
+
+ test("a form_submission that is no longer a draft refuses to resume", async ({
+ page,
+ }) => {
+ // Not gated on the Save-as-Draft flag: the record is created straight
+ // through the API, and the ?continue_draft= resume path always exists.
+ // A SUBMITTED record re-opening as an editable draft would let one
+ // submission file twice — the URL is shareable and outlives the
+ // overview card's own status filter.
+ const questionnaireId = await getQuestionnaireIdBySlug(
+ "respiratory_status-v3",
+ );
+ const response = await fetch(`${apiBaseUrl()}/api/v1/form_submission/`, {
+ method: "POST",
+ headers: adminApiHeaders(),
+ body: JSON.stringify({
+ // The create serializer resolves the questionnaire by SLUG.
+ questionnaire: "respiratory_status-v3",
+ patient: getPatientId(),
+ encounter: getEncounterId(),
+ status: "submitted",
+ response_dump: {
+ questionnaireResponses: {
+ questionnaire: { id: questionnaireId },
+ responses: [],
+ errors: [],
+ },
+ },
+ }),
+ });
+ expect(response.ok, "fixture form_submission POST failed").toBe(true);
+ const submission = (await response.json()) as { id: string };
+
+ await page.goto(
+ `/facility/${getFacilityId()}/patient/${getPatientId()}/encounter/${getEncounterId()}/questionnaire/${questionnaireId}?continue_draft=${submission.id}`,
+ );
+
+ // The dead-end error page, not an editable form.
+ await expect(page.getByText("Draft cannot be recovered")).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Save Changes" }),
+ ).toHaveCount(0);
+ });
});
diff --git a/tests/facility/patient/encounter/structuredQuestions/structuredRendering.spec.ts b/tests/facility/patient/encounter/structuredQuestions/structuredRendering.spec.ts
new file mode 100644
index 00000000000..483af04dd0c
--- /dev/null
+++ b/tests/facility/patient/encounter/structuredQuestions/structuredRendering.spec.ts
@@ -0,0 +1,98 @@
+import type { Page } from "@playwright/test";
+import { expect, test } from "@playwright/test";
+import { questionBlock } from "tests/helper/questionnaireV2";
+import { getEncounterId } from "tests/support/encounterId";
+import { getFacilityId } from "tests/support/facilityId";
+import { getPatientId } from "tests/support/patientId";
+
+test.use({ storageState: "tests/.auth/user.json" });
+
+/**
+ * Rendering/layout smoke coverage for EVERY core structured question type
+ * on the encounter fill mount, via the fixed pseudo-questionnaires
+ * (`FIXED_QUESTIONNAIRES` in StructuredFormData). The per-type CRUD flows
+ * live in the sibling specs (symptom/diagnosis/allergy/…); this spec pins
+ * what those don't: that each type's widget actually MOUNTS an input on
+ * the v2 fill page, renders exactly one question header (the renderer's —
+ * the widgets' internal QuestionLabel duplicated it and sat misaligned
+ * beside it), and doesn't break the canvas layout.
+ */
+const STRUCTURED_FIXED_FORMS: { slug: string; text: string }[] = [
+ { slug: "symptom", text: "Symptom" },
+ { slug: "diagnosis", text: "Diagnosis" },
+ { slug: "allergy_intolerance", text: "Allergy Intolerance" },
+ { slug: "medication_request", text: "Medication Request" },
+ { slug: "medication_statement", text: "Medication Statement" },
+ { slug: "service_request", text: "Service Request" },
+ { slug: "encounter", text: "Encounter" },
+ { slug: "files", text: "Files" },
+ { slug: "time_of_death", text: "Time of Death" },
+ { slug: "charge_item", text: "Charge Item" },
+ { slug: "appointment", text: "Appointment" },
+];
+
+/** The slot's degradation notices — none of them may show for a core type
+ * on its own encounter mount. */
+const DEGRADATION_NOTICES = [
+ /requires a plugin that isn't enabled/,
+ /couldn't be displayed\. Reload the page/,
+ /available when filling the form with/,
+ /can't be used on a .* questionnaire/,
+];
+
+async function expectNoHorizontalOverflow(page: Page) {
+ const overflow = await page.evaluate(() => {
+ const canvas = document.querySelector(
+ 'section[aria-label="Form canvas"]',
+ );
+ if (!canvas) return null;
+ return canvas.scrollWidth - canvas.clientWidth;
+ });
+ expect(overflow, "form canvas must not scroll horizontally").not.toBeNull();
+ // Sub-pixel rounding tolerance.
+ expect(overflow!).toBeLessThanOrEqual(2);
+}
+
+test.describe("Structured question rendering on the fill page", () => {
+ for (const { slug, text } of STRUCTURED_FIXED_FORMS) {
+ test(`${slug}: widget mounts with one aligned header and no layout break`, async ({
+ page,
+ }) => {
+ await page.goto(
+ `/facility/${getFacilityId()}/patient/${getPatientId()}/encounter/${getEncounterId()}/questionnaire/${slug}`,
+ );
+
+ const block = questionBlock(page, text);
+ await expect(block).toBeVisible();
+
+ await test.step("no degradation notice — the real input mounted", async () => {
+ for (const notice of DEGRADATION_NOTICES) {
+ await expect(block.getByText(notice)).toHaveCount(0);
+ }
+ // The widget put SOMETHING interactive on screen. Waiting on this
+ // also settles the lazy/suspense mount before the checks below.
+ // `:visible` matters: FileQuestion's first input is a deliberately
+ // hidden file input behind a styled trigger.
+ await expect(
+ block
+ .locator(
+ "button:visible, input:visible, textarea:visible, [role='combobox']:visible",
+ )
+ .first(),
+ ).toBeVisible();
+ });
+
+ await test.step("exactly one question header", async () => {
+ // The renderer's label is the only one; the widgets' internal
+ // QuestionLabel used to render a second, misaligned copy.
+ await expect(
+ block.locator("label").filter({ hasText: new RegExp(`^${text}$`) }),
+ ).toHaveCount(1);
+ });
+
+ await test.step("layout holds", async () => {
+ await expectNoHorizontalOverflow(page);
+ });
+ });
+ }
+});
diff --git a/tests/facility/patient/fill/fillPatientSubject.spec.ts b/tests/facility/patient/fill/fillPatientSubject.spec.ts
index c44c58a8e26..72156c52b26 100644
--- a/tests/facility/patient/fill/fillPatientSubject.spec.ts
+++ b/tests/facility/patient/fill/fillPatientSubject.spec.ts
@@ -27,7 +27,11 @@ test.describe("Patient-subject questionnaire fill", () => {
test.slow();
const facilityId = getFacilityId();
const patientId = getPatientId();
- const title = `QV2 Patient Subject ${Date.now()}`;
+ // The auto-slug truncates at 25 chars, so the unique part must sit
+ // INSIDE that window — `QV2 Patient Subject ${Date.now()}` sheared to
+ // "qv2-patient-subject-17857" and collided with the record a previous
+ // run left in the DB snapshot (the backend 500s on a duplicate slug).
+ const title = `QV2 PtSubj ${faker.string.alphanumeric(10)}`;
const questionTitle = `Note ${faker.string.alphanumeric(6)}`;
const answer = `Pt-${faker.string.alphanumeric(10)}`;