mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Under Node, pdf.js runs its worker in the server process and keeps it on a global, and whichever copy of pdf.js opens a document first owns that global until a restart. The local text read went through pdf-parse, which pins its own pdfjs-dist 5.4.296; the rasterizer uses the app's 6.4.299. A text read first made every later rasterization fail with "The API version does not match the Worker version" (thumbnails raster-failed, the vision read of a scan 422), and a rasterization first made every later local text read fail, which left the document out of the search index with nothing in the logs. src/lib/documents/pdfjs.ts is now the one place pdf.js is loaded and opened, with one set of options (asset directories, no eval, no system fonts). The text read walks the pages with getTextContent and lays the text out the way pdf-parse did with the options it was called with: line breaks, tabs between far-apart items, a blank line after each page, the 200-page cap. Text extracted from the PDF fixtures and from generated lab tables, encrypted files and doctor reports is identical before and after. A failed text read now emits documents.local_extract.failed with the error pdf.js threw, and annotates the index event with it, instead of returning a bare error. The report tests read PDF text through the same reader. Two tests, one per order, run a text read and a rasterization in one process; both failed before this change. Refs #1277
560 lines
23 KiB
TypeScript
560 lines
23 KiB
TypeScript
/**
|
|
* The doctor report, generated in a browser and read back as a real PDF.
|
|
*
|
|
* The surface had unit coverage for the renderer and route coverage for the
|
|
* envelope, and nothing that pressed the button. What that leaves untested is
|
|
* the part a self-hoster actually meets: a person with a few weeks of readings
|
|
* opens Settings → Gesundheitsakte, picks a window, presses Generate, and gets
|
|
* a document a practice can file. Every link in that chain — the panel's own
|
|
* `fetch`, the selection it sends, the aggregator's window, the renderer, the
|
|
* download — is proved here once, end to end.
|
|
*
|
|
* What the assertions are about:
|
|
*
|
|
* 1. The response IS a PDF: `application/pdf`, a `%PDF` magic, a byte length
|
|
* no empty document could reach, and a `Content-Length` that agrees with
|
|
* the bytes.
|
|
* 2. The document says what it contains. The bytes are parsed back to text,
|
|
* so the period line, the blood-pressure section and the medication the
|
|
* account carries are read out of the artefact rather than inferred from a
|
|
* 200.
|
|
* 3. The window travelled. The period line names two dates, and their span is
|
|
* the window the select offered — not the server's default.
|
|
*
|
|
* And the refusing control, in the same file because it is the same button: a
|
|
* window with nothing in it still produces a valid, readable PDF that names the
|
|
* period and simply carries no blood-pressure section. An empty report is a
|
|
* true statement about an empty window; a truncated or unopenable file is not.
|
|
*
|
|
* Its own account, and not the shared one: generating a report WRITES. The
|
|
* route remembers the scope and the practice name on the account row, so the
|
|
* panel a later visit opens is the collapsed "repeat run" state — which is
|
|
* exactly the state `settings-export.spec.ts` asserts the opposite of, against
|
|
* the shared account, in nine places. See `E2E_REPORT_OWNER`.
|
|
*
|
|
* The fixture is seeded through the PAGE's own `fetch` under the session the
|
|
* spec is already authenticated as — not through a second Playwright request
|
|
* context — so the rows are written by exactly the credential that later reads
|
|
* them back. The ROWS are idempotent: a re-post of the same reading collides on
|
|
* the measurement's natural key and answers 409, so `--repeat-each` and a
|
|
* second local run write nothing twice. The export QUOTA is not idempotent —
|
|
* every export route shares one 10-per-hour bucket keyed on the actor, and it
|
|
* outlives the run — so `global-setup` clears this account's bucket before each
|
|
* run rather than the file pretending the press is free.
|
|
*
|
|
* The delegate half of the story — a READ-level delegate cannot generate the
|
|
* owner's report — lives in `doctor-report-delegate.spec.ts`, which needs its
|
|
* own session row because it moves the record selector.
|
|
*/
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
import type { Page, Response } from "@playwright/test";
|
|
|
|
import { readPdfText } from "@/lib/documents/pdfjs";
|
|
|
|
import {
|
|
E2E_REPORT_OWNER,
|
|
REPORT_OWNER_STORAGE_STATE_PATH,
|
|
} from "./setup/global-setup";
|
|
import {
|
|
completeReproofWithPassword,
|
|
useStaleSession,
|
|
} from "./setup/recent-proof";
|
|
import { expect, test } from "./setup/test";
|
|
|
|
/** The allergy and the drug the report has to name. Both plain ASCII: the
|
|
* PDF's built-in Helvetica is WinAnsi-encoded and the assertions read the
|
|
* extracted text back, so a fixture that needs transliteration would be
|
|
* testing the sanitiser rather than the report. */
|
|
const ALLERGY_SUBSTANCE = "Amoxicillin";
|
|
const MEDICATION_NAME = "Ramipril";
|
|
const MEDICATION_DOSE = "5 mg";
|
|
|
|
/** Day offsets for the seeded readings — a few weeks of history, inside the
|
|
* 30-day window the spec selects and well inside the 90-day default, so a
|
|
* window that failed to travel would still find them. */
|
|
const READING_DAY_OFFSETS = [2, 5, 9, 13, 17, 21] as const;
|
|
|
|
/** The window the spec chooses in the panel. Deliberately NOT the 90-day
|
|
* fallback the panel opens with, so the period line proves a choice. */
|
|
const RANGE_DAYS = 30;
|
|
|
|
/** The section heading the PDF prints only when both blood-pressure series
|
|
* have readings in the window. Quoted without its em dash: the expectation is
|
|
* about the section being there, not about how a parser renders punctuation. */
|
|
const BP_SECTION = "ESH classification";
|
|
|
|
/** The refusing control's window: twenty days, starting four hundred days back.
|
|
* Far outside every seeded reading and well inside the route's 1..730-day span
|
|
* bounds, and named so the period line can be asserted against it rather than
|
|
* the report being taken on trust. */
|
|
const EMPTY_WINDOW_START_DAYS_BACK = 400;
|
|
const EMPTY_WINDOW_DAYS = 20;
|
|
|
|
/** Where the drug's course starts: before the empty control's window opens,
|
|
* so the drug is ongoing across every window this file asks for. */
|
|
const MEDICATION_COURSE_START_DAYS_BACK = EMPTY_WINDOW_START_DAYS_BACK + 30;
|
|
|
|
/** The UTC calendar day `offset` days back, as `YYYY-MM-DD`. */
|
|
function isoDayBack(offset: number): string {
|
|
const day = new Date();
|
|
day.setUTCDate(day.getUTCDate() - offset);
|
|
return day.toISOString().slice(0, 10);
|
|
}
|
|
|
|
/** Byte floor for "a real document". A cover page with charts is far above
|
|
* this; a truncated stream or an error body is far below it. */
|
|
const MIN_PDF_BYTES = 10_000;
|
|
|
|
interface SeedOutcome {
|
|
measurements: number[];
|
|
allergy: number;
|
|
medication: number;
|
|
emergency: number;
|
|
}
|
|
|
|
/**
|
|
* Write the record this report is about, through the page's own `fetch`.
|
|
*
|
|
* Each per-day POST carries the day's systolic, diastolic and weight as one
|
|
* array body — the route's batch arm — so a repeat run collides as a whole day
|
|
* and answers 409 rather than half-inserting. The allergy and the medication
|
|
* are read before they are written, because neither has a natural key that
|
|
* would make a second create a no-op.
|
|
*
|
|
* The emergency profile matters more than it looks: the drug list reaches the
|
|
* PDF through the emergency sheet, and that sheet only renders once the profile
|
|
* holds something. One blood type is the smallest honest way to say so.
|
|
*/
|
|
async function seedReportFixture(page: Page): Promise<SeedOutcome> {
|
|
return page.evaluate(
|
|
async (fixture) => {
|
|
const send = async (
|
|
method: string,
|
|
path: string,
|
|
body: unknown,
|
|
): Promise<number> => {
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return res.status;
|
|
};
|
|
|
|
const readList = async (path: string): Promise<unknown[]> => {
|
|
const res = await fetch(path);
|
|
if (!res.ok) return [];
|
|
const payload = (await res.json()) as { data: unknown };
|
|
return Array.isArray(payload.data) ? payload.data : [];
|
|
};
|
|
|
|
/** 09:00 UTC on the day `offset` days back — a fixed instant, so a
|
|
* second run inside the same day reproduces the same natural key. */
|
|
const instantFor = (offset: number): string => {
|
|
const day = new Date();
|
|
day.setUTCDate(day.getUTCDate() - offset);
|
|
return `${day.toISOString().slice(0, 10)}T09:00:00.000Z`;
|
|
};
|
|
|
|
const measurements: number[] = [];
|
|
for (const [index, offset] of fixture.dayOffsets.entries()) {
|
|
const measuredAt = instantFor(offset);
|
|
measurements.push(
|
|
await send("POST", "/api/measurements", [
|
|
{
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: 118 + index,
|
|
measuredAt,
|
|
},
|
|
{
|
|
type: "BLOOD_PRESSURE_DIA",
|
|
value: 74 + index,
|
|
measuredAt,
|
|
},
|
|
{
|
|
type: "WEIGHT",
|
|
value: 82.4 - index * 0.2,
|
|
measuredAt,
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
const allergies = (await readList("/api/allergies")) as Array<{
|
|
substance?: string;
|
|
}>;
|
|
const allergy = allergies.some(
|
|
(row) => row.substance === fixture.allergySubstance,
|
|
)
|
|
? 200
|
|
: await send("POST", "/api/allergies", {
|
|
substance: fixture.allergySubstance,
|
|
category: "MEDICATION",
|
|
type: "ALLERGY",
|
|
severity: "MODERATE",
|
|
});
|
|
|
|
const drugs = (await readList("/api/medications")) as Array<{
|
|
name?: string;
|
|
}>;
|
|
const medication = drugs.some(
|
|
(row) => row.name === fixture.medicationName,
|
|
)
|
|
? 200
|
|
: await send("POST", "/api/medications", {
|
|
name: fixture.medicationName,
|
|
dose: fixture.medicationDose,
|
|
// No schedule: an as-needed drug is active indefinitely and needs
|
|
// no dose ledger, which keeps the fixture about the drug LIST.
|
|
asNeeded: true,
|
|
// The report lists a medication only for a window its course had
|
|
// begun by, so a drug entered today is rightly absent from a report
|
|
// for last year. The course therefore starts before the empty
|
|
// control's window: an ongoing drug the patient was already taking
|
|
// then belongs in both reports.
|
|
startsOn: fixture.medicationStartsOn,
|
|
});
|
|
|
|
const emergency = await send("PATCH", "/api/anamnesis/emergency", {
|
|
bloodType: "O_POS",
|
|
});
|
|
|
|
return { measurements, allergy, medication, emergency };
|
|
},
|
|
{
|
|
dayOffsets: [...READING_DAY_OFFSETS],
|
|
allergySubstance: ALLERGY_SUBSTANCE,
|
|
medicationName: MEDICATION_NAME,
|
|
medicationDose: MEDICATION_DOSE,
|
|
medicationStartsOn: isoDayBack(MEDICATION_COURSE_START_DAYS_BACK),
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Open the panel and wait until the client boundary that owns it is live.
|
|
*
|
|
* `GesundheitsakteSection` carries the export panel and the sharing card in one
|
|
* boundary, and the sharing card's query only runs once that boundary has
|
|
* hydrated — so its request is the signal that the Generate button is wired
|
|
* rather than merely painted. Same gate `settings-export.spec.ts` uses, for the
|
|
* same reason.
|
|
*/
|
|
async function openReportPanel(page: Page): Promise<void> {
|
|
const boundaryHydrated = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "GET" &&
|
|
new URL(response.url()).pathname === "/api/share-links",
|
|
);
|
|
await page.goto("/settings/gesundheitsakte", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await boundaryHydrated;
|
|
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Put the panel into a known state: PDF format, the standard scope.
|
|
*
|
|
* The account carries a saved selection the moment it has generated once, so
|
|
* the panel a second run opens is the "repeat run" state with the picker
|
|
* collapsed. Both states are handled by opening the disclosure when it is shut
|
|
* and then applying the named template, which is also the only thing in the
|
|
* product that fills a selection — so the scope this report is generated under
|
|
* is a scope somebody pressed a button for, exactly as the panel intends.
|
|
*/
|
|
async function chooseStandardPdfScope(page: Page): Promise<void> {
|
|
const panel = page.getByTestId("health-record-export-panel");
|
|
|
|
const formats = panel.locator('[role="radiogroup"] [role="radio"]');
|
|
// PDF is the first of the three formats the panel offers.
|
|
await formats.first().click();
|
|
await expect(formats.first()).toHaveAttribute("aria-checked", "true");
|
|
|
|
const picker = page.getByTestId("health-record-included-data-panel");
|
|
if ((await picker.count()) === 0) {
|
|
await page.getByTestId("health-record-included-data-toggle").click();
|
|
}
|
|
await expect(picker).toBeVisible();
|
|
|
|
await page.getByTestId("report-apply-standard-export").click();
|
|
await expect(page.getByTestId("health-record-generate")).toBeEnabled();
|
|
}
|
|
|
|
interface GeneratedReport {
|
|
status: number;
|
|
contentType: string;
|
|
contentLength: string | undefined;
|
|
bytes: Buffer;
|
|
text: string;
|
|
filename: string;
|
|
}
|
|
|
|
/**
|
|
* Press Generate and take the artefact apart.
|
|
*
|
|
* Both ends of the press are observed: the response carries the bytes the
|
|
* assertions are about, and the download event is the state the person sees.
|
|
* Waiting on the response ALONE would pass on a build that produced a perfect
|
|
* PDF and never handed it to the browser.
|
|
*/
|
|
async function generateReport(page: Page): Promise<GeneratedReport> {
|
|
// The first press is refused for a recent proof (the session is a stale one,
|
|
// see the `beforeEach`); the report is the answer to the retry that follows
|
|
// the re-proof, so the refusal is not the response waited for.
|
|
const responseArrived = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "POST" &&
|
|
new URL(response.url()).pathname === "/api/export/health-record" &&
|
|
response.status() !== 401,
|
|
{ timeout: 60_000 },
|
|
);
|
|
const downloadStarted = page.waitForEvent("download", { timeout: 60_000 });
|
|
|
|
const generate = page.getByTestId("health-record-generate");
|
|
await generate.click();
|
|
await completeReproofWithPassword(page, E2E_REPORT_OWNER.password);
|
|
|
|
const response = await responseArrived;
|
|
const download = await downloadStarted;
|
|
|
|
// The bytes come off the DOWNLOAD, not off the response, because the saved
|
|
// file is the artefact the person ends up with: a build that produced a
|
|
// perfect response and saved something else would still be caught.
|
|
const saved = await download.path();
|
|
expect(saved, "the panel saved the generated report").toBeTruthy();
|
|
const bytes = await readFile(saved!);
|
|
|
|
// The control comes back, and nothing on the panel is complaining. The error
|
|
// paragraph is the panel's only failure surface, so its absence is the
|
|
// statement that the press succeeded rather than failed quietly.
|
|
await expect(generate).toBeEnabled({ timeout: 30_000 });
|
|
await expect(
|
|
page.getByTestId("health-record-export-panel").getByRole("alert"),
|
|
).toHaveCount(0);
|
|
|
|
// Asserted BEFORE the parse: a parser fed an error envelope reports "not a
|
|
// PDF", which is a true statement about the bytes and a useless one about
|
|
// what went wrong. The status and the content type say that.
|
|
expect(response.status(), await failureDetail(response)).toBe(200);
|
|
expect(response.headers()["content-type"] ?? "").toContain("application/pdf");
|
|
expect(bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
|
|
|
|
const text = await readPdfText(new Uint8Array(bytes));
|
|
|
|
return {
|
|
status: response.status(),
|
|
contentType: response.headers()["content-type"] ?? "",
|
|
contentLength: response.headers()["content-length"],
|
|
bytes,
|
|
text,
|
|
filename: download.suggestedFilename(),
|
|
};
|
|
}
|
|
|
|
/** What the route said, when it did not say "PDF". */
|
|
async function failureDetail(response: Response): Promise<string> {
|
|
try {
|
|
return (await response.text()).slice(0, 400);
|
|
} catch {
|
|
return "<no readable body>";
|
|
}
|
|
}
|
|
|
|
/** Every assertion that is about "this is a PDF", said once. */
|
|
function expectRealPdf(report: GeneratedReport): void {
|
|
expect(report.status).toBe(200);
|
|
expect(report.contentType).toContain("application/pdf");
|
|
expect(report.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
|
|
expect(report.bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
|
|
// The route declares the length it sent; a disagreement means the body was
|
|
// truncated on the way out, which a magic-number check alone would miss.
|
|
expect(report.contentLength).toBe(String(report.bytes.byteLength));
|
|
// The name the CLIENT anchor invents, not the route's `Content-Disposition`
|
|
// — the two differ by a word today. This says the panel saved a PDF, and it
|
|
// is not contract coverage of the header.
|
|
expect(report.filename).toMatch(/\.pdf$/);
|
|
}
|
|
|
|
/** The three orders `DateFormatPreference` pins a rendering to. */
|
|
type DateOrder = "DMY" | "MDY" | "YMD";
|
|
|
|
/**
|
|
* Which field of the period line is the month, read off the account.
|
|
*
|
|
* The rendering is the account's `dateFormat` preference, not a constant: DMY
|
|
* prints `10.09.2026`, YMD prints `2026-09-10`, and en-GB's MM/DD-shaped
|
|
* `10/09/2026` is a DMY date that an MDY reader silently mis-parses into a
|
|
* nonsense span. So the preference is read rather than assumed.
|
|
*
|
|
* `AUTO` resolves through the active locale and is deliberately refused here:
|
|
* the fixture pins `MDY` (see `E2E_REPORT_OWNER` in `global-setup.ts`), so an
|
|
* `AUTO` account is a drifted fixture and should say so rather than be guessed
|
|
* at.
|
|
*/
|
|
async function readDateOrder(page: Page): Promise<DateOrder> {
|
|
const preference = await page.evaluate(async () => {
|
|
const res = await fetch("/api/auth/me");
|
|
const payload = (await res.json()) as { data: { dateFormat?: string } };
|
|
return payload.data.dateFormat ?? "AUTO";
|
|
});
|
|
expect(
|
|
["DMY", "MDY", "YMD"],
|
|
"the account pins an explicit date order for the period line",
|
|
).toContain(preference);
|
|
return preference as DateOrder;
|
|
}
|
|
|
|
/**
|
|
* The period line's two dates, as a span in days.
|
|
*
|
|
* The line is rendered in the account's date order and its timezone, so the
|
|
* dates are read structurally and compared as a SPAN. A span is timezone-proof
|
|
* in a way an absolute date is not: both ends move together.
|
|
*
|
|
* The separator is captured once and back-referenced, so `10.09.2026`,
|
|
* `09/10/2026` and `2026-09-10` all parse and none of them can be read as the
|
|
* other two.
|
|
*/
|
|
function periodSpanDays(text: string, order: DateOrder): number {
|
|
const match =
|
|
/Reporting period:\s*(\d{2,4})([./-])(\d{2})\2(\d{2,4})\s*\D{1,3}\s*(\d{2,4})\2(\d{2})\2(\d{2,4})/.exec(
|
|
text,
|
|
);
|
|
expect(match, "the report prints a reporting period with two dates").not.toBe(
|
|
null,
|
|
);
|
|
const start = utcMidnight(match![1], match![3], match![4], order);
|
|
const end = utcMidnight(match![5], match![6], match![7], order);
|
|
return Math.round((end - start) / 86_400_000);
|
|
}
|
|
|
|
function utcMidnight(
|
|
first: string,
|
|
second: string,
|
|
third: string,
|
|
order: DateOrder,
|
|
): number {
|
|
const [a, b, c] = [Number(first), Number(second), Number(third)];
|
|
const [year, month, day] =
|
|
order === "YMD" ? [a, b, c] : order === "DMY" ? [c, b, a] : [c, a, b];
|
|
return Date.UTC(year, month - 1, day);
|
|
}
|
|
|
|
test.describe.serial("the doctor report", () => {
|
|
// This journey's own account owns the readings, the allergy and the drug, and
|
|
// the page's own `fetch` writes them under its session.
|
|
test.use({ storageState: REPORT_OWNER_STORAGE_STATE_PATH });
|
|
|
|
// Generating the report exports the whole record, which asks for a sign-in
|
|
// or re-proof within five minutes. The shared jar is fresh only early in a
|
|
// run, so each test starts from a session signed in ten minutes ago and
|
|
// answers the re-proof dialog the way the owner would.
|
|
let endStaleSession: (() => Promise<void>) | null = null;
|
|
test.beforeEach(async ({ page }) => {
|
|
endStaleSession = await useStaleSession(page, E2E_REPORT_OWNER.username);
|
|
});
|
|
test.afterEach(async () => {
|
|
await endStaleSession?.();
|
|
endStaleSession = null;
|
|
});
|
|
|
|
test("generates a PDF that names the period, the vitals and the medication", async ({
|
|
page,
|
|
}) => {
|
|
// A production build rendering a charted PDF over a seeded window is well
|
|
// past the 30 s default, and a timeout here would report a failure that
|
|
// says nothing about the report.
|
|
test.setTimeout(120_000);
|
|
|
|
await openReportPanel(page);
|
|
const seeded = await seedReportFixture(page);
|
|
// 201 on a first run, 409 on a repeat — both mean the readings are there.
|
|
for (const status of seeded.measurements) {
|
|
expect([201, 409]).toContain(status);
|
|
}
|
|
expect([200, 201]).toContain(seeded.allergy);
|
|
expect([200, 201]).toContain(seeded.medication);
|
|
expect(seeded.emergency).toBe(200);
|
|
|
|
// Reload so the panel is looking at the record that now exists.
|
|
await openReportPanel(page);
|
|
await chooseStandardPdfScope(page);
|
|
|
|
// The period: a window the panel offers, and not the one it defaults to.
|
|
await page.locator("#hr-range").selectOption({ value: String(RANGE_DAYS) });
|
|
await expect(page.locator("#hr-range")).toHaveValue(String(RANGE_DAYS));
|
|
|
|
const report = await generateReport(page);
|
|
expectRealPdf(report);
|
|
|
|
// The document says what it is about.
|
|
expect(report.text).toContain("Reporting period");
|
|
const span = periodSpanDays(report.text, await readDateOrder(page));
|
|
expect(span).toBeGreaterThanOrEqual(RANGE_DAYS - 1);
|
|
expect(span).toBeLessThanOrEqual(RANGE_DAYS + 1);
|
|
|
|
// The blood-pressure section only prints when both series carried readings
|
|
// in the window, so its presence is the seeded history arriving.
|
|
expect(report.text).toContain(BP_SECTION);
|
|
// And the record the clinician reads first: the drug and the allergy.
|
|
expect(report.text).toContain(MEDICATION_NAME);
|
|
expect(report.text).toContain(ALLERGY_SUBSTANCE);
|
|
});
|
|
|
|
test("a window with no readings is an empty report, not a broken one", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
await openReportPanel(page);
|
|
await chooseStandardPdfScope(page);
|
|
|
|
// A custom window a year back: inside the route's span bounds, and holding
|
|
// none of this account's readings.
|
|
await page.locator("#hr-range").selectOption({ value: "custom" });
|
|
const panel = page.getByTestId("health-record-export-panel");
|
|
const dateFields = panel.locator(
|
|
'[data-slot="date-field"] input[type="text"]',
|
|
);
|
|
await expect(dateFields).toHaveCount(2);
|
|
|
|
// The field commits a clean ISO string whatever the locale's field order is.
|
|
await dateFields.first().fill(isoDayBack(EMPTY_WINDOW_START_DAYS_BACK));
|
|
await dateFields.first().press("Enter");
|
|
await dateFields
|
|
.nth(1)
|
|
.fill(isoDayBack(EMPTY_WINDOW_START_DAYS_BACK - EMPTY_WINDOW_DAYS));
|
|
await dateFields.nth(1).press("Enter");
|
|
|
|
const report = await generateReport(page);
|
|
// Still a document: the empty window is answered with a report, not with a
|
|
// truncated file and not with an error the panel swallows.
|
|
expectRealPdf(report);
|
|
expect(report.text).toContain("Reporting period");
|
|
|
|
// And it is the window that was typed. Two silent fallbacks sit under this
|
|
// line — the panel sends `{ days }` when either date field is empty, and
|
|
// the route falls back to the 90-day default when a bound fails to parse,
|
|
// neither of them a 422. Both land on a window that HOLDS the seeded
|
|
// readings, so without this the absence below would be the fixture's luck.
|
|
const emptySpan = periodSpanDays(report.text, await readDateOrder(page));
|
|
expect(emptySpan).toBeGreaterThanOrEqual(EMPTY_WINDOW_DAYS - 1);
|
|
expect(emptySpan).toBeLessThanOrEqual(EMPTY_WINDOW_DAYS + 1);
|
|
|
|
// Nothing was measured in it, so the section that needs readings is gone.
|
|
expect(report.text).not.toContain(BP_SECTION);
|
|
|
|
// The positive control for that absence: what the window does not empty
|
|
// is still in the document. The allergy is not window-scoped at all, and
|
|
// the drug's course began before the window and never ended, so it was
|
|
// being taken then. Without this, a renderer that produced a blank page
|
|
// would pass the line above.
|
|
expect(report.text).toContain(MEDICATION_NAME);
|
|
expect(report.text).toContain(ALLERGY_SUBSTANCE);
|
|
});
|
|
});
|