From a1397a1f3a4bc9266573f2ee776db72fa7bd58c2 Mon Sep 17 00:00:00 2001 From: Chi Bong Ho Date: Tue, 4 Aug 2026 09:40:25 -0400 Subject: [PATCH] UHM-9335: add E2E coverage for the lab orders workflow --- e2e/commands/location-operations.ts | 10 + e2e/commands/patient-operations.ts | 4 +- e2e/commands/visit-operations.ts | 8 +- e2e/core/constants.ts | 1 + e2e/core/global-setup.ts | 1 - e2e/core/test.ts | 8 +- e2e/pages/o2/labs/add-lab-orders-page.ts | 105 +++++ .../o2/labs/add-lab-orders-search-page.ts | 51 +++ e2e/pages/o2/labs/index.ts | 5 + e2e/pages/o2/labs/lab-orders-page.ts | 405 ++++++++++++++++++ e2e/pages/o2/labs/patient-lab-orders-page.ts | 114 +++++ e2e/pages/o2/labs/patient-lab-results-page.ts | 42 ++ e2e/pages/o2/patient-chart-page.ts | 53 +++ e2e/specs/labs/lab-orders.spec.ts | 148 +++++++ .../src/ward-app/inborn-outborn-tag-row.scss | 1 - 15 files changed, 944 insertions(+), 12 deletions(-) create mode 100644 e2e/pages/o2/labs/add-lab-orders-page.ts create mode 100644 e2e/pages/o2/labs/add-lab-orders-search-page.ts create mode 100644 e2e/pages/o2/labs/index.ts create mode 100644 e2e/pages/o2/labs/lab-orders-page.ts create mode 100644 e2e/pages/o2/labs/patient-lab-orders-page.ts create mode 100644 e2e/pages/o2/labs/patient-lab-results-page.ts create mode 100644 e2e/pages/o2/patient-chart-page.ts create mode 100644 e2e/specs/labs/lab-orders.spec.ts diff --git a/e2e/commands/location-operations.ts b/e2e/commands/location-operations.ts index 21d3ba9..51a0407 100644 --- a/e2e/commands/location-operations.ts +++ b/e2e/commands/location-operations.ts @@ -4,6 +4,16 @@ import { type APIRequestContext, expect } from '@playwright/test'; * Mostly taken from openmrs-esm-patient-management */ +/** + * Sets the session location. + * + * Note this also covers the browser, not just the `api` fixture: the fixture's request context + * inherits `use.storageState` from the Playwright config, so it loads the same JSESSIONID as the + * browser context and the two share one server-side session. That matters because pihapps' + * RequireLoginLocationFilter is mapped to every URL and redirects to loginLocation.page unless the + * requesting session has a location -- so calling this is what lets `page.goto` reach a legacy + * .page URL at all. + */ export const changeLocation = async (api: APIRequestContext, locationUuid: string) => { const locationRes = await api.post('session', { data: { diff --git a/e2e/commands/patient-operations.ts b/e2e/commands/patient-operations.ts index e44836e..d157058 100644 --- a/e2e/commands/patient-operations.ts +++ b/e2e/commands/patient-operations.ts @@ -72,7 +72,7 @@ function getRandomBirthday(profile: PatientProfile): string { export const generateRandomPatient = async ( api: APIRequestContext, profile: PatientProfile, - locationUuid?: string, + locationUuid: string, ): Promise => { const identifierRes = await api.post(`idgen/identifiersource/${KGHEmrIdSourceUuid}/identifier`, { data: {}, @@ -89,7 +89,7 @@ export const generateRandomPatient = async ( { identifier, identifierType: KGHEmrIdTypeUuid, - location: locationUuid || process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID, + location: locationUuid, preferred: true, }, ], diff --git a/e2e/commands/visit-operations.ts b/e2e/commands/visit-operations.ts index 6cc3c0c..e5d3999 100644 --- a/e2e/commands/visit-operations.ts +++ b/e2e/commands/visit-operations.ts @@ -7,12 +7,12 @@ import { KGHVisitType } from '../core'; * Mostly taken from openmrs-esm-patient-management */ -export const startVisit = async (api: APIRequestContext, patientId: string, locationUuid?: string): Promise => { +export const startVisit = async (api: APIRequestContext, patientId: string, locationUuid: string): Promise => { const visitRes = await api.post('visit', { data: { startDatetime: dayjs().format('YYYY-MM-DDTHH:mm:ss.SSSZZ'), patient: patientId, - location: locationUuid || process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID, + location: locationUuid, visitType: KGHVisitType, attributes: [], }, @@ -22,10 +22,10 @@ export const startVisit = async (api: APIRequestContext, patientId: string, loca return await visitRes.json(); }; -export const endVisit = async (api: APIRequestContext, uuid: string, isWardTest = false) => { +export const endVisit = async (api: APIRequestContext, uuid: string, locationUuid: string) => { await api.post(`visit/${uuid}`, { data: { - location: isWardTest ? process.env.E2E_WARD_LOCATION_UUID : process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID, + location: locationUuid, visitType: KGHVisitType, stopDatetime: dayjs().format('YYYY-MM-DDTHH:mm:ss.SSSZZ'), }, diff --git a/e2e/core/constants.ts b/e2e/core/constants.ts index a064f73..324a4ae 100644 --- a/e2e/core/constants.ts +++ b/e2e/core/constants.ts @@ -4,6 +4,7 @@ export const KGHLocationsUuids = { 'Antenatal Ward': '11f5c9f9-40b8-46ad-9e7e-59473ce43246', KGH: '074b2ab0-716a-11eb-8aa6-0242ac110002', 'Maternity Pharmacy': '84b9b680-786c-4388-9e7c-805614c13b5a', + Laboratory: '0d3704ab-17f7-4272-aa1b-9118ae51acce', }; export const KGHEmrIdSourceUuid = '809b23e3-7162-11eb-8aa6-0242ac110002'; diff --git a/e2e/core/global-setup.ts b/e2e/core/global-setup.ts index 483552b..8b19ee9 100644 --- a/e2e/core/global-setup.ts +++ b/e2e/core/global-setup.ts @@ -21,7 +21,6 @@ async function globalSetup() { ); const res = await requestContext.post(`${process.env.E2E_BASE_URL}/ws/rest/v1/session`, { data: { - sessionLocation: process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID, locale: 'en', }, headers: { diff --git a/e2e/core/test.ts b/e2e/core/test.ts index 257e59d..c8cadd9 100644 --- a/e2e/core/test.ts +++ b/e2e/core/test.ts @@ -31,7 +31,7 @@ export const test = base.extend({ api: [api, { scope: 'worker' }], adultWoman: [ async ({ api }, use) => { - const patient = await generateRandomPatient(api, 'adultWomen'); + const patient = await generateRandomPatient(api, 'adultWomen', KGHLocationsUuids.KGH); await use(patient); await deletePatient(api, patient.uuid); }, @@ -41,7 +41,7 @@ export const test = base.extend({ async ({ api, adultWoman }, use) => { const visit = await startVisit(api, adultWoman.uuid, KGHLocationsUuids.KGH); await use(visit); - await endVisit(api, visit.uuid); + await endVisit(api, visit.uuid, KGHLocationsUuids.KGH); }, { scope: 'test' }, ], @@ -58,7 +58,7 @@ export const test = base.extend({ async ({ api, newborn }, use) => { const visit = await startVisit(api, newborn.uuid, KGHLocationsUuids.KGH); await use(visit); - await endVisit(api, visit.uuid); + await endVisit(api, visit.uuid, KGHLocationsUuids.KGH); }, { scope: 'test' }, ], @@ -75,7 +75,7 @@ export const test = base.extend({ async ({ api, newborn2 }, use) => { const visit = await startVisit(api, newborn2.uuid, KGHLocationsUuids.KGH); await use(visit); - await endVisit(api, visit.uuid); + await endVisit(api, visit.uuid, KGHLocationsUuids.KGH); }, { scope: 'test' }, ], diff --git a/e2e/pages/o2/labs/add-lab-orders-page.ts b/e2e/pages/o2/labs/add-lab-orders-page.ts new file mode 100644 index 0000000..3e0cd9d --- /dev/null +++ b/e2e/pages/o2/labs/add-lab-orders-page.ts @@ -0,0 +1,105 @@ +import { expect, type Page } from '@playwright/test'; +import { type Patient } from '@openmrs/esm-framework'; +import { step, test } from '../../../core'; + +/** + * The "Add Lab orders" page for a single patient (pihapps labOrder.page). + * + * Every test category is rendered into the DOM at once and only the active one is shown, so the + * lab test buttons are looked up inside the visible category pane. The first category + * (Haematology) activates itself on load. + */ +export class O2AddLabOrdersPage { + private constructor(readonly page: Page) {} + + readonly saveButton = () => this.page.locator('#draft-save-button'); + readonly discardAllButton = () => this.page.locator('#draft-discard-all'); + readonly returnButton = () => this.page.locator('#cancel-button'); + readonly draftOrderCount = () => this.page.locator('#num-draft-orders'); + readonly draftOrders = () => this.page.locator('#draft-list-container li.draft-list'); + + categoryTab(categoryName: string) { + return this.page.locator('a.category-link').filter({ hasText: categoryName }); + } + + /** + * Panel buttons wrap a hover tooltip listing their member tests, so the button's text is more + * than just its label -- hasText (substring) handles that, where an exact-name role lookup + * would not. Scoped to the visible pane so that a name reused in another category can't match. + */ + labTestButton(labTestName: string) { + return this.page.locator('.lab-selection-form:visible button.lab-tests-btn').filter({ hasText: labTestName }); + } + + draftOrder(labTestName: string) { + return this.draftOrders().filter({ hasText: labTestName }); + } + + @step + static async open(page: Page, patient: Patient, returnUrl?: string) { + return test.step(`When I navigate to the add lab orders page for patient ${patient.uuid}`, async () => { + const addLabOrdersPage = new O2AddLabOrdersPage(page); + const returnUrlParam = returnUrl ? `&returnUrl=${encodeURIComponent(returnUrl)}` : ''; + await page.goto( + `${process.env.E2E_BASE_URL}/pihapps/labs/labOrder.page?patient=${patient.uuid}${returnUrlParam}`, + ); + await expect(addLabOrdersPage.saveButton()).toBeVisible(); + return addLabOrdersPage; + }); + } + + /** For when we already navigated here (e.g. by selecting a patient from the search page). */ + @step + static async at(page: Page) { + return test.step('When I land on the add lab orders page', async () => { + const addLabOrdersPage = new O2AddLabOrdersPage(page); + await expect(addLabOrdersPage.saveButton()).toBeVisible(); + return addLabOrdersPage; + }); + } + + @step + async selectCategory(categoryName: string) { + return test.step(`When I select the "${categoryName}" lab category`, async () => { + await this.categoryTab(categoryName).click(); + await expect(this.categoryTab(categoryName)).toHaveClass(/active-category/); + }); + } + + @step + async selectLabTest(labTestName: string) { + return test.step(`When I select the "${labTestName}" lab test`, async () => { + await this.labTestButton(labTestName).click(); + await expect(this.draftOrder(labTestName)).toBeVisible(); + }); + } + + /** + * Saves the drafted orders. On success the page immediately redirects to its returnUrl, which + * tears down the "Order Successfully Created" toast before it can reliably be asserted on -- so + * leaving this page is what we wait for instead. + */ + @step + async save() { + return test.step('When I save the lab orders', async () => { + await this.saveButton().click(); + await this.page.waitForURL((url) => !url.pathname.endsWith('labOrder.page')); + }); + } + + @step + async expectDraftOrderCount(count: number) { + return test.step(`Then I should see ${count} draft order(s)`, async () => { + await expect(this.draftOrderCount()).toHaveText(String(count)); + await expect(this.draftOrders()).toHaveCount(count); + }); + } + + @step + async expectOrdersSaved() { + return test.step('Then the orders should have been saved', async () => { + await expect(this.page).not.toHaveURL(/labOrder\.page/); + await expect(this.saveButton()).toHaveCount(0); + }); + } +} diff --git a/e2e/pages/o2/labs/add-lab-orders-search-page.ts b/e2e/pages/o2/labs/add-lab-orders-search-page.ts new file mode 100644 index 0000000..d3c4d65 --- /dev/null +++ b/e2e/pages/o2/labs/add-lab-orders-search-page.ts @@ -0,0 +1,51 @@ +import { expect, type Page } from '@playwright/test'; +import { type Patient } from '@openmrs/esm-framework'; +import { getPatientIdentifierStr } from '../../../commands'; +import { step, test } from '../../../core'; +import { O2AddLabOrdersPage } from './add-lab-orders-page'; + +/** + * The find-patient screen that fronts lab ordering + * (coreapps findPatient.page for the pih.app.labs.ordering app). + */ +export class O2AddLabOrdersSearchPage { + private constructor(readonly page: Page) {} + + readonly searchByIdOrNameField = () => this.page.getByPlaceholder('Search by ID or Name'); + readonly searchResultsTable = () => this.page.locator('#patient-search-results-table'); + + searchResultRow(patientIdentifier: string) { + return this.searchResultsTable().locator('tbody tr').filter({ hasText: patientIdentifier }); + } + + @step + static async open(page: Page) { + return test.step('When I navigate to the add lab orders patient search page', async () => { + const searchPage = new O2AddLabOrdersSearchPage(page); + await page.goto(`${process.env.E2E_BASE_URL}/coreapps/findpatient/findPatient.page?app=pih.app.labs.ordering`); + await expect(searchPage.searchByIdOrNameField()).toBeVisible(); + return searchPage; + }); + } + + /** + * Searches by EMR ID and lands on the add lab orders page for that patient. + * + * Typing alone issues no request -- the widget searches on Enter. And on an exact identifier + * match it does not populate the results table at all: it looks the patient up + * (GET /ws/rest/v1/patient?identifier=...) and navigates straight to the app's afterSelectedUrl. + * So there is no row to click in this flow; `searchResultRow` is there for a name search, which + * does render rows. + */ + @step + async searchForPatientByIdentifier(patient: Patient) { + const patientIdentifier = getPatientIdentifierStr(patient); + await test.step(`When I search for patient "${patientIdentifier}" by EMR ID`, async () => { + await this.searchByIdOrNameField().click(); + await this.searchByIdOrNameField().pressSequentially(patientIdentifier); + await this.searchByIdOrNameField().press('Enter'); + await expect(this.page).toHaveURL(/labOrder\.page/); + }); + return O2AddLabOrdersPage.at(this.page); + } +} diff --git a/e2e/pages/o2/labs/index.ts b/e2e/pages/o2/labs/index.ts new file mode 100644 index 0000000..9833763 --- /dev/null +++ b/e2e/pages/o2/labs/index.ts @@ -0,0 +1,5 @@ +export * from './add-lab-orders-page'; +export * from './add-lab-orders-search-page'; +export * from './lab-orders-page'; +export * from './patient-lab-orders-page'; +export * from './patient-lab-results-page'; diff --git a/e2e/pages/o2/labs/lab-orders-page.ts b/e2e/pages/o2/labs/lab-orders-page.ts new file mode 100644 index 0000000..c9ed0c9 --- /dev/null +++ b/e2e/pages/o2/labs/lab-orders-page.ts @@ -0,0 +1,405 @@ +import { expect, type Page } from '@playwright/test'; +import { type Patient } from '@openmrs/esm-framework'; +import { getPatientIdentifierStr } from '../../../commands'; +import { step, test } from '../../../core'; + +/** Status as rendered in the order table's Status column. */ +export type LabOrderStatus = 'Ordered' | 'Collected' | 'Reported' | 'Not Performed' | 'Canceled' | 'Expired'; + +/** Aggregate status badge rendered against a patient group row. */ +export type LabPatientStatus = + | 'All Ordered' + | 'All Collected' + | 'All Reported' + | 'All Not Performed' + | 'All Canceled' + | 'All Expired' + | 'Mixed'; + +/** The Lab Status buttons offered by the record-results form. */ +export type LabFulfillerStatus = 'In Progress' | 'Completed' | 'Not performed'; + +/** + * Every fulfillment status the list can filter on. The page defaults to showing only Ordered + + * Collected, and it re-queries after every save -- so without widening this, a row that reaches a + * terminal status silently drops out of the table. + */ +const ALL_FULFILLMENT_STATUSES = [ + 'AWAITING_FULFILLMENT', + 'IN_FULFILLMENT', + 'COMPLETED_FULFILLMENT', + 'UNABLE_TO_COMPLETE_FULFILLMENT', + 'EXPIRED_BEFORE_FULFILLMENT', + 'CANCELLED_BEFORE_FULFILLMENT', +]; + +/** + * The lab orders worklist (pihapps labOrderList.page). + * + * This is a single page holding four sibling sections that are shown and hidden with jQuery; the + * URL never changes as you move between them, so assertions key on section visibility rather than + * on page.url(). The three data-entry sections are modelled as nested classes. + */ +export class O2LabOrdersPage { + private constructor(readonly page: Page) {} + + // ── Sections ────────────────────────────────────────────────────────────── + readonly viewOrdersSection = () => this.page.locator('#view-orders-section'); + readonly specimenCollectionSection = () => this.page.locator('#edit-specimen-encounter-section'); + readonly recordResultsSection = () => this.page.locator('#record-lab-results-section'); + readonly notPerformedSection = () => this.page.locator('#edit-reason-not-performed-section'); + + // ── List view ───────────────────────────────────────────────────────────── + readonly groupByOrderButton = () => this.page.locator('#group-by-order-btn'); + readonly groupByPatientButton = () => this.page.locator('#group-by-patient-btn'); + readonly addLabOrdersLink = () => this.page.getByRole('link', { name: 'Add Lab orders' }); + readonly ordersTable = () => this.page.locator('#orders-table'); + readonly patientsTable = () => this.page.locator('#patients-table'); + readonly patientFilter = () => this.page.locator('#patient-filter-display'); + + /** + * A row in "Group by: Order" mode. Rows carry no id or data attribute, so they are found by the + * lab test name in the Lab Test column (9th cell). + */ + orderRow(labTestName: string) { + return this.ordersTable() + .locator('tbody tr') + .filter({ has: this.page.locator('td:nth-child(9)', { hasText: labTestName }) }); + } + + orderRowStatus(labTestName: string) { + return this.orderRow(labTestName).locator('td:nth-child(8)'); + } + + patientGroupRow(patient: Patient) { + return this.patientsTable().locator(`tr.patient-group-row[data-patient-uuid="${patient.uuid}"]`); + } + + patientSubRow(patient: Patient, labTestName: string) { + return this.patientsTable() + .locator(`tr.patient-sub-row.patient-sub-row-${patient.uuid}`) + .filter({ hasText: labTestName }); + } + + @step + static async open(page: Page) { + return test.step('When I navigate to the lab orders page', async () => { + const labOrdersPage = new O2LabOrdersPage(page); + // Deep-link every status: the page reads repeated `status` params on load, and the default + // (Ordered + Collected) would hide any order that reached a terminal status. + const statusParams = ALL_FULFILLMENT_STATUSES.map((status) => `status=${status}`).join('&'); + await page.goto(`${process.env.E2E_BASE_URL}/pihapps/labs/labOrderList.page?${statusParams}`); + await expect(labOrdersPage.viewOrdersSection()).toBeVisible(); + await labOrdersPage.waitForOrdersTable(); + return labOrdersPage; + }); + } + + /** + * The table is cleared and repopulated with a spinner placeholder row on every refresh, so wait + * for rows to exist and then for the spinners to be gone. + */ + private async waitForOrdersTable() { + await expect(this.ordersTable().locator('tbody tr')).not.toHaveCount(0); + await expect(this.ordersTable().locator('tbody i.icon-spinner')).toHaveCount(0); + } + + private async waitForPatientsTable() { + await expect(this.patientsTable().locator('tbody tr')).not.toHaveCount(0); + await expect(this.patientsTable().locator('tbody i.icon-spinner')).toHaveCount(0); + } + + /** + * Narrows the (global, paginated) list to one patient. Driven through the jQuery UI autocomplete + * the way a user would: typing character by character, since fill() would not trigger it. + */ + @step + async filterByPatient(patient: Patient) { + const patientIdentifier = getPatientIdentifierStr(patient); + return test.step(`When I filter the lab orders by patient "${patientIdentifier}"`, async () => { + await this.patientFilter().click(); + await this.patientFilter().pressSequentially(patientIdentifier); + const suggestion = this.page.locator('ul.ui-autocomplete li').first(); + await suggestion.waitFor({ state: 'visible' }); + await suggestion.click(); + await this.waitForOrdersTable(); + }); + } + + @step + async switchToGroupByPatient() { + return test.step('When I switch to "Group by: Patient"', async () => { + await this.groupByPatientButton().click(); + await expect(this.groupByPatientButton()).toHaveClass(/active/); + await this.waitForPatientsTable(); + }); + } + + @step + async switchToGroupByOrder() { + return test.step('When I switch to "Group by: Order"', async () => { + await this.groupByOrderButton().click(); + await expect(this.groupByOrderButton()).toHaveClass(/active/); + await this.waitForOrdersTable(); + }); + } + + @step + async expandPatientRow(patient: Patient) { + return test.step('When I expand the patient row', async () => { + await this.patientGroupRow(patient).locator('td:nth-child(1) span').click(); + await expect(this.patientsTable().locator(`tr.patient-sub-row-${patient.uuid}`).first()).toBeVisible(); + }); + } + + // ── Row actions ─────────────────────────────────────────────────────────── + // These are bare icons with no role and no accessible name beyond `title`. + + @step + async collectSpecimen(labTestName: string) { + await test.step(`When I collect the specimen for "${labTestName}"`, async () => { + await this.orderRow(labTestName).locator('i.collect-specimen-action').click(); + await expect(this.specimenCollectionSection()).toBeVisible(); + }); + return new O2LabOrdersPage.SpecimenCollectionForm(this.page, this); + } + + /** Bulk specimen collection: the only entry point is the patient group row in patient mode. */ + @step + async collectSpecimenForPatient(patient: Patient) { + await test.step("When I collect specimens for all of the patient's orders", async () => { + await this.patientGroupRow(patient).locator('i.collect-specimen-group-action').click(); + await expect(this.specimenCollectionSection()).toBeVisible(); + }); + return new O2LabOrdersPage.SpecimenCollectionForm(this.page, this); + } + + @step + async recordResults(labTestName: string) { + await test.step(`When I open the results form for "${labTestName}"`, async () => { + await this.orderRow(labTestName).locator('i.enter-results-action').click(); + await expect(this.recordResultsSection()).toBeVisible(); + }); + return new O2LabOrdersPage.RecordResultsForm(this.page, this); + } + + @step + async markAsNotCollected(labTestName: string) { + await test.step(`When I mark "${labTestName}" as not collected`, async () => { + await this.orderRow(labTestName).locator('i.mark-not-performed-action').click(); + await expect(this.notPerformedSection()).toBeVisible(); + }); + return new O2LabOrdersPage.NotPerformedForm(this.page, this); + } + + // ── Assertions ──────────────────────────────────────────────────────────── + + @step + async expectOrderCount(count: number) { + return test.step(`Then I should see ${count} lab order(s) in the table`, async () => { + await expect(this.ordersTable().locator('tbody tr')).toHaveCount(count); + }); + } + + @step + async expectOrderStatus(labTestName: string, status: LabOrderStatus) { + return test.step(`Then "${labTestName}" should have status "${status}"`, async () => { + await expect(this.orderRow(labTestName)).toBeVisible(); + await expect(this.orderRowStatus(labTestName)).toContainText(status); + }); + } + + @step + async expectOrderStatuses(statusesByLabTest: Record) { + return test.step('Then I should see the expected order statuses', async () => { + for (const [labTestName, status] of Object.entries(statusesByLabTest)) { + await expect(this.orderRowStatus(labTestName)).toContainText(status); + } + }); + } + + @step + async expectPatientStatus(patient: Patient, status: LabPatientStatus) { + return test.step(`Then the patient's orders should show status "${status}"`, async () => { + await expect(this.patientGroupRow(patient).locator('td:nth-child(3)')).toHaveText(status); + }); + } + + @step + async expectPatientSubRowStatus(patient: Patient, labTestName: string, status: LabOrderStatus) { + return test.step(`Then "${labTestName}" should show status "${status}" under the patient`, async () => { + await expect(this.patientSubRow(patient, labTestName).locator('td:nth-child(2)')).toContainText(status); + }); + } + + @step + async expectBackOnOrdersList() { + return test.step('Then I should be back on the lab orders list', async () => { + await expect(this.viewOrdersSection()).toBeVisible(); + await expect(this.specimenCollectionSection()).toBeHidden(); + await expect(this.recordResultsSection()).toBeHidden(); + await expect(this.notPerformedSection()).toBeHidden(); + }); + } + + // ── Sub-view: specimen collection ───────────────────────────────────────── + + static SpecimenCollectionForm = class SpecimenCollectionForm { + constructor( + private page: Page, + readonly labOrdersPage: O2LabOrdersPage, + ) {} + + private readonly section = () => this.page.locator('#specimen-encounter-section'); + + readonly labIdField = () => this.page.locator('#specimen-encounter-section-lab-id-input'); + readonly specimenCollectionLocation = () => + this.page.locator('#specimen-encounter-section-specimen-location-picker'); + readonly saveButton = () => this.section().locator('button.confirm.action-button'); + readonly cancelButton = () => this.section().locator('button.cancel.action-button'); + readonly errors = () => this.section().locator('.errors-section'); + + /** Accessible name comes from aria-label, which is the lab test's display string. */ + orderCheckbox(labTestName: string) { + return this.section().getByRole('checkbox', { name: labTestName }); + } + + @step + async waitUntilReady() { + await expect(this.section().locator('.form-content-section')).toBeVisible(); + await expect(this.labIdField()).toBeVisible(); + } + + /** + * Specimen collection date, received date and collection location are all either pre-filled or + * optional, so the Lab ID is the only field the tests need to set. + */ + @step + async fillLabId(labId: string) { + return test.step(`When I enter the lab ID "${labId}"`, async () => { + await this.labIdField().fill(labId); + }); + } + + @step + async uncheckOrder(labTestName: string) { + return test.step(`When I deselect "${labTestName}" from the specimen collection`, async () => { + await this.orderCheckbox(labTestName).uncheck(); + }); + } + + @step + async expectOrderSelected(labTestName: string, selected: boolean) { + return test.step(`Then "${labTestName}" should be ${selected ? 'selected' : 'deselected'}`, async () => { + if (selected) { + await expect(this.orderCheckbox(labTestName)).toBeChecked(); + } else { + await expect(this.orderCheckbox(labTestName)).not.toBeChecked(); + } + }); + } + + @step + async save() { + await test.step('When I save the specimen collection', async () => { + await this.saveButton().click(); + await expect(this.labOrdersPage.specimenCollectionSection()).toBeHidden(); + }); + return this.labOrdersPage; + } + }; + + // ── Sub-view: record results ────────────────────────────────────────────── + + static RecordResultsForm = class RecordResultsForm { + constructor( + private page: Page, + readonly labOrdersPage: O2LabOrdersPage, + ) {} + + private readonly section = () => this.page.locator('#lab-results-section'); + + readonly saveButton = () => this.section().locator('button.confirm.action-button'); + readonly cancelButton = () => this.section().locator('button.cancel.action-button'); + readonly reasonNotPerformed = () => this.section().locator('.result-field.reason-not-performed select'); + readonly errors = () => this.section().locator('.errors-section'); + + /** + * The Lab Status control is a hidden