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