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:
Daniel Volz
2026-02-20 18:52:59 +01:00
committed by GitHub
parent 89d565bc9d
commit 052751b2ba
74 changed files with 8815 additions and 4027 deletions
@@ -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();
});
});
+158 -15
View File
@@ -198,8 +198,36 @@ describe("useRefill", () => {
expect(result.current.showEditStockModal).toBe(true);
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "editStock" }, "");
expect(result.current.editStockFullBlisters).toBe(2); // 20 / 10 = 2
expect(result.current.editStockPartialBlisterPills).toBe(0); // 20 % 10 = 0
expect(result.current.editStockFullBlisters).toBe(1); // (20 - 5 loose) / 10 = 1
expect(result.current.editStockPartialBlisterPills).toBe(5); // (20 - 5 loose) % 10 = 5
expect(result.current.editStockLoosePills).toBe(5); // loose pills are tracked separately
});
it("prefills bottle correction with total pills in partial field", () => {
const { result } = renderHook(() => useRefill());
const bottleMed: Medication = {
id: 4,
name: "Bottle Test",
packageType: "bottle",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
stockAdjustment: -2,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Bottle Test", medsLeft: 148, daysLeft: 148 }] as Coverage[],
});
});
expect(result.current.editStockFullBlisters).toBe(0);
expect(result.current.editStockPartialBlisterPills).toBe(148);
});
it("closes edit stock modal using history back", () => {
@@ -319,24 +347,23 @@ describe("useRefill", () => {
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// Pre-fill: user sees 148 pills (148 / 1 = 148 full, 0 partial)
// Pre-fill for bottle: full=0, partial=current total
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Pills in a Box", medsLeft: 148, daysLeft: 148 }] as Coverage[],
});
});
// User adds +1 → 149 full blisters (pillsPerBlister=1)
// User sets total to 149 pills.
act(() => {
result.current.setEditStockFullBlisters(149);
result.current.setEditStockPartialBlisterPills(0);
result.current.setEditStockPartialBlisterPills(149);
});
await act(async () => {
await result.current.submitStockCorrection(4, bottleMed, mockLoadMeds);
});
// desiredTotal = 149 * 1 + 0 = 149
// desiredTotal = 149
// baseTotal (fixed) = getPackageSize(bottle) = looseTablets = 150
// newStockAdjustment = 149 - 150 = -1
// → getMedTotal = 150 + (-1) = 149 ✓
@@ -348,8 +375,8 @@ describe("useRefill", () => {
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
});
it("stock correction uses correct base for blister type medications", async () => {
// Ensure blister type still works correctly after the bottle fix
it("stock correction clamps blister totals to package size", async () => {
// Ensure blister correction enforces configured package max.
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const blisterMed: Medication = {
@@ -379,7 +406,7 @@ describe("useRefill", () => {
});
});
// User changes to 27 (+1): 5 full + 2 partial
// User attempts to set 27 (+1): 5 full + 2 partial.
act(() => {
result.current.setEditStockFullBlisters(5);
result.current.setEditStockPartialBlisterPills(2);
@@ -389,16 +416,132 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(2, blisterMed, mockLoadMeds);
});
// desiredTotal = 5 * 5 + 2 = 27
// baseTotal = getPackageSize(blister) = 1*5*5 + 0 = 25
// newStockAdjustment = 27 - 25 = 2
// → getMedTotal = 25 + 2 = 27 ✓
// desiredTotal is capped to package max (25)
// baseTotal = getPackageSize(blister) = 25
// newStockAdjustment = 25 - 25 = 0
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call: [string, RequestInit]) => call[0] === "/api/medications/2/stock-adjustment"
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
expect(body.stockAdjustment).toBe(2);
expect(body.stockAdjustment).toBe(0);
});
it("stock correction allows loose pills beyond package size", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const blisterMed: Medication = {
id: 5,
name: "Loose Friendly",
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-30T21:07:00" }],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(blisterMed, {
all: [{ name: "Loose Friendly", medsLeft: 0, daysLeft: 0 }] as Coverage[],
});
// sealed package part at max (20), loose adds +7 beyond max
result.current.setEditStockFullBlisters(2);
result.current.setEditStockPartialBlisterPills(0);
result.current.setEditStockLoosePills(7);
});
await act(async () => {
await result.current.submitStockCorrection(5, blisterMed, mockLoadMeds);
});
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call: [string, RequestInit]) => call[0] === "/api/medications/5/stock-adjustment"
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
// NEW: baseTotal = structuralMax + finalLoosePills = 20 + 7 = 27; desiredTotal = 27 => stockAdjustment=0
// looseTablets is sent separately so DB reflects the actual loose count after correction
expect(body.stockAdjustment).toBe(0);
expect(body.looseTablets).toBe(7);
});
it("stock correction carries partial overflow into full blisters", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const blisterMed: Medication = {
id: 6,
name: "Carry Partial",
packageType: "blister",
packCount: 11,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 2,
stockAdjustment: -223,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-30T21:07:00" }],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(blisterMed, {
all: [{ name: "Carry Partial", medsLeft: 54, daysLeft: 54 }] as Coverage[],
});
// 10 full + 5 partial + 2 loose should canonicalize to 11 full + 0 partial + 2 loose => 57
result.current.setEditStockFullBlisters(10);
result.current.setEditStockPartialBlisterPills(5);
result.current.setEditStockLoosePills(2);
});
await act(async () => {
await result.current.submitStockCorrection(6, blisterMed, mockLoadMeds);
});
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call: [string, RequestInit]) => call[0] === "/api/medications/6/stock-adjustment"
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
// baseTotal = structuralMax + finalLoosePills = 275 + 2 = 277; desiredTotal = 57 => stockAdjustment = -220
expect(body.stockAdjustment).toBe(-220);
expect(body.looseTablets).toBe(2);
});
it("prefill keeps loose pills separate from partial blister pills", () => {
const blisterMed: Medication = {
id: 7,
name: "Loose Separate",
packageType: "blister",
packCount: 11,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 2,
stockAdjustment: -223,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-30T21:07:00" }],
updatedAt: null,
};
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(blisterMed, {
all: [{ name: "Loose Separate", medsLeft: 54, daysLeft: 54 }] as Coverage[],
});
});
expect(result.current.editStockFullBlisters).toBe(10);
expect(result.current.editStockPartialBlisterPills).toBe(2);
expect(result.current.editStockLoosePills).toBe(2);
});
it("allows setting state directly", () => {
+45 -1
View File
@@ -116,6 +116,20 @@ const mockPastDays = [
},
];
const mockTodayDay = {
dateStr: "Today",
date: new Date(),
isPast: false,
meds: [
{
medName: "Aspirin",
total: 1,
doses: [{ id: `1-0-${Date.now() + 60_000}`, timeStr: "09:00", when: Date.now() + 60_000, usage: 1, takenBy: [] }],
lastWhen: Date.now() + 60_000,
},
],
};
// Default mock factory
const createMockAppContext = (overrides = {}) => ({
meds: [],
@@ -133,6 +147,8 @@ const createMockAppContext = (overrides = {}) => ({
lastAutoEmailSent: null,
lastNotificationType: null,
lastNotificationChannel: null,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
},
scheduleDays: 30,
setScheduleDays: vi.fn(),
@@ -494,6 +510,33 @@ describe("DashboardPage interactions", () => {
fireEvent.change(select, { target: { value: "90" } });
expect(setScheduleDays).toHaveBeenCalledWith(90);
});
it("hides past and future sections when upcomingTodayOnly is enabled", () => {
mockContextValue = createMockAppContext({
settings: {
...createMockAppContext().settings,
upcomingTodayOnly: true,
},
showPastDays: true,
showFutureDays: true,
pastDays: mockPastDays,
todayDay: mockTodayDay,
futureDays: mockFutureDays,
meds: mockMeds,
coverage: mockCoverage,
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
expect(document.querySelector(".day-block.today")).toBeInTheDocument();
expect(document.querySelector(".past-days-toggle")).not.toBeInTheDocument();
expect(document.querySelector(".future-days-toggle")).not.toBeInTheDocument();
expect(document.querySelector(".day-block.past")).not.toBeInTheDocument();
});
});
describe("DashboardPage structure", () => {
@@ -607,9 +650,10 @@ describe("DashboardPage with medications", () => {
</MemoryRouter>
);
// Aspirin has notes
// Aspirin has notes and reminders.
const notesIcons = document.querySelectorAll(".notes-icon");
expect(notesIcons.length).toBeGreaterThan(0);
expect(document.querySelectorAll(".notes-icon svg").length).toBeGreaterThan(0);
});
it("renders schedule timeline with future doses", () => {
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MedicationsPage } from "../../pages/MedicationsPage";
@@ -119,6 +119,7 @@ const createMockFormHook = (overrides = {}) => ({
let mockContextValue = createMockContext();
let mockFormHookValue = createMockFormHook();
const fetchMock = vi.fn();
vi.mock("../../hooks", () => ({
useMedicationForm: () => mockFormHookValue,
@@ -138,9 +139,50 @@ vi.mock("../../components/Auth", () => ({
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true }),
}));
function renderPage() {
vi.mock("../../components", async () => {
const actual = await vi.importActual<typeof import("../../components")>("../../components");
return {
...actual,
MedicationAvatar: ({ name }: { name: string }) => <span data-testid={`avatar-${name}`}></span>,
DateInput: ({ value, onChange }: { value: string; onChange: (e: { target: { value: string } }) => void }) => (
<input value={value} onChange={onChange} />
),
Lightbox: () => null,
ConfirmModal: ({
title,
message,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
}: {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
}) => (
<div data-testid="confirm-modal">
<h3>{title}</h3>
<p>{message}</p>
<button type="button" onClick={onConfirm}>
{confirmLabel}
</button>
<button type="button" onClick={onCancel}>
{cancelLabel}
</button>
</div>
),
MobileEditModal: () => null,
ReportModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div data-testid="report-modal-open">Report Modal</div> : null,
};
});
function renderPage(initialEntry = "/medications") {
render(
<MemoryRouter>
<MemoryRouter initialEntries={[initialEntry]}>
<MedicationsPage />
</MemoryRouter>
);
@@ -157,6 +199,9 @@ describe("MedicationsPage", () => {
vi.clearAllMocks();
mockContextValue = createMockContext();
mockFormHookValue = createMockFormHook();
Object.defineProperty(window, "innerWidth", { value: 1200, writable: true });
fetchMock.mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal("fetch", fetchMock);
});
it("renders list-first view with new button", () => {
@@ -179,11 +224,32 @@ describe("MedicationsPage", () => {
const submit = document.querySelector('button[type="submit"]');
expect(submit).toBeInTheDocument();
});
it("switches desktop form tabs", () => {
renderPage();
openNewMedicationForm();
const stockTab = screen.getByRole("tab", { name: "form.sections.stock" });
const scheduleTab = screen.getByRole("tab", { name: "form.sections.schedule" });
fireEvent.click(stockTab);
expect(stockTab).toHaveAttribute("aria-selected", "true");
fireEvent.click(scheduleTab);
expect(scheduleTab).toHaveAttribute("aria-selected", "true");
});
it("opens report modal from list actions", () => {
renderPage();
fireEvent.click(screen.getByText("report.button"));
expect(screen.getByTestId("report-modal-open")).toBeInTheDocument();
});
});
describe("MedicationsPage with items", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({ meds: mockMeds });
mockFormHookValue = createMockFormHook();
});
@@ -198,12 +264,123 @@ describe("MedicationsPage with items", () => {
it("calls startEdit from list action", () => {
const startEdit = vi.fn();
mockFormHookValue = createMockFormHook({ startEdit });
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
renderPage();
const editButton = document.querySelector(".med-actions .info") as HTMLButtonElement | null;
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton as HTMLButtonElement);
expect(startEdit).toHaveBeenCalledTimes(1);
});
it("opens edit flow from editMedId query parameter", async () => {
const startEdit = vi.fn();
mockFormHookValue = createMockFormHook({ startEdit });
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
renderPage("/medications?editMedId=1");
await waitFor(() => {
expect(startEdit).toHaveBeenCalledTimes(1);
});
});
it("opens unsaved confirm and continues edit after confirmation", async () => {
const startEdit = vi.fn();
const resetForm = vi.fn();
mockContextValue = createMockContext({
meds: mockMeds,
coverageByMed: {
Aspirin: { medsLeft: 12.4 },
},
});
mockFormHookValue = createMockFormHook({
formChanged: true,
startEdit,
resetForm,
});
renderPage();
const editButton = document.querySelector(".med-actions .info") as HTMLButtonElement;
fireEvent.click(editButton);
expect(screen.getByTestId("confirm-modal")).toBeInTheDocument();
fireEvent.click(screen.getByText("common.unsavedChanges.leave"));
await waitFor(() => {
expect(resetForm).toHaveBeenCalledTimes(1);
expect(startEdit).toHaveBeenCalledTimes(1);
});
});
it("marks medication obsolete after confirmation", async () => {
mockContextValue = createMockContext({ meds: mockMeds });
fetchMock.mockImplementation((url: string) => {
if (url === "/api/medications/1/obsolete") {
return Promise.resolve({ ok: true, json: async () => ({}) });
}
if (url === "/api/medications?includeObsolete=true") {
return Promise.resolve({ ok: true, json: async () => mockMeds });
}
return Promise.resolve({ ok: true, json: async () => [] });
});
renderPage();
fireEvent.click(screen.getByText("medications.list.markObsolete"));
expect(screen.getByTestId("confirm-modal")).toBeInTheDocument();
const confirmButtons = screen.getAllByText("medications.list.markObsolete");
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medications/1/obsolete", {
method: "POST",
credentials: "include",
});
});
});
it("reactivates obsolete medication from obsolete section", async () => {
const obsoleteMed = { ...mockMeds[0], id: 2, isObsolete: true, obsoleteAt: "2025-01-01T00:00:00Z" };
mockContextValue = createMockContext({ meds: [obsoleteMed] });
fetchMock.mockImplementation((url: string) => {
if (url === "/api/medications/2/reactivate") {
return Promise.resolve({ ok: true, json: async () => ({}) });
}
if (url === "/api/medications?includeObsolete=true") {
return Promise.resolve({ ok: true, json: async () => [obsoleteMed] });
}
return Promise.resolve({ ok: true, json: async () => [] });
});
renderPage();
fireEvent.click(screen.getByText("medications.list.reactivate"));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medications/2/reactivate", {
method: "POST",
credentials: "include",
});
});
});
it("toggles obsolete section visibility and persists state", async () => {
const obsoleteMed = { ...mockMeds[0], id: 2, isObsolete: true, obsoleteAt: "2025-01-01T00:00:00Z" };
mockContextValue = createMockContext({ meds: [obsoleteMed] });
renderPage();
expect(screen.getByText("medications.list.reactivate")).toBeInTheDocument();
const obsoleteToggleButton = document.querySelector(".med-group-head-toggle") as HTMLButtonElement;
expect(obsoleteToggleButton).toBeInTheDocument();
fireEvent.click(obsoleteToggleButton);
await waitFor(() => {
expect(screen.queryByText("medications.list.reactivate")).not.toBeInTheDocument();
});
fireEvent.click(obsoleteToggleButton);
expect(screen.getByText("medications.list.reactivate")).toBeInTheDocument();
});
});
describe("MedicationsPage form interactions", () => {
@@ -225,4 +402,19 @@ describe("MedicationsPage form interactions", () => {
fireEvent.change(nameInput as HTMLInputElement, { target: { value: "Test Med" } });
expect(handleValueChange).toHaveBeenCalled();
});
it("opens mobile edit flow when creating new entry on mobile viewport", () => {
const resetForm = vi.fn();
mockFormHookValue = createMockFormHook({
resetForm,
});
Object.defineProperty(window, "innerWidth", { value: 375, writable: true });
const pushStateSpy = vi.spyOn(window.history, "pushState");
renderPage();
openNewMedicationForm();
expect(resetForm).toHaveBeenCalledTimes(1);
expect(pushStateSpy).toHaveBeenCalledWith({ modal: "edit" }, "");
});
});
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsPage } from "../../pages/SettingsPage";
@@ -91,11 +92,53 @@ const createMockContext = (overrides = {}) => ({
});
let mockContextValue = createMockContext();
const fetchMock = vi.fn();
vi.mock("../../context", () => ({
useAppContext: () => mockContextValue,
}));
interface MockConfirmModalProps {
title: string;
message: ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
interface MockExportModalProps {
isOpen: boolean;
onClose: () => void;
onExport: () => void;
}
vi.mock("../../components", () => ({
ConfirmModal: ({ title, message, confirmLabel, cancelLabel, onConfirm, onCancel }: MockConfirmModalProps) => (
<div>
<div>{title}</div>
<div>{message}</div>
<button type="button" onClick={onConfirm}>
{confirmLabel}
</button>
<button type="button" onClick={onCancel}>
{cancelLabel}
</button>
</div>
),
ExportModal: ({ isOpen, onClose, onExport }: MockExportModalProps) =>
isOpen ? (
<div>
<button type="button" onClick={onExport}>
export-modal-export
</button>
<button type="button" onClick={onClose}>
export-modal-close
</button>
</div>
) : null,
}));
function renderPage() {
render(
<MemoryRouter>
@@ -108,6 +151,8 @@ describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext();
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal("fetch", fetchMock);
});
it("renders settings form container", () => {
@@ -115,6 +160,12 @@ describe("SettingsPage", () => {
expect(document.querySelector(".settings-form")).toBeInTheDocument();
});
it("renders loading text while settings are loading", () => {
mockContextValue = createMockContext({ settingsLoading: true });
renderPage();
expect(screen.getByText("settings.loading")).toBeInTheDocument();
});
it("renders major sections", () => {
renderPage();
expect(screen.getByText(/settings\.language\.title/i)).toBeInTheDocument();
@@ -129,6 +180,177 @@ describe("SettingsPage", () => {
expect(select).toBeInTheDocument();
fireEvent.change(select as HTMLSelectElement, { target: { value: "de" } });
expect(changeLanguageMock).toHaveBeenCalledWith("de");
expect(fetchMock).toHaveBeenCalledWith("/api/settings/language", expect.objectContaining({ method: "PUT" }));
});
it("updates timeline toggles through setSettings", () => {
const setSettings = vi.fn();
mockContextValue = createMockContext({
setSettings,
settings: {
...createMockContext().settings,
swapDashboardMainSections: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
},
});
renderPage();
const swapRow = screen.getByText("settings.timeline.swapDashboardSections").closest(".setting-row");
const upcomingRow = screen.getByText("settings.timeline.upcomingTodayOnly").closest(".setting-row");
const sharedRow = screen.getByText("settings.timeline.shareScheduleTodayOnly").closest(".setting-row");
const swapToggle = swapRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
const upcomingToggle = upcomingRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
const sharedToggle = sharedRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(swapToggle).toBeInTheDocument();
expect(upcomingToggle).toBeInTheDocument();
expect(sharedToggle).toBeInTheDocument();
fireEvent.click(swapToggle);
fireEvent.click(upcomingToggle);
fireEvent.click(sharedToggle);
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ swapDashboardMainSections: true }));
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ upcomingTodayOnly: true }));
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareScheduleTodayOnly: true }));
});
it("updates share stock status toggle through setSettings", () => {
const setSettings = vi.fn();
mockContextValue = createMockContext({
setSettings,
settings: {
...createMockContext().settings,
shareStockStatus: false,
},
});
renderPage();
const shareStockRow = screen.getByText("settings.stock.shareStockStatus").closest(".setting-row");
const shareStockToggle = shareStockRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(shareStockToggle).toBeInTheDocument();
fireEvent.click(shareStockToggle);
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareStockStatus: true }));
});
it("opens export modal when export action is clicked", () => {
const setShowExportModal = vi.fn();
mockContextValue = createMockContext({ setShowExportModal });
renderPage();
fireEvent.click(screen.getByText("exportImport.export"));
expect(setShowExportModal).toHaveBeenCalledWith(true);
});
it("triggers export modal close callback", () => {
const setShowExportModal = vi.fn();
mockContextValue = createMockContext({
showExportModal: true,
setShowExportModal,
});
renderPage();
fireEvent.click(screen.getByText("export-modal-close"));
expect(setShowExportModal).toHaveBeenCalledWith(false);
});
it("triggers export modal export callback", () => {
const handleExport = vi.fn();
mockContextValue = createMockContext({
showExportModal: true,
handleExport,
});
renderPage();
fireEvent.click(screen.getByText("export-modal-export"));
expect(handleExport).toHaveBeenCalledTimes(1);
});
it("calls testEmail when email test button is clicked", () => {
const testEmail = vi.fn();
mockContextValue = createMockContext({
testEmail,
settings: {
...createMockContext().settings,
smtpHost: "smtp.example.com",
emailEnabled: true,
notificationEmail: "a@example.com",
},
});
renderPage();
fireEvent.click(screen.getByText("common.test"));
expect(testEmail).toHaveBeenCalledTimes(1);
});
it("calls testShoutrrr when push test button is clicked", () => {
const testShoutrrr = vi.fn();
mockContextValue = createMockContext({
testShoutrrr,
settings: {
...createMockContext().settings,
shoutrrrEnabled: true,
shoutrrrUrl: "https://ntfy.sh/topic",
},
});
renderPage();
const testButtons = screen.getAllByText("common.test");
fireEvent.click(testButtons[testButtons.length - 1]);
expect(testShoutrrr).toHaveBeenCalledTimes(1);
});
it("clears import success banner when close is clicked", () => {
const setImportResult = vi.fn();
mockContextValue = createMockContext({
setImportResult,
importResult: {
medications: 1,
doses: 2,
refills: 3,
shares: 4,
},
});
renderPage();
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
expect(setImportResult).toHaveBeenCalledWith(null);
});
it("opens hidden import file input when import action is clicked", () => {
renderPage();
const importInput = document.getElementById("import-file-input") as HTMLInputElement;
const clickSpy = vi.spyOn(importInput, "click");
fireEvent.click(screen.getByText("exportImport.import"));
expect(clickSpy).toHaveBeenCalledTimes(1);
});
it("cancels import confirm and clears pending import", () => {
const setShowImportConfirm = vi.fn();
const setPendingImportData = vi.fn();
mockContextValue = createMockContext({
setShowImportConfirm,
setPendingImportData,
showImportConfirm: true,
meds: [{ id: 1 }],
});
renderPage();
fireEvent.click(screen.getByText("exportImport.cancelButton"));
expect(setShowImportConfirm).toHaveBeenCalledWith(false);
expect(setPendingImportData).toHaveBeenCalledWith(null);
});
it("renders notification matrix with toggle switches", () => {
@@ -152,4 +374,72 @@ describe("SettingsPage", () => {
expect(modeGroup).toBeInTheDocument();
expect(modeGroup?.querySelectorAll(".radio-card").length).toBe(2);
});
it("renders threshold validation message when critical/low/high order is invalid", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 30,
lowStockDays: 20,
highStockDays: 10,
},
});
renderPage();
expect(screen.getByText("settings.stock.thresholdValidation")).toBeInTheDocument();
});
it("renders email and push test result messages", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
emailEnabled: true,
notificationEmail: "a@example.com",
smtpHost: "smtp.example.com",
shoutrrrEnabled: true,
shoutrrrUrl: "https://ntfy.sh/topic",
},
testEmailResult: { success: true, message: "email ok" },
testShoutrrrResult: { success: false, message: "push failed" },
});
renderPage();
expect(screen.getByText("email ok")).toBeInTheDocument();
expect(screen.getByText("push failed")).toBeInTheDocument();
});
it("renders import confirm for existing data and handles confirm", () => {
const handleImportConfirm = vi.fn();
mockContextValue = createMockContext({
handleImportConfirm,
showImportConfirm: true,
meds: [{ id: 1 }],
});
renderPage();
expect(screen.getByText("exportImport.confirmImport")).toBeInTheDocument();
expect(screen.getByText(/exportImport\.confirmImportWarning/i)).toBeInTheDocument();
fireEvent.click(screen.getByText("exportImport.confirmButton"));
expect(handleImportConfirm).toHaveBeenCalledTimes(1);
});
it("renders import confirm for empty state and handles cancel", () => {
const setShowImportConfirm = vi.fn();
const setPendingImportData = vi.fn();
mockContextValue = createMockContext({
setShowImportConfirm,
setPendingImportData,
showImportConfirm: true,
meds: [],
});
renderPage();
expect(screen.getByText("exportImport.confirmImportEmpty")).toBeInTheDocument();
expect(screen.getByText("exportImport.confirmImportEmptyMessage")).toBeInTheDocument();
fireEvent.click(screen.getByText("exportImport.cancelButton"));
expect(setShowImportConfirm).toHaveBeenCalledWith(false);
expect(setPendingImportData).toHaveBeenCalledWith(null);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
type LoggerModule = typeof import("../../utils/logger");
async function loadLogger(level?: string): Promise<LoggerModule["log"]> {
vi.resetModules();
if (typeof level === "string") {
Object.defineProperty(globalThis, "__LOG_LEVEL__", {
value: level,
configurable: true,
writable: true,
});
} else {
Reflect.deleteProperty(globalThis, "__LOG_LEVEL__");
}
const mod = await import("../../utils/logger");
return mod.log;
}
describe("frontend logger", () => {
it("defaults to warn threshold", async () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const log = await loadLogger();
log.debug("d");
log.info("i");
log.warn("w");
log.error("e");
expect(debugSpy).not.toHaveBeenCalled();
expect(infoSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith("w");
expect(errorSpy).toHaveBeenCalledWith("e");
});
it("logs everything at debug level", async () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const log = await loadLogger("debug");
log.debug("d");
log.info("i");
log.warn("w");
log.error("e");
expect(debugSpy).toHaveBeenCalledWith("d");
expect(infoSpy).toHaveBeenCalledWith("i");
expect(warnSpy).toHaveBeenCalledWith("w");
expect(errorSpy).toHaveBeenCalledWith("e");
});
it("suppresses all logs at silent level", async () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const log = await loadLogger("silent");
log.debug("d");
log.info("i");
log.warn("w");
log.error("e");
expect(debugSpy).not.toHaveBeenCalled();
expect(infoSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
});