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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user