fix: keep topical stock non-depleting in planner flows (#359)

* fix: keep topical stock non-depleting in planner and reports

* test: stabilize e2e selectors for updated medication semantics

* fix(backend): add missing planner translation keys
This commit is contained in:
Daniel Volz
2026-02-28 23:36:52 +01:00
committed by GitHub
parent 8efd99d738
commit 9e8a6315e7
19 changed files with 807 additions and 130 deletions
+71 -29
View File
@@ -70,40 +70,82 @@ setup("authenticate", async ({ page }) => {
// Wait for auth container
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
// ---- 3. Ensure the test user exists ----
// ---- 3. Query auth state to determine login method ----
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
await page.request
.post(`${baseURL}/api/auth/register`, {
data: { username: TEST_USER.username, password: TEST_USER.password },
})
.catch(() => {});
// ---- 4. Log in via UI ----
const usernameField = page.locator("#username");
const passwordField = page.locator("#password");
// Make sure we're on the login form (not register)
const isOnRegister = await page
.locator(".auth-subtitle")
.filter({ hasText: /Create Account/i })
.isVisible()
.catch(() => false);
if (isOnRegister) {
const switchBtn = page.locator("button.auth-link-btn");
if (await switchBtn.isVisible().catch(() => false)) {
await switchBtn.click();
await page.waitForTimeout(500);
let formLoginEnabled = true;
let oidcEnabled = false;
try {
const stateRes = await page.request.get(`${baseURL}/api/auth/state`);
if (stateRes.ok()) {
const state = await stateRes.json();
formLoginEnabled = state.formLoginEnabled !== false;
oidcEnabled = state.oidcEnabled === true;
}
} catch {
// Fallback: assume form login is available
}
await usernameField.clear();
await usernameField.fill(TEST_USER.username);
await passwordField.clear();
await passwordField.fill(TEST_USER.password);
// ---- 4. Ensure the test user exists (only if form login is available) ----
if (formLoginEnabled) {
await page.request
.post(`${baseURL}/api/auth/register`, {
data: { username: TEST_USER.username, password: TEST_USER.password },
})
.catch(() => {});
}
// Click the submit button (not the SSO button)
await page.locator('button.auth-submit[type="submit"]').click();
// ---- 5. Log in via the appropriate method ----
if (formLoginEnabled) {
// Form login path: username/password
const usernameField = page.locator("#username");
const passwordField = page.locator("#password");
// Make sure we're on the login form (not register)
const isOnRegister = await page
.locator(".auth-subtitle")
.filter({ hasText: /Create Account/i })
.isVisible()
.catch(() => false);
if (isOnRegister) {
const switchBtn = page.locator("button.auth-link-btn");
if (await switchBtn.isVisible().catch(() => false)) {
await switchBtn.click();
await page.waitForTimeout(500);
}
}
await usernameField.clear();
await usernameField.fill(TEST_USER.username);
await passwordField.clear();
await passwordField.fill(TEST_USER.password);
// Click the submit button (not the SSO button)
await page.locator('button.auth-submit[type="submit"]').click();
} else if (oidcEnabled) {
// SSO-only path: click the SSO button and let the OIDC provider handle login.
// This requires the OIDC provider to be configured with test credentials
// (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars)
// or to auto-approve the test user.
await page.locator("button.sso-btn").click();
// Wait for OIDC redirect and callback — the provider may show its own login form
const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME;
const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD;
if (oidcUsername && oidcPassword) {
// Fill OIDC provider login form (generic selectors — override if needed)
await page.waitForURL(/.*/, { timeout: 15000 });
const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first();
const oidcPassField = page.locator('input[name="password"], input[type="password"]').first();
if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) {
await oidcUserField.fill(oidcUsername);
await oidcPassField.fill(oidcPassword);
await page.locator('button[type="submit"]').first().click();
}
}
} else {
throw new Error("No login method available: form login and OIDC are both disabled");
}
// Wait for successful auth — app header should appear
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
+61 -5
View File
@@ -1,16 +1,28 @@
import { expect, type Page, test } from "@playwright/test";
async function isAuthEnabled(page: Page): Promise<boolean> {
interface AuthStateResponse {
authEnabled: boolean;
formLoginEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
registrationEnabled: boolean;
}
async function getAuthState(page: Page): Promise<AuthStateResponse | null> {
try {
const response = await page.request.get("/api/auth/state");
if (!response.ok()) return true;
const state = await response.json();
return state?.authEnabled !== false;
if (!response.ok()) return null;
return (await response.json()) as AuthStateResponse;
} catch {
return true;
return null;
}
}
async function isAuthEnabled(page: Page): Promise<boolean> {
const state = await getAuthState(page);
return state?.authEnabled !== false;
}
/**
* Authentication E2E Tests
*
@@ -110,4 +122,48 @@ test.describe("Authentication", () => {
const newText = await subtitle.textContent();
expect(newText).not.toBe(initialText);
});
test("should show SSO button when OIDC is enabled", async ({ page }) => {
const state = await getAuthState(page);
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
test.skip(!state?.oidcEnabled, "OIDC is not enabled in this environment");
await page.goto("/");
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
const ssoButton = page.locator("button.sso-btn");
await expect(ssoButton).toBeVisible();
await expect(ssoButton).toContainText(state.oidcProviderName || "SSO");
});
test("should hide form login when formLoginEnabled is false", async ({ page }) => {
const state = await getAuthState(page);
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
test.skip(state?.formLoginEnabled !== false, "Form login is enabled — cannot test hidden state");
await page.goto("/");
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
// Username/password fields should not be visible
await expect(page.locator("#username")).not.toBeVisible();
await expect(page.locator("#password")).not.toBeVisible();
// SSO button should be the only login method
await expect(page.locator("button.sso-btn")).toBeVisible();
});
test("should show both login methods when OIDC and form login are enabled", async ({ page }) => {
const state = await getAuthState(page);
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
test.skip(!state?.oidcEnabled, "OIDC is not enabled");
test.skip(!state?.formLoginEnabled, "Form login is not enabled");
await page.goto("/");
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
// Both login methods visible
await expect(page.locator("#username")).toBeVisible();
await expect(page.locator("#password")).toBeVisible();
await expect(page.locator("button.sso-btn")).toBeVisible();
});
});
+39 -13
View File
@@ -103,25 +103,43 @@ export const test = base.extend<object>({
/**
* Wait for the app to be fully loaded past any loading/initializing screens.
* Includes a single retry with page reload to handle transient auth failures
* (e.g. brief race between context setup and cookie application).
* Retries up to 2 times with page reload to handle transient auth or
* rate-limit failures.
*/
export async function waitForAppReady(page: Page): Promise<void> {
const hero = page.locator("header.hero");
try {
await expect(hero).toBeVisible({ timeout: 15000 });
} catch {
// Auth might have failed transiently — reload and retry once
await page.reload();
await expect(hero).toBeVisible({ timeout: 15000 });
for (let attempt = 0; attempt < 3; attempt++) {
try {
await expect(hero).toBeVisible({ timeout: 15000 });
return;
} catch {
if (attempt === 2) throw new Error("App failed to become ready after 3 attempts");
// Check for rate-limit error displayed in UI
const rateLimited = await page
.locator("text=rate limit, text=429, text=too many")
.first()
.isVisible()
.catch(() => false);
if (rateLimited) {
// Wait longer before retrying if rate-limited
await page.waitForTimeout(5000);
}
await page.reload();
}
}
}
/**
* Navigate to a page and wait for it to be ready.
* Handles transient navigation failures with a single retry.
*/
export async function navigateTo(page: Page, path: string): Promise<void> {
await page.goto(path);
const response = await page.goto(path);
if (response && response.status() === 429) {
// Rate-limited — wait and retry once
await page.waitForTimeout(5000);
await page.goto(path);
}
await waitForAppReady(page);
await page.waitForLoadState("networkidle");
}
@@ -259,13 +277,21 @@ export async function createMedicationViaAPI(data: {
/**
* Delete a medication via the backend API.
* Includes retry for rate-limited responses.
*/
export async function deleteMedicationViaAPI(id: number): Promise<void> {
const token = getAuthCookie();
await fetch(`${API_BASE}/api/medications/${id}`, {
method: "DELETE",
headers: token ? { Cookie: `access_token=${token}` } : {},
});
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
method: "DELETE",
headers: token ? { Cookie: `access_token=${token}` } : {},
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
continue;
}
return;
}
}
/**
+3 -3
View File
@@ -233,7 +233,7 @@ test.describe("Medication Editing", () => {
// Change intake from 1 pill daily to 2 pills every 7 days
const intakeRow = page.locator(".blister-row").first();
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
const usageField = intakeRow.getByLabel(/(Usage|form\.blisters\.usage)/i);
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
await usageField.fill("2");
@@ -247,7 +247,7 @@ test.describe("Medication Editing", () => {
// Verify the changes persisted
await clickEditMed(page, "Edit Intake Med");
const savedRow = page.locator(".blister-row").first();
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/(Usage|form\.blisters\.usage)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
});
@@ -279,7 +279,7 @@ test.describe("Medication Editing", () => {
// Fill the new intake row
const secondRow = page.locator(".blister-row").nth(1);
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
await secondRow.getByLabel(/(Usage|form\.blisters\.usage)/i).fill("0.5");
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
await saveEditAndVerify(page, "Add Intake Med");
+193
View File
@@ -0,0 +1,193 @@
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
/**
* Medication Lifecycle Integration Tests
*
* End-to-end workflows that verify changes propagate across pages:
* create → verify on medications → check in planner → check in schedule → edit → delete
*/
test.describe("Medication lifecycle", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 90000 });
const MED_NAME = "Lifecycle TestMed";
const MED_EDITED = "Lifecycle Edited";
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
test("create medication via API and verify it appears on all pages", async ({ page }) => {
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Step 1: Create medication
const created = await createMedicationViaAPI({
name: MED_NAME,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
expect(created.id).toBeTruthy();
// Step 2: Verify on medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
// Step 3: Verify in planner
await navigateTo(page, "/planner");
await page.waitForLoadState("networkidle");
await page.locator('form.planner button[type="submit"]').click();
await expect(page.locator(".table")).toBeVisible({ timeout: 15000 });
await expect(page.locator(".table").getByText(MED_NAME)).toBeVisible();
// Step 4: Verify in schedule
await navigateTo(page, "/schedule");
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
});
test("edit medication name via UI and verify update propagates", async ({ page }) => {
await deleteAllMedicationsViaAPI();
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Create a fresh medication for this test
await createMedicationViaAPI({
name: MED_NAME,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
// Navigate to medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
// Open edit view from medication row actions
const medRow = page.locator(".med-row").filter({ hasText: MED_NAME });
await expect(medRow.first()).toBeVisible({ timeout: 10000 });
await medRow.first().locator("button.info").click();
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
timeout: 5000,
});
// Update the name
const form = page.locator("form.form-grid:visible").first();
const nameInput = form.getByLabel(/(Commercial Name|Name|form\.name)/i).first();
await nameInput.fill(MED_EDITED);
// Save
const submitButton = form.locator('button[type="submit"]').first();
await expect(submitButton).toBeEnabled({ timeout: 5000 });
await submitButton.click();
// Wait for modal to close or save to complete
await page.waitForLoadState("networkidle");
// Verify edited name appears on medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_EDITED).first()).toBeVisible({ timeout: 10000 });
// Old name should no longer appear
await expect(page.locator(".med-row").filter({ hasText: MED_NAME })).toHaveCount(0, { timeout: 5000 });
});
test("delete medication via API and verify it disappears from all pages", async ({ page }) => {
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Create and then delete
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: MED_NAME,
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 5,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
// Verify it exists first
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME)).toBeVisible({ timeout: 10000 });
// Delete via API
await deleteAllMedicationsViaAPI();
// Verify gone from medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME)).not.toBeVisible({ timeout: 5000 });
// Verify planner shows no results for this med
await navigateTo(page, "/planner");
await page.waitForLoadState("networkidle");
await page.locator('form.planner button[type="submit"]').click();
// Either no table or table without the medication name
const table = page.locator(".table");
const tableVisible = await table.isVisible().catch(() => false);
if (tableVisible) {
await expect(table.getByText(MED_NAME)).not.toBeVisible({ timeout: 3000 });
}
});
test("medication with multiple intakes shows all schedule entries", async ({ page }) => {
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
const todayEvening = (() => {
const d = new Date();
d.setHours(20, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: "MultiIntake Med",
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [
{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false },
{ usage: 2, every: 1, start: todayEvening, intakeRemindersEnabled: false },
],
});
// Verify schedule shows this medication
await navigateTo(page, "/schedule");
await expect(page.getByText("MultiIntake Med").first()).toBeVisible({ timeout: 10000 });
// The medication should appear at least twice (morning + evening)
const medEntries = page.getByText("MultiIntake Med");
expect(await medEntries.count()).toBeGreaterThanOrEqual(2);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
/**
* Performance Tests
*
* Verify the schedule timeline and planner render within acceptable
* time limits when many medications exist.
*/
test.describe("Performance with many medications", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 120000 });
const MED_COUNT = 20;
const MED_PREFIX = "PerfTest Med";
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Create medications sequentially (API rate limits prevent parallel)
for (let i = 1; i <= MED_COUNT; i++) {
await createMedicationViaAPI({
name: `${MED_PREFIX} ${i}`,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
}
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
test("schedule page renders within 10 seconds with 20 medications", async ({ page }) => {
const start = Date.now();
await navigateTo(page, "/schedule");
// Wait for schedule entries to render
const scheduleEntries = page.locator(".schedule-entry, .timeline-entry, .card");
await expect(scheduleEntries.first()).toBeVisible({ timeout: 15000 });
const renderTime = Date.now() - start;
// Verify all medications appear
for (let i = 1; i <= MED_COUNT; i++) {
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
}
// Goal: render under 10 seconds
expect(renderTime).toBeLessThan(10000);
});
test("medications page renders within 10 seconds with 20 medications", async ({ page }) => {
const start = Date.now();
await navigateTo(page, "/medications");
// Wait for medication cards to render
const medEntries = page.locator(".medication-card, .card, .table-row");
await expect(medEntries.first()).toBeVisible({ timeout: 15000 });
const renderTime = Date.now() - start;
// Verify count — all 20 should be visible
for (let i = 1; i <= MED_COUNT; i++) {
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
}
expect(renderTime).toBeLessThan(10000);
});
test("planner calculates within 15 seconds with 20 medications", async ({ page }) => {
await navigateTo(page, "/planner");
const start = Date.now();
await page.waitForLoadState("networkidle");
await page.locator('form.planner button[type="submit"]').click();
await expect(page.locator(".table")).toBeVisible({ timeout: 20000 });
const calcTime = Date.now() - start;
// All medications should appear in the results
const rows = page.locator(".table .table-row");
expect(await rows.count()).toBeGreaterThanOrEqual(MED_COUNT);
// Goal: calculate and render under 15 seconds
expect(calcTime).toBeLessThan(15000);
});
});
+50 -9
View File
@@ -106,7 +106,7 @@ test.describe("Planner with medications", () => {
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
});
test("should show usage data in results rows", async ({ page }) => {
test("should show correct usage values in results rows", async ({ page }) => {
await navigateTo(page, "/planner");
await calculatePlanner(page);
@@ -116,10 +116,15 @@ test.describe("Planner with medications", () => {
const rows = resultsTable.locator(".table-row");
expect(await rows.count()).toBeGreaterThanOrEqual(2);
const firstRowText = await rows.first().textContent();
expect(firstRowText).toBeTruthy();
// Check for "pill" (matches both "pill" and "pills")
expect(firstRowText!.toLowerCase()).toContain("pill");
// Each medication has usage=1, every=1 → plannerUsage should reflect the period
// Verify the usage column contains a numeric <strong> value and "pill(s)"
for (const row of await rows.all()) {
const usageCell = row.locator("[data-label]").nth(1); // Usage is 2nd column
const usageStrong = usageCell.locator("strong");
await expect(usageStrong).toBeVisible();
const usageText = await usageStrong.textContent();
expect(Number(usageText)).toBeGreaterThan(0);
}
});
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
@@ -139,9 +144,16 @@ test.describe("Planner with medications", () => {
const resultsTable = page.locator(".table");
await expect(resultsTable).toBeVisible({ timeout: 10000 });
// Low-stock med (3 pills) should have a danger chip over 90 days
// Low-stock med (3 pills, usage 1/day, 90 days) should have danger status
const dangerChips = resultsTable.locator(".status-chip.danger");
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
// Find the low-stock med row and verify its usage value ~90 pills
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
await expect(lowStockRow).toBeVisible();
const lowUsage = await lowStockRow.locator("[data-label] strong").first().textContent();
expect(Number(lowUsage)).toBeGreaterThanOrEqual(85); // ~90 pills needed
expect(Number(lowUsage)).toBeLessThanOrEqual(95);
});
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
@@ -161,9 +173,16 @@ test.describe("Planner with medications", () => {
const resultsTable = page.locator(".table");
await expect(resultsTable).toBeVisible({ timeout: 10000 });
// With 60 pills and 7-day range, high-stock should be "Enough"
const successChips = resultsTable.locator(".status-chip.success");
expect(await successChips.count()).toBeGreaterThanOrEqual(1);
// High-stock med (60 pills, usage 1/day, 7 days → needs ~7, has 60) should be "Enough"
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
await expect(highStockRow).toBeVisible();
const highStatus = highStockRow.locator(".status-chip.success");
await expect(highStatus).toBeVisible();
// Verify usage is ~7 pills for the 7-day range
const highUsage = await highStockRow.locator("[data-label] strong").first().textContent();
expect(Number(highUsage)).toBeGreaterThanOrEqual(5);
expect(Number(highUsage)).toBeLessThanOrEqual(10);
});
test("should show table header with correct columns", async ({ page }) => {
@@ -180,6 +199,28 @@ test.describe("Planner with medications", () => {
await expect(tableHead.getByText(/Status/i)).toBeVisible();
});
test("should display available stock for each medication", async ({ page }) => {
await navigateTo(page, "/planner");
await calculatePlanner(page);
const resultsTable = page.locator(".table");
await expect(resultsTable).toBeVisible({ timeout: 10000 });
// High-stock med should show a blister + loose-pill stock breakdown
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
await expect(highStockRow).toBeVisible();
const highStockText = await highStockRow.textContent();
expect(highStockText).toMatch(/\d+\s*(blisters|Blister)/i);
expect(highStockText).toMatch(/\d+\s*(pill|pills|Tablette|Tabletten)/i);
// Low-stock med: 1 pack × 1 blister × 3 pills = 3 pills = 0 full blisters + 3 loose
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
await expect(lowStockRow).toBeVisible();
const lowStockText = await lowStockRow.textContent();
// Should show 3 loose pills
expect(lowStockText).toMatch(/3\s*(pill|pills|Tablette|Tabletten)/i);
});
test("should reset form and clear results", async ({ page }) => {
await navigateTo(page, "/planner");
await calculatePlanner(page);