Files
medassist-ng/frontend/src/test/components/MedDetailModal.test.tsx
T

1079 lines
34 KiB
TypeScript

import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MedDetailModal } from "../../components/MedDetailModal";
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../../types";
import * as utils from "../../utils";
const defaultSettings: StockThresholds = {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 3,
expiryWarningDays: 30,
};
const mockMedication: Medication = {
id: 1,
name: "Test Med",
genericName: "Generic Name",
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ["John"],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
updatedAt: null,
expiryDate: "2025-12-31",
notes: "Test notes",
};
const mockCoverage: Coverage = {
name: "Test Med",
medsLeft: 25,
daysLeft: 25,
depletionDate: "2024-04-01",
depletionTime: Date.now() + 25 * 86400000,
nextDose: null,
};
const defaultProps = {
selectedMed: mockMedication,
coverage: { all: [mockCoverage] },
settings: defaultSettings,
showImageLightbox: false,
showRefillModal: false,
showEditStockModal: false,
onClose: vi.fn(),
onOpenImageLightbox: vi.fn(),
onCloseImageLightbox: vi.fn(),
onOpenRefillModal: vi.fn(),
onCloseRefillModal: vi.fn(),
onOpenEditStockModal: vi.fn(),
onCloseEditStockModal: vi.fn(),
refillPacks: 0,
onRefillPacksChange: vi.fn(),
refillLoose: 0,
onRefillLooseChange: vi.fn(),
usePrescriptionRefill: false,
onUsePrescriptionRefillChange: vi.fn(),
refillSaving: false,
refillHistory: [] as RefillEntry[],
refillHistoryExpanded: false,
onRefillHistoryExpandedChange: vi.fn(),
onSubmitRefill: vi.fn(),
editStockFullBlisters: 0,
onEditStockFullBlistersChange: vi.fn(),
editStockPartialBlisterPills: 0,
onEditStockPartialBlisterPillsChange: vi.fn(),
editStockLoosePills: 0,
onEditStockLoosePillsChange: vi.fn(),
editStockSaving: false,
onSubmitStockCorrection: vi.fn(),
};
describe("MedDetailModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders nothing when selectedMed is null", () => {
render(<MedDetailModal {...defaultProps} selectedMed={null} />);
expect(screen.queryByText("Test Med")).not.toBeInTheDocument();
});
it("renders modal when medication is selected", () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText("Test Med")).toBeInTheDocument();
});
it("displays medication name", () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText("Test Med")).toBeInTheDocument();
});
it("displays generic name", () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText("Generic Name")).toBeInTheDocument();
});
it("renders close button", () => {
render(<MedDetailModal {...defaultProps} />);
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
const closeBtn = closeButtons[0];
expect(closeBtn).toBeInTheDocument();
});
it("calls onClose when close button clicked", () => {
const onClose = vi.fn();
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
const closeBtn = closeButtons[0];
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when overlay clicked", () => {
const onClose = vi.fn();
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
const overlay = document.querySelector(".modal-overlay");
if (overlay) {
fireEvent.click(overlay);
}
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not call onClose when modal content clicked", () => {
const onClose = vi.fn();
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
const content = document.querySelector(".modal-content");
if (content) {
fireEvent.click(content);
}
expect(onClose).not.toHaveBeenCalled();
});
it("displays notes when available", () => {
render(<MedDetailModal {...defaultProps} />);
expect(screen.getByText("Test notes")).toBeInTheDocument();
});
it("shows loose pills in stock details for blister medications", () => {
const medWithLoose: Medication = {
...mockMedication,
pillsPerBlister: 5,
looseTablets: 2,
};
const coverageWithLoose: Coverage = {
...mockCoverage,
medsLeft: 50,
};
render(<MedDetailModal {...defaultProps} selectedMed={medWithLoose} coverage={{ all: [coverageWithLoose] }} />);
expect(screen.getByText("+ 2 modal.loosePills", { exact: false })).toBeInTheDocument();
});
it("shows prescription details section when prescription is enabled", () => {
const med: Medication = {
...mockMedication,
prescriptionEnabled: true,
prescriptionAuthorizedRefills: 5,
prescriptionRemainingRefills: 2,
prescriptionLowRefillThreshold: 1,
prescriptionExpiryDate: "2026-12-31",
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText(/form\.sections\.prescription/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.authorizedRefills/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.remainingRefills/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.lowThreshold/i)).toBeInTheDocument();
expect(screen.getByText(/prescription\.expiryDate/i)).toBeInTheDocument();
});
it("does not show prescription details section when prescription is disabled", () => {
const med: Medication = {
...mockMedication,
prescriptionEnabled: false,
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.queryByText(/form\.sections\.prescription/i)).not.toBeInTheDocument();
});
it("displays schedule information", () => {
render(<MedDetailModal {...defaultProps} />);
// Should have schedule section
const scheduleSection = document.querySelector(".med-detail-schedules");
expect(scheduleSection).toBeInTheDocument();
});
it("renders med detail header", () => {
render(<MedDetailModal {...defaultProps} />);
const header = document.querySelector(".med-detail-header");
expect(header).toBeInTheDocument();
});
it("renders med detail body", () => {
render(<MedDetailModal {...defaultProps} />);
const body = document.querySelector(".med-detail-body");
expect(body).toBeInTheDocument();
});
it("shows configured pack count in package details, independent from current stock", () => {
const medWithConfiguredPacks: Medication = {
...mockMedication,
packCount: 11,
blistersPerPack: 5,
pillsPerBlister: 5,
};
const lowCurrentStockCoverage: Coverage = {
...mockCoverage,
medsLeft: 47,
};
render(
<MedDetailModal
{...defaultProps}
selectedMed={medWithConfiguredPacks}
coverage={{ all: [lowCurrentStockCoverage] }}
/>
);
const packsLabel = screen.getByText(/modal\.packs/i);
const packsValue = packsLabel.closest(".med-detail-item")?.querySelector(".med-detail-value");
expect(packsValue?.textContent).toBe("11");
});
});
describe("MedDetailModal without coverage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("works without coverage data", () => {
render(<MedDetailModal {...defaultProps} coverage={{ all: [] }} />);
// Should still render the medication name
expect(screen.getByText("Test Med")).toBeInTheDocument();
});
});
describe("MedDetailModal without optional fields", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("works without generic name", () => {
const med = { ...mockMedication, genericName: null };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText("Test Med")).toBeInTheDocument();
});
it("works without notes", () => {
const med = { ...mockMedication, notes: null };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText("Test Med")).toBeInTheDocument();
});
it("works without takenBy", () => {
const med = { ...mockMedication, takenBy: [] };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText("Test Med")).toBeInTheDocument();
});
it("works without expiryDate", () => {
const med = { ...mockMedication, expiryDate: null };
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
expect(screen.getByText("Test Med")).toBeInTheDocument();
});
});
describe("MedDetailModal with refill modal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows refill modal when open", () => {
render(<MedDetailModal {...defaultProps} showRefillModal={true} />);
// Modal should show refill section
const modal = document.querySelector(".modal-overlay");
expect(modal).toBeInTheDocument();
});
it("calls onCloseRefillModal when refill modal closed", () => {
const onCloseRefillModal = vi.fn();
render(<MedDetailModal {...defaultProps} showRefillModal={true} onCloseRefillModal={onCloseRefillModal} />);
// Modal close button
const closeButtons = document.querySelectorAll("button");
const cancelBtn = Array.from(closeButtons).find(
(btn) => btn.textContent?.includes("cancel") || btn.textContent?.includes("Cancel")
);
if (cancelBtn) {
fireEvent.click(cancelBtn);
}
});
it("calls onSubmitRefill when refill submitted", () => {
const onSubmitRefill = vi.fn();
render(<MedDetailModal {...defaultProps} showRefillModal={true} onSubmitRefill={onSubmitRefill} refillLoose={1} />);
const submitBtn = document.querySelector(".refill-modal .modal-footer .success") as HTMLButtonElement;
fireEvent.click(submitBtn);
expect(onSubmitRefill).toHaveBeenCalledWith(mockMedication.id, false);
});
it("disables refill submit button when no pills are entered", () => {
render(<MedDetailModal {...defaultProps} showRefillModal={true} refillPacks={0} refillLoose={0} />);
const submitBtn = document.querySelector(".refill-modal .modal-footer .success") as HTMLButtonElement;
expect(submitBtn).toBeDisabled();
});
it("shows singular refill preview text when total refill is one pill", () => {
const bottleMed: Medication = {
...mockMedication,
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 10,
};
render(<MedDetailModal {...defaultProps} selectedMed={bottleMed} showRefillModal={true} refillLoose={1} />);
expect(screen.getByText(/\+1 common\.pill/i)).toBeInTheDocument();
});
it("parses refill packs and loose pills inputs", () => {
const onRefillPacksChange = vi.fn();
const onRefillLooseChange = vi.fn();
render(
<MedDetailModal
{...defaultProps}
showRefillModal={true}
onRefillPacksChange={onRefillPacksChange}
onRefillLooseChange={onRefillLooseChange}
/>
);
const numberInputs = document.querySelectorAll(".refill-modal input[type='number']");
fireEvent.change(numberInputs[0], { target: { value: "3" } });
fireEvent.change(numberInputs[1], { target: { value: "5" } });
expect(onRefillPacksChange).toHaveBeenCalledWith(3);
expect(onRefillLooseChange).toHaveBeenCalledWith(5);
});
it("uses zero fallback for invalid refill input values", () => {
const onRefillPacksChange = vi.fn();
const onRefillLooseChange = vi.fn();
render(
<MedDetailModal
{...defaultProps}
showRefillModal={true}
onRefillPacksChange={onRefillPacksChange}
onRefillLooseChange={onRefillLooseChange}
/>
);
const numberInputs = document.querySelectorAll(".refill-modal input[type='number']");
fireEvent.change(numberInputs[0], { target: { value: "NaN" } });
fireEvent.change(numberInputs[1], { target: { value: "" } });
expect(onRefillPacksChange).toHaveBeenCalledWith(0);
expect(onRefillLooseChange).toHaveBeenCalledWith(0);
});
it("shows package size breakdown key for blister stock correction", () => {
render(<MedDetailModal {...defaultProps} showEditStockModal={true} />);
expect(screen.queryByText("editStock.packageSizeBreakdown")).not.toBeInTheDocument();
expect(document.querySelector(".edit-stock-live-breakdown")).toBeInTheDocument();
});
it("shows numeric package size text for bottle stock correction", () => {
const bottleMed: Medication = {
...mockMedication,
packageType: "bottle",
totalPills: 150,
looseTablets: 130,
};
render(<MedDetailModal {...defaultProps} selectedMed={bottleMed} showEditStockModal={true} />);
expect(screen.getByText("editStock.packageSize_150")).toBeInTheDocument();
});
it("shows bottles-based refill input for liquid container and preview in ml package amount", () => {
const liquidMed: Medication = {
...mockMedication,
name: "Liquid Med",
packageType: "liquid_container",
packCount: 1,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 150,
looseTablets: 150,
};
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} showRefillModal={true} refillLoose={150} />);
const refillModal = document.querySelector(".refill-modal");
expect(refillModal).not.toBeNull();
expect(within(refillModal as HTMLElement).getByText(/form\.bottles/i)).toBeInTheDocument();
expect(screen.queryByText(/refill\.pillsToAdd/i)).not.toBeInTheDocument();
expect(screen.getByText(/\+150 form\.packageAmountUnitMl/i)).toBeInTheDocument();
});
it("maps liquid refill bottle input to package amount in ml", () => {
const liquidMed: Medication = {
...mockMedication,
name: "Liquid Med",
packageType: "liquid_container",
packCount: 1,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 150,
looseTablets: 150,
};
const onRefillLooseChange = vi.fn();
const onRefillPacksChange = vi.fn();
render(
<MedDetailModal
{...defaultProps}
selectedMed={liquidMed}
showRefillModal={true}
onRefillLooseChange={onRefillLooseChange}
onRefillPacksChange={onRefillPacksChange}
refillLoose={0}
/>
);
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
fireEvent.change(input, { target: { value: "2" } });
expect(onRefillPacksChange).toHaveBeenCalledWith(2);
expect(onRefillLooseChange).toHaveBeenCalledWith(300);
});
it("shows tubes-based refill input for tube package and preview in g package amount", () => {
const tubeMed: Medication = {
...mockMedication,
name: "Tube Med",
packageType: "tube",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "g",
totalPills: 600,
looseTablets: 600,
};
render(<MedDetailModal {...defaultProps} selectedMed={tubeMed} showRefillModal={true} refillLoose={150} />);
const refillModal = document.querySelector(".refill-modal");
expect(refillModal).not.toBeNull();
expect(within(refillModal as HTMLElement).getByText(/form\.tubes/i)).toBeInTheDocument();
expect(screen.queryByText(/refill\.pillsToAdd/i)).not.toBeInTheDocument();
expect(screen.getByText(/\+150 form\.packageAmountUnitG/i)).toBeInTheDocument();
});
it("maps tube refill count input to package amount in g", () => {
const tubeMed: Medication = {
...mockMedication,
name: "Tube Med",
packageType: "tube",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "g",
totalPills: 600,
looseTablets: 600,
};
const onRefillLooseChange = vi.fn();
const onRefillPacksChange = vi.fn();
render(
<MedDetailModal
{...defaultProps}
selectedMed={tubeMed}
showRefillModal={true}
onRefillLooseChange={onRefillLooseChange}
onRefillPacksChange={onRefillPacksChange}
refillLoose={0}
/>
);
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
fireEvent.change(input, { target: { value: "2" } });
expect(onRefillPacksChange).toHaveBeenCalledWith(2);
expect(onRefillLooseChange).toHaveBeenCalledWith(300);
});
});
describe("MedDetailModal actions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders action buttons", () => {
render(<MedDetailModal {...defaultProps} />);
const buttons = document.querySelectorAll("button");
expect(buttons.length).toBeGreaterThan(0);
});
it("calls onOpenRefillModal when refill clicked", () => {
const onOpenRefillModal = vi.fn();
render(<MedDetailModal {...defaultProps} onOpenRefillModal={onOpenRefillModal} />);
const buttons = document.querySelectorAll("button");
const refillBtn = Array.from(buttons).find(
(btn) => btn.textContent?.includes("refill") || btn.textContent?.includes("Refill")
);
if (refillBtn) {
fireEvent.click(refillBtn);
expect(onOpenRefillModal).toHaveBeenCalled();
}
});
it("calls generateICS when export calendar button is clicked", () => {
const generateICSSpy = vi.spyOn(utils, "generateICS").mockImplementation(() => "BEGIN:VCALENDAR");
render(<MedDetailModal {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: /modal\.exportTooltip/i }));
expect(generateICSSpy).toHaveBeenCalledWith(mockMedication);
});
it("calls onOpenEditStockModal when stock correction icon is clicked", () => {
const onOpenEditStockModal = vi.fn();
render(<MedDetailModal {...defaultProps} onOpenEditStockModal={onOpenEditStockModal} />);
fireEvent.click(screen.getByRole("button", { name: /editStock\.buttonLabel/i }));
expect(onOpenEditStockModal).toHaveBeenCalledTimes(1);
});
it("does not render export calendar button when no blisters exist", () => {
const medWithoutBlisters: Medication = {
...mockMedication,
blisters: [],
};
render(<MedDetailModal {...defaultProps} selectedMed={medWithoutBlisters} />);
expect(screen.queryByRole("button", { name: /modal\.exportTooltip/i })).not.toBeInTheDocument();
});
});
describe("MedDetailModal with multiple blisters", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders multiple schedule entries", () => {
const med = {
...mockMedication,
blisters: [
{ usage: 1, every: 1, start: "2024-01-01T09:00:00" },
{ usage: 2, every: 7, start: "2024-01-01T20:00:00" },
],
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const _scheduleEntries = document.querySelectorAll(".schedule-entry");
// Should have multiple schedule entries
});
});
describe("MedDetailModal with image", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders medication avatar", () => {
render(<MedDetailModal {...defaultProps} />);
const avatar = document.querySelector(".med-avatar");
expect(avatar).toBeInTheDocument();
});
it("shows lightbox when image clicked", () => {
const onOpenImageLightbox = vi.fn();
const med = { ...mockMedication, imageUrl: "test-image.jpg" };
render(<MedDetailModal {...defaultProps} selectedMed={med} onOpenImageLightbox={onOpenImageLightbox} />);
const avatar = document.querySelector(".med-avatar");
if (avatar) {
fireEvent.click(avatar);
}
expect(onOpenImageLightbox).toHaveBeenCalledTimes(1);
});
it("renders lightbox when enabled and image is present", () => {
const med = { ...mockMedication, imageUrl: "test-image.jpg" };
render(<MedDetailModal {...defaultProps} selectedMed={med} showImageLightbox={true} />);
expect(document.querySelector(".lightbox-overlay")).toBeInTheDocument();
});
});
describe("MedDetailModal nested modal overlays", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("closes refill modal when clicking refill overlay", () => {
const onCloseRefillModal = vi.fn();
render(<MedDetailModal {...defaultProps} showRefillModal={true} onCloseRefillModal={onCloseRefillModal} />);
const overlays = document.querySelectorAll(".modal-overlay");
fireEvent.click(overlays[1]);
expect(onCloseRefillModal).toHaveBeenCalledTimes(1);
});
it("closes edit stock modal when clicking edit-stock overlay", () => {
const onCloseEditStockModal = vi.fn();
render(
<MedDetailModal {...defaultProps} showEditStockModal={true} onCloseEditStockModal={onCloseEditStockModal} />
);
const overlays = document.querySelectorAll(".modal-overlay");
fireEvent.click(overlays[1]);
expect(onCloseEditStockModal).toHaveBeenCalledTimes(1);
});
it("renders only edit stock modal in editStockOnly mode", () => {
render(<MedDetailModal {...defaultProps} showEditStockModal={true} editStockOnly={true} />);
expect(screen.getByText("editStock.title")).toBeInTheDocument();
expect(screen.queryByText("form.sections.schedule")).not.toBeInTheDocument();
});
it("closes edit stock modal on Escape", () => {
const onCloseEditStockModal = vi.fn();
render(
<MedDetailModal {...defaultProps} showEditStockModal={true} onCloseEditStockModal={onCloseEditStockModal} />
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onCloseEditStockModal).toHaveBeenCalled();
});
});
describe("MedDetailModal with low stock", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows stock status for low stock", () => {
const lowCoverage: Coverage = {
name: "Test Med",
medsLeft: 3,
daysLeft: 3,
depletionDate: "2024-01-05",
depletionTime: Date.now() + 3 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} coverage={{ all: [lowCoverage] }} />);
// Should render status indicator
const _statusElements = document.querySelectorAll(".danger, .warning, .success");
// Status should be visible
});
});
describe("MedDetailModal with refill history", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows refill history when expanded", () => {
const refillHistory: RefillEntry[] = [
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
];
render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
// Refill history should be visible
const modal = document.querySelector(".modal-overlay");
expect(modal).toBeInTheDocument();
});
it("calls onRefillHistoryExpandedChange when toggle clicked", () => {
const onRefillHistoryExpandedChange = vi.fn();
const refillHistory: RefillEntry[] = [
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
];
render(
<MedDetailModal
{...defaultProps}
refillHistory={refillHistory}
onRefillHistoryExpandedChange={onRefillHistoryExpandedChange}
/>
);
// Click expand toggle if exists
const expandButton = document.querySelector('[class*="expand"], [class*="toggle"]');
if (expandButton) {
fireEvent.click(expandButton);
}
});
});
describe("MedDetailModal intake schedule usage display", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not multiply usage by personCount when intakes have per-intake takenBy", () => {
// Two people at medication level, but each intake has its own takenBy
const med: Medication = {
...mockMedication,
takenBy: ["Alice", "Bob"],
blisters: [
{ usage: 1, every: 1, start: "2024-01-01T09:00:00" },
{ usage: 1, every: 1, start: "2024-01-01T21:00:00" },
],
intakes: [
{ usage: 1, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false },
{ usage: 1, every: 1, start: "2024-01-01T21:00:00", takenBy: "Bob", intakeRemindersEnabled: false },
],
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
// Each intake should show "1" in usage (not "2")
rows.forEach((el) => {
expect(el.textContent).toContain("1");
expect(el.textContent).not.toMatch(/^2\b/);
});
});
it("multiplies usage by personCount for legacy blisters without per-intake takenBy", () => {
// Two people at medication level, legacy blisters without intakes
const med: Medication = {
...mockMedication,
takenBy: ["Alice", "Bob"],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
// No intakes array - legacy format
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
// Legacy: 1 pill * 2 people = "2 pills"
expect(rows.length).toBe(1);
expect(rows[0].textContent).toContain("2");
});
it("shows correct usage for single person with per-intake takenBy", () => {
const med: Medication = {
...mockMedication,
takenBy: ["Alice"],
pillWeightMg: 500,
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00" }],
intakes: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false }],
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
expect(rows.length).toBe(1);
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
expect(rows[0].textContent).toContain("2");
expect(rows[0].textContent).toContain("1000");
});
});
describe("MedDetailModal partial blister normalization", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("carries partial pills into full blisters when partial reaches pillsPerBlister", () => {
const onEditStockFullBlistersChange = vi.fn();
const onEditStockPartialBlisterPillsChange = vi.fn();
const blisterMed: Medication = {
...mockMedication,
packCount: 10,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 0,
};
// full=12, partial=4 (one below pillsPerBlister)
render(
<MedDetailModal
{...defaultProps}
selectedMed={blisterMed}
showEditStockModal={true}
editStockFullBlisters={12}
editStockPartialBlisterPills={4}
editStockLoosePills={0}
onEditStockFullBlistersChange={onEditStockFullBlistersChange}
onEditStockPartialBlisterPillsChange={onEditStockPartialBlisterPillsChange}
/>
);
// Find the increment button for the partial blister pills stepper
// The partial stepper is the second stepper in the modal
const incrementButtons = document.querySelectorAll(".stepper-btn.increment");
const partialIncrementBtn = incrementButtons[1]; // full[0], partial[1], loose[2]
expect(partialIncrementBtn).not.toBeDisabled();
// Press + on partial: 4 → 5 = pillsPerBlister → normalization carries to full
fireEvent.click(partialIncrementBtn);
// full should have been called with 13 (12 + 1 carry)
expect(onEditStockFullBlistersChange).toHaveBeenCalledWith(13);
// partial should have been called with 0 (5 % 5 = 0)
expect(onEditStockPartialBlisterPillsChange).toHaveBeenCalledWith(0);
});
it("does not carry partial pills into full when below pillsPerBlister", () => {
const onEditStockFullBlistersChange = vi.fn();
const onEditStockPartialBlisterPillsChange = vi.fn();
const blisterMed: Medication = {
...mockMedication,
packCount: 10,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 0,
};
// full=12, partial=0
render(
<MedDetailModal
{...defaultProps}
selectedMed={blisterMed}
showEditStockModal={true}
editStockFullBlisters={12}
editStockPartialBlisterPills={0}
editStockLoosePills={0}
onEditStockFullBlistersChange={onEditStockFullBlistersChange}
onEditStockPartialBlisterPillsChange={onEditStockPartialBlisterPillsChange}
/>
);
const incrementButtons = document.querySelectorAll(".stepper-btn.increment");
const partialIncrementBtn = incrementButtons[1];
fireEvent.click(partialIncrementBtn);
// full should not change (1 partial pill with pbb=5 is NOT a carry)
expect(onEditStockFullBlistersChange).toHaveBeenCalledWith(12);
// partial should go to 1
expect(onEditStockPartialBlisterPillsChange).toHaveBeenCalledWith(1);
});
});
describe("MedDetailModal stock overflow warning", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows overflow warning icon when stock exceeds blister package capacity", () => {
const overflowCoverage: Coverage = {
name: "Test Med",
medsLeft: 49,
daysLeft: 49,
depletionDate: "2024-03-01",
depletionTime: Date.now() + 49 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
// For blister meds, denominator is package capacity (not current stock), so overflow is shown.
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).toBeInTheDocument();
});
it("does not show warning icon when stock is within package capacity", () => {
render(<MedDetailModal {...defaultProps} />);
// packageSize = 30, currentStock = 25 < 30
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
it("does not show warning icon when stock equals package capacity", () => {
const exactCoverage: Coverage = {
name: "Test Med",
medsLeft: 30,
daysLeft: 30,
depletionDate: "2024-02-01",
depletionTime: Date.now() + 30 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} coverage={{ all: [exactCoverage] }} />);
// packageSize = 30, currentStock = 30 — equal, no warning
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
});
describe("MedDetailModal amount-based stock display", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows current liquid stock against configured structural capacity", () => {
const liquidMed: Medication = {
...mockMedication,
id: 20,
name: "Liquid Multi",
packageType: "liquid_container",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 450,
looseTablets: 450,
};
const liquidCoverage: Coverage = {
name: "Liquid Multi",
medsLeft: 450,
daysLeft: 45,
depletionDate: "2024-04-01",
depletionTime: Date.now() + 45 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} coverage={{ all: [liquidCoverage] }} />);
expect(screen.getByText("450 / 600 form.packageAmountUnitMl")).toBeInTheDocument();
expect(screen.queryByText("450 / 450 form.packageAmountUnitMl")).not.toBeInTheDocument();
});
});
describe("MedDetailModal bottle package type", () => {
const bottleMed: Medication = {
id: 2,
name: "Bottle Med",
genericName: null,
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 80,
totalPills: 100,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
updatedAt: null,
expiryDate: null,
notes: null,
};
const bottleCoverage: Coverage = {
name: "Bottle Med",
medsLeft: 80,
daysLeft: 80,
depletionDate: "2024-06-01",
depletionTime: Date.now() + 80 * 86400000,
nextDose: null,
};
const bottleProps = {
...defaultProps,
selectedMed: bottleMed,
coverage: { all: [bottleCoverage] },
};
beforeEach(() => {
vi.clearAllMocks();
});
it("does not show blister fields in stock info section", () => {
render(<MedDetailModal {...bottleProps} />);
// Should show current stock
expect(screen.getByText(/modal\.currentStock/i)).toBeInTheDocument();
// Should NOT show full blisters or open blister labels
expect(screen.queryByText(/table\.fullBlisters/i)).not.toBeInTheDocument();
expect(screen.queryByText(/table\.openBlister/i)).not.toBeInTheDocument();
});
it("shows bottle type in package details section", () => {
render(<MedDetailModal {...bottleProps} />);
// Should show package type as bottle
expect(screen.getByText(/form\.packageTypeBottle/i)).toBeInTheDocument();
// Should show total capacity
expect(screen.getByText(/form\.totalCapacity/i)).toBeInTheDocument();
});
it("shows pills-only refill modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showRefillModal={true} />);
// Should show pills to add label
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
// Should NOT show packs label in refill
const _refillModal = document.querySelector(".refill-modal");
// Packs label should not be present for bottle type
expect(screen.queryByText("refill.packs")).not.toBeInTheDocument();
});
it("parses bottle refill pills input", () => {
const onRefillLooseChange = vi.fn();
render(<MedDetailModal {...bottleProps} showRefillModal={true} onRefillLooseChange={onRefillLooseChange} />);
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
fireEvent.change(input, { target: { value: "7" } });
expect(onRefillLooseChange).toHaveBeenCalledWith(7);
});
it("uses zero fallback for invalid bottle refill input", () => {
const onRefillLooseChange = vi.fn();
render(<MedDetailModal {...bottleProps} showRefillModal={true} onRefillLooseChange={onRefillLooseChange} />);
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
expect(onRefillLooseChange).toHaveBeenCalledWith(0);
});
it("shows looseTablets as total capacity fallback when totalPills is null (backward compat)", () => {
// Old medications created before totalPills column existed
const oldBottleMed: Medication = {
...bottleMed,
totalPills: null,
looseTablets: 180,
};
const oldCoverage: Coverage = {
name: "Bottle Med",
medsLeft: 138,
daysLeft: 138,
depletionDate: "2024-06-01",
depletionTime: Date.now() + 138 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...bottleProps} selectedMed={oldBottleMed} coverage={{ all: [oldCoverage] }} />);
// Total Capacity should show 180 (looseTablets), not "—"
const capacityLabel = screen.getByText(/form\.totalCapacity/i);
const capacityValue = capacityLabel.closest(".med-detail-item")?.querySelector(".med-detail-value");
expect(capacityValue?.textContent).toBe("180");
});
it("shows total pills input in edit stock modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showEditStockModal={true} />);
// Should show total pills label
expect(screen.getByText(/editStock\.totalPills/i)).toBeInTheDocument();
// Should NOT show full blisters or partial blister labels
expect(screen.queryByText(/editStock\.fullBlisters/i)).not.toBeInTheDocument();
expect(screen.queryByText(/editStock\.partialBlisterPills/i)).not.toBeInTheDocument();
});
});