feat: reports, timeline toggles, and stock correction improvements (#236)
* refactor(frontend): modularize styles and polish modal/ui interactions * feat: add report workflow and timeline/settings improvements * fix: resolve CI failures for backend typing, lint, and playwright config
This commit is contained in:
@@ -92,4 +92,22 @@ describe("ConfirmModal", () => {
|
||||
const confirmBtn = screen.getByText("Yes");
|
||||
expect(confirmBtn.className).toContain("success");
|
||||
});
|
||||
|
||||
it("applies warning variant when specified", () => {
|
||||
render(<ConfirmModal {...defaultProps} confirmVariant="warning" />);
|
||||
const confirmBtn = screen.getByText("Yes");
|
||||
expect(confirmBtn.className).toContain("warning");
|
||||
});
|
||||
|
||||
it("applies custom overlay class", () => {
|
||||
const { container } = render(<ConfirmModal {...defaultProps} overlayClassName="nested-confirm" />);
|
||||
const overlay = container.querySelector(".modal-overlay");
|
||||
expect(overlay?.className).toContain("nested-confirm");
|
||||
});
|
||||
|
||||
it("calls onCancel when Escape is pressed", () => {
|
||||
render(<ConfirmModal {...defaultProps} />);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DateInput } from "../../components/DateInput";
|
||||
|
||||
vi.mock("../../utils/formatters", () => ({
|
||||
formatDate: vi.fn(() => "14.02.2026"),
|
||||
getNumericLocale: vi.fn(() => "de-DE"),
|
||||
}));
|
||||
|
||||
describe("DateInput", () => {
|
||||
it("renders placeholder display when value is empty", () => {
|
||||
render(<DateInput value="" onChange={vi.fn()} placeholder="Select date" />);
|
||||
|
||||
expect(screen.getByText("Select date")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("")).toHaveAttribute("type", "date");
|
||||
});
|
||||
|
||||
it("renders formatted date display when value exists", () => {
|
||||
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("14.02.2026")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("2026-02-14")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tries showPicker on wrapper click", () => {
|
||||
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("2026-02-14") as HTMLInputElement & {
|
||||
showPicker?: () => void;
|
||||
};
|
||||
const showPicker = vi.fn();
|
||||
input.showPicker = showPicker;
|
||||
|
||||
fireEvent.click(input.closest(".date-input-wrapper") as HTMLElement);
|
||||
expect(showPicker).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to focus when showPicker throws", () => {
|
||||
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("2026-02-14") as HTMLInputElement & {
|
||||
showPicker?: () => void;
|
||||
};
|
||||
input.showPicker = vi.fn(() => {
|
||||
throw new Error("showPicker not supported");
|
||||
});
|
||||
const focusSpy = vi.spyOn(input, "focus").mockImplementation(() => {});
|
||||
|
||||
fireEvent.click(input.closest(".date-input-wrapper") as HTMLElement);
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("triggers picker fallback on Enter and Space", () => {
|
||||
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("2026-02-14") as HTMLInputElement & {
|
||||
showPicker?: () => void;
|
||||
};
|
||||
const showPicker = vi.fn();
|
||||
input.showPicker = showPicker;
|
||||
const wrapper = input.closest(".date-input-wrapper") as HTMLElement;
|
||||
|
||||
fireEvent.keyDown(wrapper, { key: "Enter" });
|
||||
fireEvent.keyDown(wrapper, { key: " " });
|
||||
|
||||
expect(showPicker).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DateTimeInput } from "../../components/DateTimeInput";
|
||||
|
||||
vi.mock("../../utils/formatters", () => ({
|
||||
formatDateTime: vi.fn(() => "14.02.2026, 20:30"),
|
||||
getNumericLocale: vi.fn(() => "de-DE"),
|
||||
}));
|
||||
|
||||
describe("DateTimeInput", () => {
|
||||
it("renders placeholder when value is empty", () => {
|
||||
render(<DateTimeInput value="" onChange={vi.fn()} placeholder="Select date time" />);
|
||||
|
||||
expect(screen.getByText("Select date time")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("")).toHaveAttribute("type", "datetime-local");
|
||||
});
|
||||
|
||||
it("renders formatted datetime display", () => {
|
||||
render(<DateTimeInput value="2026-02-14T20:30" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("14.02.2026, 20:30")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("2026-02-14T20:30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses picker on click and keyboard", () => {
|
||||
render(<DateTimeInput value="2026-02-14T20:30" onChange={vi.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("2026-02-14T20:30") as HTMLInputElement & {
|
||||
showPicker?: () => void;
|
||||
};
|
||||
const showPicker = vi.fn();
|
||||
input.showPicker = showPicker;
|
||||
const wrapper = input.closest(".date-input-wrapper") as HTMLElement;
|
||||
|
||||
fireEvent.click(wrapper);
|
||||
fireEvent.keyDown(wrapper, { key: "Enter" });
|
||||
|
||||
expect(showPicker).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,15 @@ describe("Lightbox", () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when Escape key is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onClose when image is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||
|
||||
@@ -64,6 +64,8 @@ const defaultProps = {
|
||||
onEditStockFullBlistersChange: vi.fn(),
|
||||
editStockPartialBlisterPills: 0,
|
||||
onEditStockPartialBlisterPillsChange: vi.fn(),
|
||||
editStockLoosePills: 0,
|
||||
onEditStockLoosePillsChange: vi.fn(),
|
||||
editStockSaving: false,
|
||||
onSubmitStockCorrection: vi.fn(),
|
||||
};
|
||||
@@ -100,7 +102,8 @@ describe("MedDetailModal", () => {
|
||||
it("renders close button", () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
const closeBtn = screen.getByText("×");
|
||||
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
|
||||
const closeBtn = closeButtons[0];
|
||||
expect(closeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -108,7 +111,8 @@ describe("MedDetailModal", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const closeBtn = screen.getByText("×");
|
||||
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
|
||||
const closeBtn = closeButtons[0];
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
@@ -144,6 +148,23 @@ describe("MedDetailModal", () => {
|
||||
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,
|
||||
@@ -341,6 +362,26 @@ describe("MedDetailModal with refill modal", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal actions", () => {
|
||||
@@ -373,10 +414,18 @@ describe("MedDetailModal actions", () => {
|
||||
const generateICSSpy = vi.spyOn(utils, "generateICS").mockImplementation(() => "BEGIN:VCALENDAR");
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle("modal.exportTooltip"));
|
||||
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,
|
||||
@@ -384,7 +433,7 @@ describe("MedDetailModal actions", () => {
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={medWithoutBlisters} />);
|
||||
expect(screen.queryByTitle("modal.exportTooltip")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /modal\.exportTooltip/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -465,6 +514,23 @@ describe("MedDetailModal nested modal overlays", () => {
|
||||
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", () => {
|
||||
@@ -592,12 +658,93 @@ describe("MedDetailModal intake schedule usage display", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 warning icon when stock exceeds package capacity", () => {
|
||||
it("does not show overflow warning icon with live stock denominator", () => {
|
||||
const overflowCoverage: Coverage = {
|
||||
name: "Test Med",
|
||||
medsLeft: 49,
|
||||
@@ -609,10 +756,9 @@ describe("MedDetailModal stock overflow warning", () => {
|
||||
|
||||
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
|
||||
|
||||
// packageSize = 1 * 1 * 30 + 0 = 30, currentStock = 49 > 30
|
||||
// Live denominator uses current stock, so overflow warning is not shown in detail row.
|
||||
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
expect(warningIcon?.getAttribute("data-tooltip")).toBe("tooltips.stockExceedsCapacity");
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show warning icon when stock is within package capacity", () => {
|
||||
|
||||
@@ -170,10 +170,11 @@ describe("MobileEditModal", () => {
|
||||
expect(screen.getByText(/form\.pillsPerBlister/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders loose tablets input", () => {
|
||||
it("does not render loose tablets input in package section", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.loose/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/form\.loosePills/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/form\.total/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders intake schedules section", () => {
|
||||
@@ -206,14 +207,14 @@ describe("MobileEditModal", () => {
|
||||
it("renders add intake button", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /form\.blisters\.addIntake/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onAddIntake when add intake clicked", () => {
|
||||
const onAddIntake = vi.fn();
|
||||
render(<MobileEditModal {...defaultProps} onAddIntake={onAddIntake} />);
|
||||
|
||||
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
|
||||
const addBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i });
|
||||
fireEvent.click(addBtn);
|
||||
|
||||
expect(onAddIntake).toHaveBeenCalledTimes(1);
|
||||
@@ -698,7 +699,7 @@ describe("MobileEditModal optional fields", () => {
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} onAddIntake={onAddIntake} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/form\.blisters\.addIntake/i));
|
||||
fireEvent.click(screen.getByRole("button", { name: /form\.blisters\.addIntake/i }));
|
||||
expect(onAddIntake).toHaveBeenCalledWith("OnlyPerson");
|
||||
});
|
||||
});
|
||||
@@ -714,29 +715,6 @@ describe("MobileEditModal bottle package type", () => {
|
||||
totalPills: "100",
|
||||
};
|
||||
|
||||
it("shows pills-only refill form for bottle type when editing", () => {
|
||||
render(<MobileEditModal {...defaultProps} form={bottleForm} editingId={1} />);
|
||||
|
||||
// Should show "pillsToAdd" label for bottle
|
||||
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
|
||||
|
||||
// Should NOT show "packs" label in refill section
|
||||
const refillSection = document.querySelector(".refill-section");
|
||||
expect(refillSection).toBeInTheDocument();
|
||||
expect(refillSection!.textContent).not.toContain("refill.packs");
|
||||
expect(refillSection!.textContent).not.toContain("refill.loosePills");
|
||||
});
|
||||
|
||||
it("shows packs and loose refill form for blister type when editing", () => {
|
||||
render(<MobileEditModal {...defaultProps} form={defaultForm} editingId={1} />);
|
||||
|
||||
// Should show "packs" and "loosePills" labels for blister
|
||||
const refillSection = document.querySelector(".refill-section");
|
||||
expect(refillSection).toBeInTheDocument();
|
||||
expect(refillSection!.textContent).toContain("refill.packs");
|
||||
expect(refillSection!.textContent).toContain("refill.loosePills");
|
||||
});
|
||||
|
||||
it("shows totalCapacity and currentPills fields for bottle form", () => {
|
||||
render(<MobileEditModal {...defaultProps} form={bottleForm} />);
|
||||
|
||||
@@ -752,7 +730,7 @@ describe("MobileEditModal bottle package type", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobileEditModal refill and image actions", () => {
|
||||
describe("MobileEditModal image actions", () => {
|
||||
const baseMed = {
|
||||
id: 1,
|
||||
name: "Aspirin",
|
||||
@@ -776,52 +754,6 @@ describe("MobileEditModal refill and image actions", () => {
|
||||
imageUrl: null,
|
||||
};
|
||||
|
||||
it("calls onSubmitRefill when refill button is clicked", () => {
|
||||
const onSubmitRefill = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MobileEditModal
|
||||
{...defaultProps}
|
||||
editingId={1}
|
||||
meds={[baseMed]}
|
||||
refillLoose={2}
|
||||
onSubmitRefill={onSubmitRefill}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /refill\.button/i }));
|
||||
expect(onSubmitRefill).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("disables refill button when refill values are empty", () => {
|
||||
render(<MobileEditModal {...defaultProps} editingId={1} meds={[baseMed]} refillPacks={0} refillLoose={0} />);
|
||||
|
||||
const refillButton = screen.getByRole("button", { name: /refill\.button/i });
|
||||
expect(refillButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows refill preview for singular pill", () => {
|
||||
render(<MobileEditModal {...defaultProps} editingId={1} meds={[baseMed]} refillPacks={0} refillLoose={1} />);
|
||||
|
||||
expect(document.querySelector(".refill-preview")?.textContent).toContain("+1 common.pill");
|
||||
});
|
||||
|
||||
it("disables refill button while refill is saving", () => {
|
||||
render(
|
||||
<MobileEditModal
|
||||
{...defaultProps}
|
||||
editingId={1}
|
||||
meds={[baseMed]}
|
||||
refillPacks={1}
|
||||
refillLoose={0}
|
||||
refillSaving={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const refillButton = screen.getByRole("button", { name: /common\.saving/i });
|
||||
expect(refillButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls onUploadMedImage when selecting a file", () => {
|
||||
const onUploadMedImage = vi.fn().mockResolvedValue(undefined);
|
||||
render(<MobileEditModal {...defaultProps} editingId={1} meds={[baseMed]} onUploadMedImage={onUploadMedImage} />);
|
||||
|
||||
@@ -65,4 +65,28 @@ describe("ProfileModal", () => {
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed on overlay", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={true} onClose={onClose} />);
|
||||
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
if (overlay) {
|
||||
fireEvent.keyDown(overlay, { key: "Escape" });
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not close on non-escape keydown", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal isOpen={true} onClose={onClose} />);
|
||||
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
if (overlay) {
|
||||
fireEvent.keyDown(overlay, { key: "Enter" });
|
||||
}
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ReportModal from "../../components/ReportModal";
|
||||
import type { Medication } from "../../types";
|
||||
|
||||
function createMedication(overrides: Partial<Medication> = {}): Medication {
|
||||
return {
|
||||
id: 1,
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
takenBy: ["Alice"],
|
||||
packageType: "blister",
|
||||
packCount: 2,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ReportModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders and closes when cancel is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
|
||||
expect(screen.getByText(/report\.title/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.cancel/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates text report and closes modal", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 2,
|
||||
dosesDismissed: 0,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generates printable report when PDF format is selected", async () => {
|
||||
const onClose = vi.fn();
|
||||
const mockWrite = vi.fn();
|
||||
const mockClose = vi.fn();
|
||||
const mockPrint = vi.fn();
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue({
|
||||
document: {
|
||||
write: mockWrite,
|
||||
close: mockClose,
|
||||
},
|
||||
onload: null,
|
||||
print: mockPrint,
|
||||
} as unknown as Window);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
firstDoseAt: null,
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows person filter and supports deselect/select all", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/report\.filterByPerson/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
||||
expect(screen.getByText("Alice Med")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
|
||||
expect(screen.getByRole("button", { name: /report\.generate/i })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.selectAll/i }));
|
||||
expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("generates markdown report and keeps modal open on fetch error", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
||||
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatMd/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,7 @@ describe("ShareDialog", () => {
|
||||
|
||||
it("calls onClose when close button is clicked", () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("×"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -73,13 +73,13 @@ describe("ShareDialog", () => {
|
||||
|
||||
it("calls onCopyShareLink when copy button is clicked", () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
|
||||
fireEvent.click(screen.getByText("📋"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /share\.copyLink/i }));
|
||||
expect(defaultProps.onCopyShareLink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows copied indicator after copy", () => {
|
||||
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" shareCopied={true} />);
|
||||
expect(screen.getByText("✓")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /share\.copied/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects link text when input is clicked", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SharedSchedule } from "../../components/SharedSchedule";
|
||||
@@ -13,95 +13,22 @@ function renderSharedSchedule(path: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function expandTodayIfCollapsed() {
|
||||
const todayDivider = document.querySelector(".day-block.today .day-divider.clickable") as HTMLDivElement;
|
||||
expect(todayDivider).toBeInTheDocument();
|
||||
const todayBlock = document.querySelector(".day-block.today") as HTMLDivElement;
|
||||
if (todayBlock?.classList.contains("collapsed")) {
|
||||
fireEvent.click(todayDivider);
|
||||
}
|
||||
}
|
||||
|
||||
function createSharedData(overrides: Record<string, unknown> = {}) {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
yesterday.setHours(9, 0, 0, 0);
|
||||
|
||||
function createSharedData() {
|
||||
return {
|
||||
sharedBy: "Owner",
|
||||
takenBy: "Max",
|
||||
scheduleDays: 30,
|
||||
shareStockStatus: true,
|
||||
stockCalculationMode: "automatic",
|
||||
stockThresholds: {
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90,
|
||||
reminderDaysBefore: 7,
|
||||
expiryWarningDays: 30,
|
||||
},
|
||||
medications: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Ibuprofen",
|
||||
genericName: "Ibu",
|
||||
takenBy: ["Max"],
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
expiryDate: null,
|
||||
notes: null,
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [{ usage: 1, every: 1, start: yesterday.toISOString() }],
|
||||
intakes: [
|
||||
{ usage: 1, every: 1, start: yesterday.toISOString(), takenBy: "Max", intakeRemindersEnabled: false },
|
||||
],
|
||||
updatedAt: null,
|
||||
dismissedUntil: null,
|
||||
lastStockCorrectionAt: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
medications: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mockShareFetch(
|
||||
token: string,
|
||||
sharedData: Record<string, unknown>,
|
||||
doses: Array<{ doseId: string; dismissed?: boolean }> = []
|
||||
) {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === `/api/share/${token}/doses` && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses }) });
|
||||
}
|
||||
if (url === `/api/share/${token}`) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
|
||||
}
|
||||
if (url === `/api/share/${token}/doses` && init?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
if (url.startsWith(`/api/share/${token}/doses/`) && init?.method === "DELETE") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
describe.skip("SharedSchedule", () => {
|
||||
describe("SharedSchedule", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(global, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
|
||||
vi.spyOn(global, "clearInterval").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
const first = String(args[0] ?? "");
|
||||
if (first.includes("not wrapped in act")) return;
|
||||
});
|
||||
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
|
||||
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -109,52 +36,27 @@ describe.skip("SharedSchedule", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("closes theme menu when clicking outside", async () => {
|
||||
const sharedData = createSharedData();
|
||||
mockShareFetch("token-123", sharedData);
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("theme.title"));
|
||||
expect(document.querySelector(".theme-menu.open")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(document.body);
|
||||
expect(document.querySelector(".theme-menu.open")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state initially", async () => {
|
||||
let resolveShare: ((value: unknown) => void) | null = null;
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url === "/api/share/token-123/doses") {
|
||||
it("renders shared schedule shell for valid token", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
return new Promise((resolve) => {
|
||||
resolveShare = resolve;
|
||||
});
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(createSharedData()) });
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
expect(screen.getByText("common.loading")).toBeInTheDocument();
|
||||
|
||||
resolveShare?.({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(createSharedData()),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("common.loading")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders not found error for 404 links", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
it("renders not found state for missing share link", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url === "/api/share/token-123/doses") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
@@ -171,26 +73,8 @@ describe.skip("SharedSchedule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders generic error for unexpected status codes", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url === "/api/share/token-123/doses") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) });
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("share.error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders expired link state for 410 responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
it("renders expired state for expired share links", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url === "/api/share/token-123/doses") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
@@ -216,31 +100,13 @@ describe.skip("SharedSchedule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders schedule shell for valid shared data", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url === "/api/share/token-123/doses") {
|
||||
it("renders generic error when loading share data fails", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
sharedBy: "Owner",
|
||||
takenBy: "Max",
|
||||
scheduleDays: 30,
|
||||
shareStockStatus: true,
|
||||
stockCalculationMode: "automatic",
|
||||
stockThresholds: {
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90,
|
||||
reminderDaysBefore: 7,
|
||||
expiryWarningDays: 30,
|
||||
},
|
||||
medications: [],
|
||||
}),
|
||||
});
|
||||
return Promise.reject(new Error("network failed"));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
@@ -248,265 +114,7 @@ describe.skip("SharedSchedule", () => {
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share.scheduleFor/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
||||
expect(screen.getByText("share.error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens theme menu and switches to light theme", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url === "/api/share/token-123/doses") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
sharedBy: "Owner",
|
||||
takenBy: "Max",
|
||||
scheduleDays: 30,
|
||||
shareStockStatus: true,
|
||||
stockCalculationMode: "automatic",
|
||||
stockThresholds: {
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90,
|
||||
reminderDaysBefore: 7,
|
||||
expiryWarningDays: 30,
|
||||
},
|
||||
medications: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTitle("theme.title"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /theme\.light/i }));
|
||||
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("renders schedule rows for populated data and can expand future days", async () => {
|
||||
const sharedData = createSharedData();
|
||||
mockShareFetch("token-123", sharedData);
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const futureToggle = document.querySelector(".future-days-toggle");
|
||||
expect(futureToggle).toBeInTheDocument();
|
||||
fireEvent.click(futureToggle as Element);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll(".day-block").length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("marks and undoes a dose via shared API", async () => {
|
||||
const sharedData = createSharedData();
|
||||
mockShareFetch("token-123", sharedData);
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const takeButton = document.querySelector(".dose-btn.take:not([disabled])") as HTMLButtonElement;
|
||||
expect(takeButton).toBeInTheDocument();
|
||||
fireEvent.click(takeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
|
||||
"/api/share/token-123/doses",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("undos a taken dose via shared API", async () => {
|
||||
const sharedData = createSharedData();
|
||||
const today = new Date();
|
||||
const todayDateOnlyMs = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
||||
mockShareFetch("token-123", sharedData, [{ doseId: `1-0-${todayDateOnlyMs}-Max` }]);
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expandTodayIfCollapsed();
|
||||
|
||||
const undoButton = await waitFor(() => {
|
||||
const button = document.querySelector(".dose-btn.undo") as HTMLButtonElement | null;
|
||||
expect(button).toBeInTheDocument();
|
||||
return button as HTMLButtonElement;
|
||||
});
|
||||
fireEvent.click(undoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mock.calls.some((call) => {
|
||||
const [url, init] = call as [string, RequestInit | undefined];
|
||||
return typeof url === "string" && url.includes("/api/share/token-123/doses/") && init?.method === "DELETE";
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("hides stock status chips when shareStockStatus is false", async () => {
|
||||
const sharedData = createSharedData({ shareStockStatus: false });
|
||||
mockShareFetch("token-123", sharedData);
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(document.querySelector(".status-chip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens and closes lightbox for medication image", async () => {
|
||||
const pushStateSpy = vi.spyOn(window.history, "pushState").mockImplementation(() => {});
|
||||
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
const sharedData = createSharedData({
|
||||
medications: [
|
||||
{
|
||||
...createSharedData().medications[0],
|
||||
imageUrl: "ibuprofen.png",
|
||||
},
|
||||
],
|
||||
});
|
||||
mockShareFetch("token-123", sharedData);
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expandTodayIfCollapsed();
|
||||
|
||||
const avatar = await waitFor(() => {
|
||||
const element = document.querySelector(".day-block.today .med-avatar.clickable") as HTMLDivElement | null;
|
||||
expect(element).toBeInTheDocument();
|
||||
return element as HTMLDivElement;
|
||||
});
|
||||
fireEvent.click(avatar);
|
||||
|
||||
expect(pushStateSpy).toHaveBeenCalled();
|
||||
expect(document.querySelector(".lightbox-overlay")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(document.querySelector(".lightbox-overlay") as HTMLDivElement);
|
||||
expect(backSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reverts optimistic taken state when mark-dose request fails", async () => {
|
||||
const sharedData = createSharedData();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
|
||||
}
|
||||
if (url === "/api/share/token-123/doses" && init?.method === "POST") {
|
||||
return Promise.reject(new Error("post failed"));
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
});
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expandTodayIfCollapsed();
|
||||
|
||||
const takeButton = await waitFor(() => {
|
||||
const button = document.querySelector(".dose-btn.take:not([disabled])") as HTMLButtonElement | null;
|
||||
expect(button).toBeInTheDocument();
|
||||
return button as HTMLButtonElement;
|
||||
});
|
||||
fireEvent.click(takeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.undo")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".dose-btn.take:not([disabled])")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("reverts optimistic undo state when undo request fails", async () => {
|
||||
const today = new Date();
|
||||
const todayDateOnlyMs = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
||||
const sharedData = createSharedData();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ doses: [{ doseId: `1-0-${todayDateOnlyMs}-Max` }] }),
|
||||
});
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
|
||||
}
|
||||
if (url.startsWith("/api/share/token-123/doses/") && init?.method === "DELETE") {
|
||||
return Promise.reject(new Error("delete failed"));
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
});
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expandTodayIfCollapsed();
|
||||
|
||||
const undoButton = await waitFor(() => {
|
||||
const button = document.querySelector(".dose-btn.undo") as HTMLButtonElement | null;
|
||||
expect(button).toBeInTheDocument();
|
||||
return button as HTMLButtonElement;
|
||||
});
|
||||
fireEvent.click(undoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.undo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("persists manual collapse state in localStorage", async () => {
|
||||
const setItemSpy = vi.spyOn(window.localStorage, "setItem");
|
||||
const sharedData = createSharedData();
|
||||
mockShareFetch("token-123", sharedData);
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const todayDivider = document.querySelector(".day-block.today .day-divider.clickable") as HTMLDivElement;
|
||||
fireEvent.click(todayDivider);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalled();
|
||||
expect(
|
||||
setItemSpy.mock.calls.some((call) => String(call[0]).includes("share_token-123_collapsedDays")) ||
|
||||
setItemSpy.mock.calls.some((call) => String(call[0]).includes("share_token-123_expandedDays"))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SharedSchedule } from "../../components/SharedSchedule";
|
||||
|
||||
function renderSharedSchedule(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function createSharedData(overrides: Record<string, unknown> = {}) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
yesterday.setHours(9, 0, 0, 0);
|
||||
|
||||
return {
|
||||
sharedBy: "Owner",
|
||||
takenBy: "Max",
|
||||
scheduleDays: 30,
|
||||
shareStockStatus: true,
|
||||
shareScheduleTodayOnly: true,
|
||||
stockCalculationMode: "automatic",
|
||||
stockThresholds: {
|
||||
lowStockDays: 7,
|
||||
normalStockDays: 30,
|
||||
highStockDays: 90,
|
||||
reminderDaysBefore: 7,
|
||||
expiryWarningDays: 30,
|
||||
},
|
||||
medications: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Ibuprofen",
|
||||
genericName: "Ibu",
|
||||
takenBy: ["Max"],
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
expiryDate: null,
|
||||
notes: null,
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [{ usage: 1, every: 1, start: yesterday.toISOString() }],
|
||||
intakes: [
|
||||
{ usage: 1, every: 1, start: yesterday.toISOString(), takenBy: "Max", intakeRemindersEnabled: false },
|
||||
],
|
||||
updatedAt: null,
|
||||
dismissedUntil: null,
|
||||
lastStockCorrectionAt: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SharedSchedule today-only", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
|
||||
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("hides past and future sections when shareScheduleTodayOnly is enabled", async () => {
|
||||
const sharedData = createSharedData();
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(document.querySelector(".day-block.today")).toBeInTheDocument();
|
||||
expect(document.querySelector(".past-days-toggle")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".future-days-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user