fix: align stock and refill semantics

Squash merge PR #474
This commit is contained in:
Daniel Volz
2026-03-25 06:49:34 +01:00
committed by GitHub
parent 37fc2b8e66
commit 7059c25f1c
18 changed files with 1063 additions and 463 deletions
@@ -921,6 +921,39 @@ describe("MedDetailModal stock overflow warning", () => {
});
});
describe("MedDetailModal amount-based stock display", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows current liquid stock against configured structural capacity", () => {
const liquidMed: Medication = {
...mockMedication,
id: 20,
name: "Liquid Multi",
packageType: "liquid_container",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 450,
looseTablets: 450,
};
const liquidCoverage: Coverage = {
name: "Liquid Multi",
medsLeft: 450,
daysLeft: 45,
depletionDate: "2024-04-01",
depletionTime: Date.now() + 45 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} coverage={{ all: [liquidCoverage] }} />);
expect(screen.getByText("450 / 600 form.packageAmountUnitMl")).toBeInTheDocument();
expect(screen.queryByText("450 / 450 form.packageAmountUnitMl")).not.toBeInTheDocument();
});
});
describe("MedDetailModal bottle package type", () => {
const bottleMed: Medication = {
id: 2,
@@ -113,6 +113,56 @@ describe("ReportModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("exports bottle current stock separately from configured capacity", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 0,
automaticDosesTaken: 0,
dosesDismissed: 0,
firstDoseAt: null,
lastDoseAt: null,
refills: [],
},
}),
});
render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 50,
}),
]}
/>
);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
expect(content).toContain("report.docTotalCapacity: 100");
expect(content).toContain("report.docCurrentStock: 70 common.pills");
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("generates printable report when PDF format is selected", async () => {
const onClose = vi.fn();
const mockWrite = vi.fn();
@@ -344,6 +344,58 @@ describe("UserFilterModal", () => {
expect(screen.queryByText(/600\/600 .*common\.pills/)).not.toBeInTheDocument();
});
it("shows liquid stock against configured multi-container capacity", () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
const liquidMedication: Medication = {
...mockMedication,
id: 13,
name: "Liquid Multi",
genericName: "Liquid Generic",
packageType: "liquid_container",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 450,
looseTablets: 450,
intakes: [
{
usage: 2,
every: 1,
start: "2024-01-01T09:32:00",
intakeUnit: "ml",
takenBy: "John",
intakeRemindersEnabled: true,
},
],
};
const liquidCoverage: Coverage = {
name: "Liquid Multi",
medsLeft: 450,
daysLeft: 30,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
render(
<UserFilterModal
selectedUser="John"
meds={[liquidMedication]}
coverage={{ all: [liquidCoverage] }}
settings={defaultSettings}
onClose={onClose}
onClearUser={vi.fn()}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText("450/600 form.packageAmountUnitMl")).toBeInTheDocument();
expect(screen.queryByText("450/450 form.packageAmountUnitMl")).not.toBeInTheDocument();
});
it("renders liquid container intakes and stock in ml", () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
+195 -20
View File
@@ -89,6 +89,25 @@ describe("useRefill", () => {
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "refill" }, "");
});
it("resets stale refill form state when opening modal", () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.setRefillPacks(4);
result.current.setRefillLoose(9);
result.current.setUsePrescriptionRefill(true);
});
act(() => {
result.current.openRefillModal();
});
expect(result.current.showRefillModal).toBe(true);
expect(result.current.refillPacks).toBe(1);
expect(result.current.refillLoose).toBe(0);
expect(result.current.usePrescriptionRefill).toBe(false);
});
it("closes refill modal using history back", () => {
const { result } = renderHook(() => useRefill());
@@ -325,42 +344,197 @@ describe("useRefill", () => {
expect(mockLoadMeds).toHaveBeenCalled();
});
it("stock correction uses correct base for bottle type medications", async () => {
// BUG FIX: submitStockCorrection used blister formula (packCount * blistersPerPack * pillsPerBlister + looseTablets)
// for ALL medications, but getMedTotal() uses only looseTablets + stockAdjustment for bottles.
// This mismatch caused the correction to compute the wrong stockAdjustment.
it("resets blister stock correction payload to zero base fields", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 4,
name: "Pills in a Box",
packageType: "bottle",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
stockAdjustment: -2,
const blisterMed: Medication = {
id: 8,
name: "Zero Reset Blister",
packageType: "blister",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
stockAdjustment: -4,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
// getMedTotal for bottle = looseTablets + stockAdjustment = 150 + (-2) = 148
// getPackageSize for bottle = looseTablets = 150
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(blisterMed, {
all: [{ name: "Zero Reset Blister", medsLeft: 31, daysLeft: 31 }] as Coverage[],
});
result.current.setEditStockFullBlisters(0);
result.current.setEditStockPartialBlisterPills(0);
result.current.setEditStockLoosePills(0);
});
await act(async () => {
await result.current.submitStockCorrection(8, blisterMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
looseTablets: 0,
});
});
it("resets bottle stock correction payload to zero base fields", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 9,
name: "Zero Reset Bottle",
packageType: "bottle",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 5,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// 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[],
all: [{ name: "Zero Reset Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
});
result.current.setEditStockFullBlisters(0);
result.current.setEditStockPartialBlisterPills(0);
});
await act(async () => {
await result.current.submitStockCorrection(9, bottleMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
looseTablets: 0,
totalPills: 0,
});
});
it.each([
{
label: "liquid container",
id: 10,
med: {
id: 10,
name: "Zero Reset Liquid",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 1,
packageAmountValue: 180,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 180,
looseTablets: 180,
stockAdjustment: 0,
takenBy: [],
blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
} satisfies Medication,
coverage: 180,
},
{
label: "tube",
id: 11,
med: {
id: 11,
name: "Zero Reset Tube",
medicationForm: "topical",
packageType: "tube",
doseUnit: "units",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 80,
looseTablets: 80,
stockAdjustment: 0,
takenBy: [],
blisters: [{ usage: 2, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
} satisfies Medication,
coverage: 80,
},
])("resets $label stock correction payload to zero amount-base fields", async ({ id, med, coverage }) => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(med, {
all: [{ name: med.name, medsLeft: coverage, daysLeft: coverage }] as Coverage[],
});
result.current.setEditStockFullBlisters(0);
result.current.setEditStockPartialBlisterPills(0);
});
await act(async () => {
await result.current.submitStockCorrection(id, med, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
looseTablets: 0,
totalPills: 0,
packageAmountValue: 0,
});
});
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 4,
name: "Capacity Bottle",
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 5,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Capacity Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
});
});
// User sets total to 149 pills.
// User corrects current stock to 70 pills.
act(() => {
result.current.setEditStockPartialBlisterPills(149);
result.current.setEditStockPartialBlisterPills(70);
});
await act(async () => {
@@ -376,7 +550,8 @@ describe("useRefill", () => {
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
expect(body.stockAdjustment).toBe(50);
expect(body.looseTablets).toBeUndefined();
});
it("stock correction clamps blister totals to package size", async () => {
@@ -646,4 +646,58 @@ describe("MedicationsPage form interactions", () => {
expect(screen.getAllByText("form.enrichment.applied").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
});
it("shows liquid stock against configured multi-container capacity in the list", () => {
const liquidMed = {
...mockMeds[0],
id: 2,
name: "Liquid Multi",
genericName: "Liquid Generic",
packageType: "liquid_container" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
packageAmountValue: 150,
packageAmountUnit: "ml" as const,
totalPills: 450,
looseTablets: 450,
};
mockContextValue = createMockContext({
meds: [liquidMed],
coverageByMed: {
"Liquid Multi": { medsLeft: 450 },
},
});
renderPage();
expect(screen.getByText(/medications\.details\.stock: 450 \/ 600 ml/i)).toBeInTheDocument();
expect(screen.queryByText(/medications\.details\.stock: 450 \/ 450 ml/i)).not.toBeInTheDocument();
});
it("shows bottle current stock against configured bottle capacity in the list", () => {
const bottleMed = {
...mockMeds[0],
id: 3,
name: "Bottle Capacity",
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 50,
};
mockContextValue = createMockContext({
meds: [bottleMed],
coverageByMed: {
"Bottle Capacity": { medsLeft: 70 },
},
});
renderPage();
expect(screen.getByText(/medications\.details\.stock: 70 \/ 100 common\.pills/i)).toBeInTheDocument();
expect(screen.queryByText(/medications\.details\.stock: 100 \/ 100 common\.pills/i)).not.toBeInTheDocument();
});
});
+85 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
import { FIELD_LIMITS, getMedTotal, getPackageSize, getStockDisplayCapacity } from "../types";
describe("getMedTotal", () => {
it("calculates total pills without stock adjustment", () => {
@@ -85,6 +85,20 @@ describe("getMedTotal", () => {
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
});
it("uses loose stock for bottle current total even when explicit capacity exists", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 50,
};
expect(getMedTotal(med)).toBe(70);
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
@@ -158,6 +172,20 @@ describe("getPackageSize", () => {
expect(getPackageSize(med)).toBe(200);
});
it("returns explicit bottle capacity instead of current stock", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 70,
stockAdjustment: 25,
};
expect(getPackageSize(med)).toBe(100);
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
@@ -195,6 +223,62 @@ describe("getPackageSize", () => {
});
});
describe("getStockDisplayCapacity", () => {
it("returns configured multi-container capacity for liquid containers", () => {
const liquid = {
packageType: "liquid_container" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
packageAmountValue: 150,
totalPills: 450,
looseTablets: 450,
};
expect(getStockDisplayCapacity(liquid)).toBe(600);
});
it("returns configured multi-container capacity for tubes", () => {
const tube = {
packageType: "tube" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
packageAmountValue: 150,
totalPills: 450,
looseTablets: 450,
};
expect(getStockDisplayCapacity(tube)).toBe(600);
});
it("falls back to current package size when amount metadata is missing", () => {
const liquid = {
packageType: "liquid_container" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 450,
looseTablets: 450,
};
expect(getStockDisplayCapacity(liquid)).toBe(450);
});
it("keeps bottle semantics unchanged", () => {
const bottle = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 80,
};
expect(getStockDisplayCapacity(bottle)).toBe(100);
});
});
describe("FIELD_LIMITS", () => {
it("has correct limits for name field", () => {
expect(FIELD_LIMITS.name.min).toBe(0);