fix: align frontend tube/liquid container semantics (#364)

* fix: align frontend tube/liquid container semantics

* test(frontend): fix PR #364 CI regressions
This commit is contained in:
Daniel Volz
2026-03-02 00:23:32 +01:00
committed by GitHub
parent cd18581bdd
commit da004b5c3e
29 changed files with 5286 additions and 526 deletions
@@ -644,9 +644,9 @@ describe("MedDetailModal intake schedule usage display", () => {
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
// Each intake should show "1 pill" (not "2 pills")
usageElements.forEach((el) => {
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
// Each intake should show "1" in usage (not "2")
rows.forEach((el) => {
expect(el.textContent).toContain("1");
expect(el.textContent).not.toMatch(/^2\b/);
});
@@ -662,10 +662,10 @@ describe("MedDetailModal intake schedule usage display", () => {
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
// Legacy: 1 pill * 2 people = "2 pills"
expect(usageElements.length).toBe(1);
expect(usageElements[0].textContent).toContain("2");
expect(rows.length).toBe(1);
expect(rows[0].textContent).toContain("2");
});
it("shows correct usage for single person with per-intake takenBy", () => {
@@ -678,11 +678,11 @@ describe("MedDetailModal intake schedule usage display", () => {
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
expect(usageElements.length).toBe(1);
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
expect(rows.length).toBe(1);
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
expect(usageElements[0].textContent).toContain("2");
expect(usageElements[0].textContent).toContain("1000");
expect(rows[0].textContent).toContain("2");
expect(rows[0].textContent).toContain("1000");
});
});
@@ -279,7 +279,7 @@ describe("MobileEditModal", () => {
/>
);
const amountInput = screen.getByLabelText("form.packageAmount") as HTMLInputElement;
const amountInput = screen.getByLabelText("form.packageAmountPerBottle") as HTMLInputElement;
expect(amountInput).toBeInTheDocument();
expect(amountInput.tagName).toBe("INPUT");
expect(amountInput).toHaveAttribute("inputmode", "decimal");
+11 -7
View File
@@ -39,12 +39,16 @@ vi.mock("../../utils/formatters", () => ({
getSystemLocale: () => "en-US",
}));
vi.mock("../../utils/schedule", () => ({
buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args),
calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args),
computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args),
isDoseDismissed: vi.fn(() => false),
}));
vi.mock("../../utils/schedule", async () => {
const actual = await vi.importActual<typeof import("../../utils/schedule")>("../../utils/schedule");
return {
...actual,
buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args),
calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args),
computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args),
isDoseDismissed: vi.fn(() => false),
};
});
const meds: Medication[] = [
{
@@ -464,7 +468,7 @@ describe("useAppContext", () => {
all: [
{
name: "Aspirin",
daysLeft: 2,
daysLeft: 8,
medsLeft: 5,
depletionTime: Date.now() + 100000,
},
@@ -244,6 +244,7 @@ describe("DashboardPage helper functions", () => {
{ name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null },
{ name: "B", daysLeft: 10, medsLeft: 4, depletionDate: null, depletionTime: null, nextDose: null },
],
[],
"2026-01-01T10:00:00.000Z",
"intake",
"email",
@@ -270,6 +271,7 @@ describe("DashboardPage helper functions", () => {
30,
[],
[{ name: "C", daysLeft: 12, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
[],
null,
null,
null,
@@ -288,6 +290,7 @@ describe("DashboardPage helper functions", () => {
30,
[],
[{ name: "D", daysLeft: 40, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
[],
null,
null,
null,
+45
View File
@@ -97,6 +97,29 @@ describe("getMedTotal", () => {
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
expect(getMedTotal(med)).toBe(80);
});
it("calculates tube/liquid totals from amount fields, not blister math", () => {
const tube = {
packageType: "tube" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 600,
looseTablets: 600,
stockAdjustment: 4,
};
const liquid = {
packageType: "liquid_container" as const,
packCount: 3,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 450,
looseTablets: 450,
};
expect(getMedTotal(tube)).toBe(604);
expect(getMedTotal(liquid)).toBe(450);
});
});
describe("getPackageSize", () => {
@@ -148,6 +171,28 @@ describe("getPackageSize", () => {
// Should use looseTablets only, ignore stockAdjustment and blister math
expect(getPackageSize(med)).toBe(80);
});
it("returns totalPills for tube/liquid container package size", () => {
const tube = {
packageType: "tube" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 600,
looseTablets: 600,
};
const liquid = {
packageType: "liquid_container" as const,
packCount: 3,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 450,
looseTablets: 450,
};
expect(getPackageSize(tube)).toBe(600);
expect(getPackageSize(liquid)).toBe(450);
});
});
describe("FIELD_LIMITS", () => {
+75 -48
View File
@@ -5,7 +5,6 @@ import {
calculateCoverage,
computeMissedPastDoseIds,
expandDoseIds,
getNextReminderForMed,
getReminderStatusText,
getStockStatus,
isDoseDismissed,
@@ -1202,6 +1201,80 @@ describe("getStockStatus", () => {
expect(result.level).toBe("critical");
expect(result.className).toBe("danger");
});
it("returns normal (no stock reminder semantics) for tube packageType regardless of stock thresholds", () => {
// Tubes have no stock reminder semantics: thresholds (low, critical, high) do not apply.
// However, if truly empty or exhausted, out-of-stock is still returned.
const resultWithMeds = getStockStatus(100, 50, thresholds, "tube");
expect(resultWithMeds.level).toBe("normal");
expect(resultWithMeds.className).toBe("success");
expect(resultWithMeds.label).toBe("status.noSchedule");
// Even with low days remaining (would be critical for non-tube)
const resultLow = getStockStatus(2, 50, thresholds, "tube");
expect(resultLow.level).toBe("normal");
expect(resultLow.className).toBe("success");
// Exhausted/empty tubes still show as out-of-stock
const resultEmpty = getStockStatus(0, 0, thresholds, "tube");
expect(resultEmpty.level).toBe("out-of-stock");
expect(resultEmpty.className).toBe("danger");
});
it("applies liquid_container thresholds: low=critical(threshold), critical=ceil(critical/2)", () => {
// For liquid_container, baseline is criticalStockDays (7)
// low = 7, critical = ceil(7/2) = 4
const thresholdsLiquid: StockThresholds = {
lowStockDays: 30,
criticalStockDays: 7,
normalStockDays: 90,
highStockDays: 180,
expiryWarningDays: 30,
};
// daysLeft = 8 (above low threshold of 7)
const resultNormal = getStockStatus(8, 100, thresholdsLiquid, "liquid_container");
expect(resultNormal.level).toBe("normal");
expect(resultNormal.className).toBe("success");
// daysLeft = 7 (at low threshold, below normal)
const resultLow = getStockStatus(7, 100, thresholdsLiquid, "liquid_container");
expect(resultLow.level).toBe("low");
expect(resultLow.className).toBe("warning");
// daysLeft = 4 (at critical threshold)
const resultCritical = getStockStatus(4, 100, thresholdsLiquid, "liquid_container");
expect(resultCritical.level).toBe("critical");
expect(resultCritical.className).toBe("danger");
// daysLeft = 2 (below critical threshold)
const resultVeryCritical = getStockStatus(2, 100, thresholdsLiquid, "liquid_container");
expect(resultVeryCritical.level).toBe("critical");
expect(resultVeryCritical.className).toBe("danger");
});
it("handles liquid_container with boundary baseline (criticalStockDays=1)", () => {
// Boundary case: criticalStockDays=1, so low=1, critical=ceil(1/2)=1
const boundaryThresholds: StockThresholds = {
lowStockDays: 30,
criticalStockDays: 1,
normalStockDays: 90,
highStockDays: 180,
expiryWarningDays: 30,
};
// daysLeft = 2 (above low threshold)
const resultNormal = getStockStatus(2, 100, boundaryThresholds, "liquid_container");
expect(resultNormal.level).toBe("normal");
// daysLeft = 1 (at low and critical thresholds)
const resultCritical = getStockStatus(1, 100, boundaryThresholds, "liquid_container");
expect(resultCritical.level).toBe("critical");
// daysLeft = 0 (out of stock)
const resultEmpty = getStockStatus(0, 100, boundaryThresholds, "liquid_container");
expect(resultEmpty.level).toBe("out-of-stock");
});
});
describe("getNextReminderForMed", () => {
@@ -1213,53 +1286,7 @@ describe("getNextReminderForMed", () => {
vi.useRealTimers();
});
it('returns "—" when no depletion time', () => {
const med: Coverage = {
name: "Test",
medsLeft: 100,
daysLeft: null,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
expect(getNextReminderForMed(med, 7, "en")).toBe("—");
});
it('returns "Due now" when reminder time is past', () => {
const now = Date.now();
const med: Coverage = {
name: "Test",
medsLeft: 5,
daysLeft: 3,
depletionDate: null,
depletionTime: now + 3 * 86400000,
nextDose: null,
};
// Reminder 7 days before = already past
expect(getNextReminderForMed(med, 7, "en")).toBe("Due now");
});
it("returns formatted date for future reminder", () => {
const now = Date.now();
const med: Coverage = {
name: "Test",
medsLeft: 100,
daysLeft: 30,
depletionDate: null,
depletionTime: now + 30 * 86400000,
nextDose: null,
};
const result = getNextReminderForMed(med, 7, "en-US");
expect(result).not.toBe("—");
expect(result).not.toBe("Due now");
});
});
describe("getReminderStatusText", () => {
const mockT = (key: string, options?: Record<string, unknown>) => {
const mockT = (key: string, options?: Record<string, number>) => {
if (options?.count) return `${key} (${options.count})`;
if (options?.days) return `${key} (${options.days})`;
return key;