c78fc43083
* feat(backend): add intake journal APIs and share note support * feat(frontend): add intake journal and shared note flows
1753 lines
44 KiB
TypeScript
1753 lines
44 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { DashboardPage } from "../../pages/DashboardPage";
|
|
import {
|
|
formatFullBlisters,
|
|
formatOpenBlisterAndLoose,
|
|
getBlisterStock,
|
|
getMedTotal,
|
|
getReminderStatusData,
|
|
userStorageKey,
|
|
} from "../../pages/dashboard-helpers";
|
|
|
|
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
|
|
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
|
|
|
|
// Mock data for tests with medications
|
|
const mockMeds = [
|
|
{
|
|
id: 1,
|
|
name: "Aspirin",
|
|
packCount: 1,
|
|
blistersPerPack: 2,
|
|
pillsPerBlister: 10,
|
|
looseTablets: 5,
|
|
takenBy: ["John"],
|
|
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00Z" }],
|
|
intakeRemindersEnabled: true,
|
|
notes: "Take with food",
|
|
expiryDate: "2025-12-31",
|
|
imageUrl: null,
|
|
updatedAt: null,
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Vitamin D",
|
|
packCount: 0,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 3,
|
|
takenBy: [],
|
|
blisters: [{ usage: 1, every: 1, start: "2024-01-01T08:00:00Z" }],
|
|
intakeRemindersEnabled: false,
|
|
notes: null,
|
|
expiryDate: null,
|
|
imageUrl: null,
|
|
updatedAt: null,
|
|
},
|
|
];
|
|
|
|
const mockCoverage = {
|
|
all: [
|
|
{
|
|
name: "Aspirin",
|
|
medsLeft: 25,
|
|
daysLeft: 25,
|
|
depletionDate: "2025-02-15",
|
|
depletionTime: Date.now() + 25 * 86400000,
|
|
nextDose: null,
|
|
},
|
|
{
|
|
name: "Vitamin D",
|
|
medsLeft: 3,
|
|
daysLeft: 3,
|
|
depletionDate: "2025-01-25",
|
|
depletionTime: Date.now() + 3 * 86400000,
|
|
nextDose: null,
|
|
},
|
|
],
|
|
low: [
|
|
{
|
|
name: "Vitamin D",
|
|
medsLeft: 3,
|
|
daysLeft: 3,
|
|
depletionDate: "2025-01-25",
|
|
depletionTime: Date.now() + 3 * 86400000,
|
|
nextDose: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
const mockFutureDays = [
|
|
{
|
|
dateStr: "Mon, Jan 22",
|
|
date: new Date(),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [{ id: `1-0-${Date.now()}`, timeStr: "09:00", when: Date.now(), usage: 1, takenBy: ["John"] }],
|
|
lastWhen: Date.now(),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const mockPastDays = [
|
|
{
|
|
dateStr: "Sun, Jan 21",
|
|
date: new Date(Date.now() - 86400000),
|
|
isPast: true,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [
|
|
{
|
|
id: `1-0-${Date.now() - 86400000}`,
|
|
timeStr: "09:00",
|
|
when: Date.now() - 86400000,
|
|
usage: 1,
|
|
takenBy: ["John"],
|
|
},
|
|
],
|
|
lastWhen: Date.now() - 86400000,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
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,
|
|
},
|
|
],
|
|
};
|
|
|
|
const mockJournalEntry = {
|
|
doseTrackingId: 1,
|
|
doseId: "1-0-1760000000000",
|
|
medicationId: 1,
|
|
medicationName: "Aspirin",
|
|
scheduledFor: "2026-05-21T09:00:00.000Z",
|
|
takenAt: "2026-05-21T09:05:00.000Z",
|
|
dismissed: false,
|
|
takenSource: "manual" as const,
|
|
markedBy: null,
|
|
note: "",
|
|
updatedAt: null,
|
|
createdAt: null,
|
|
};
|
|
|
|
function getRouteDateKey(value: Date): string {
|
|
const year = value.getFullYear();
|
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
const day = String(value.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
// Default mock factory
|
|
const createMockAppContext = (overrides = {}) => ({
|
|
meds: [],
|
|
settings: {
|
|
lowStockThreshold: 30,
|
|
criticalStockThreshold: 7,
|
|
expiryWarningDays: 30,
|
|
lowStockDays: 7,
|
|
normalStockDays: 30,
|
|
highStockDays: 90,
|
|
emailEnabled: false,
|
|
shoutrrrEnabled: false,
|
|
reminderDaysBefore: 7,
|
|
notificationEmail: "",
|
|
lastAutoEmailSent: null,
|
|
lastNotificationType: null,
|
|
lastNotificationChannel: null,
|
|
upcomingTodayOnly: false,
|
|
shareScheduleTodayOnly: false,
|
|
},
|
|
scheduleDays: 30,
|
|
setScheduleDays: vi.fn(),
|
|
showPastDays: false,
|
|
setShowPastDays: vi.fn(),
|
|
pastDays: [],
|
|
todayDay: null,
|
|
futureDays: [],
|
|
takenDoses: new Set(),
|
|
skippedDoses: new Set(),
|
|
dismissedDoses: new Set(),
|
|
markDoseTaken: vi.fn(),
|
|
undoDoseTaken: vi.fn(),
|
|
coverage: { all: [], low: [] },
|
|
coverageByMed: {},
|
|
depletionByMed: {},
|
|
stockThresholds: {
|
|
lowStockDays: 7,
|
|
normalStockDays: 30,
|
|
highStockDays: 90,
|
|
criticalStockDays: 7,
|
|
expiryWarningDays: 30,
|
|
},
|
|
manuallyExpandedDays: new Set(),
|
|
manuallyCollapsedDays: new Set(),
|
|
toggleDayCollapse: vi.fn(),
|
|
openMedDetail: vi.fn(),
|
|
openUserFilter: vi.fn(),
|
|
openShareDialog: vi.fn(),
|
|
openScheduleLightbox: vi.fn(),
|
|
missedPastDoseIds: [],
|
|
getDayStockStatus: vi.fn(() => "success"),
|
|
getDoseId: vi.fn((id, person) => (person ? `${id}-${person}` : id)),
|
|
isDoseTakenAutomatically: vi.fn(() => false),
|
|
loadMeds: vi.fn(),
|
|
loadSettings: vi.fn(),
|
|
...overrides,
|
|
});
|
|
|
|
let mockContextValue = createMockAppContext();
|
|
|
|
describe("DashboardPage helper functions", () => {
|
|
it("builds user storage key correctly", () => {
|
|
expect(userStorageKey(5, "scheduleDays")).toBe("user_5_scheduleDays");
|
|
expect(userStorageKey(undefined, "scheduleDays")).toBe("scheduleDays");
|
|
});
|
|
|
|
it("calculates blister stock breakdown", () => {
|
|
expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 0 });
|
|
});
|
|
|
|
it("formats blister and open blister labels", () => {
|
|
const t = (key: string) => key;
|
|
expect(formatFullBlisters(1, t)).toBe("1 common.blister");
|
|
expect(formatFullBlisters(3, t)).toBe("3 common.blisters");
|
|
expect(formatOpenBlisterAndLoose(0, 0, 10, t)).toBe("-");
|
|
expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills + 4 modal.loosePills");
|
|
});
|
|
|
|
it("computes total pills for blister and bottle types", () => {
|
|
expect(
|
|
getMedTotal({
|
|
packageType: "blister",
|
|
packCount: 1,
|
|
blistersPerPack: 2,
|
|
pillsPerBlister: 10,
|
|
looseTablets: 3,
|
|
stockAdjustment: 2,
|
|
})
|
|
).toBe(25);
|
|
|
|
expect(
|
|
getMedTotal({
|
|
packageType: "bottle",
|
|
packCount: 0,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 1,
|
|
looseTablets: 50,
|
|
stockAdjustment: -3,
|
|
})
|
|
).toBe(47);
|
|
});
|
|
|
|
it("builds reminder status data for critical and history rows", () => {
|
|
const t = (key: string) => key;
|
|
const result = getReminderStatusData(
|
|
7,
|
|
30,
|
|
[{ name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null }],
|
|
[
|
|
{ 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",
|
|
"A",
|
|
"Max",
|
|
"2026-01-01T09:00:00.000Z",
|
|
"both",
|
|
"A (+1)",
|
|
t,
|
|
"en-US"
|
|
);
|
|
|
|
expect(result.status.className).toBe("danger");
|
|
expect(result.lowStockMeds.length).toBe(2);
|
|
expect(result.lastStockSent).not.toBeNull();
|
|
expect(result.lastIntakeSent?.medName).toBe("A");
|
|
});
|
|
|
|
it("builds warning and success reminder statuses", () => {
|
|
const t = (key: string) => key;
|
|
|
|
const warning = getReminderStatusData(
|
|
7,
|
|
30,
|
|
[],
|
|
[{ name: "C", daysLeft: 12, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
|
[],
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
t,
|
|
"en-US"
|
|
);
|
|
expect(warning.status.className).toBe("warning");
|
|
|
|
const success = getReminderStatusData(
|
|
7,
|
|
30,
|
|
[],
|
|
[{ name: "D", daysLeft: 40, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
|
[],
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
t,
|
|
"en-US"
|
|
);
|
|
expect(success.status.className).toBe("success");
|
|
expect(success.lastStockSent).toBeNull();
|
|
expect(success.lastIntakeSent).toBeNull();
|
|
});
|
|
});
|
|
|
|
// Mock the context
|
|
vi.mock("../../context", () => ({
|
|
useAppContext: () => mockContextValue,
|
|
}));
|
|
|
|
vi.mock("../../components/Auth", () => ({
|
|
useAuth: () => ({
|
|
user: { id: 1, username: "testuser" },
|
|
authFetch: authFetchMock,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../context/FeedbackContext", () => ({
|
|
useFeedback: () => ({
|
|
showFeedback: feedbackMock.showFeedback,
|
|
dismissFeedback: vi.fn(),
|
|
clearFeedback: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
describe("DashboardPage", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext();
|
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
});
|
|
|
|
it("renders dashboard page", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should render the dashboard section
|
|
const section = document.querySelector("section.grid");
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders reorder section title", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText(/dashboard\.reorder\.title/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders overview section title", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText(/dashboard\.overview\.title/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders schedule section title", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders empty state when no medications", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// With no meds, should show the dashboard cards
|
|
const cards = document.querySelectorAll(".card");
|
|
expect(cards.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders today doses even when schedule data omits takenBy arrays", () => {
|
|
mockContextValue = createMockAppContext({
|
|
todayDay: {
|
|
dateStr: "Today",
|
|
date: new Date(),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [
|
|
{
|
|
id: "dose-without-taken-by",
|
|
timeStr: "09:00",
|
|
when: Date.now() + 60_000,
|
|
usage: 1,
|
|
takenBy: undefined as unknown as string[],
|
|
},
|
|
],
|
|
lastWhen: Date.now() + 60_000,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument();
|
|
expect(screen.getByText("09:00")).toBeInTheDocument();
|
|
});
|
|
|
|
it("disables the journal note action for untaken doses", () => {
|
|
const openJournalEditor = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
openJournalEditor,
|
|
todayDay: {
|
|
dateStr: "Today",
|
|
date: new Date(),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [
|
|
{
|
|
id: "untaken-dose",
|
|
timeStr: "09:00",
|
|
when: Date.now() + 60_000,
|
|
usage: 1,
|
|
takenBy: ["John"],
|
|
},
|
|
],
|
|
lastWhen: Date.now() + 60_000,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
|
|
expect(noteButton).toBeDisabled();
|
|
expect(noteButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
|
|
|
|
fireEvent.click(noteButton);
|
|
expect(openJournalEditor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("enables the journal note action for skipped doses", () => {
|
|
const openJournalEditor = vi.fn();
|
|
const skippedDoseId = "skipped-dose-John";
|
|
mockContextValue = createMockAppContext({
|
|
openJournalEditor,
|
|
skippedDoses: new Set([skippedDoseId]),
|
|
todayDay: {
|
|
dateStr: "Today",
|
|
date: new Date(),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [
|
|
{
|
|
id: "skipped-dose",
|
|
timeStr: "09:00",
|
|
when: Date.now() - 60_000,
|
|
usage: 1,
|
|
takenBy: ["John"],
|
|
},
|
|
],
|
|
lastWhen: Date.now() - 60_000,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
|
|
expect(noteButton).not.toBeDisabled();
|
|
|
|
fireEvent.click(noteButton);
|
|
expect(openJournalEditor).toHaveBeenCalledWith(skippedDoseId);
|
|
});
|
|
|
|
it("closes the journal editor after saving a main app note", async () => {
|
|
const saveJournalNote = vi.fn(async () => true);
|
|
const closeJournalEditor = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
journalEditorOpen: true,
|
|
journalEvent: mockJournalEntry,
|
|
journalEventLoading: false,
|
|
journalEventSaving: false,
|
|
journalEventDeleting: false,
|
|
journalEventError: null,
|
|
saveJournalNote,
|
|
closeJournalEditor,
|
|
deleteJournalNote: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
|
|
target: { value: "Main app note" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
|
|
|
await waitFor(() => {
|
|
expect(saveJournalNote).toHaveBeenCalledWith("Main app note");
|
|
expect(closeJournalEditor).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("renders schedule days selector", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have schedule days select dropdown
|
|
const select = document.querySelector(".schedule-days-select");
|
|
expect(select).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders timeline section", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have timeline div
|
|
const timeline = document.querySelector(".timeline");
|
|
expect(timeline).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders table headers for overview", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have table headers
|
|
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders runs-out and expiry as a stacked date pair in overview rows", () => {
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const headerPair = document.querySelector(".table-head .date-pair-stack-header");
|
|
expect(headerPair).toBeInTheDocument();
|
|
expect(headerPair).toHaveTextContent("table.runsOut");
|
|
expect(headerPair).toHaveTextContent("table.expiry");
|
|
|
|
const rowPair = document.querySelector(".table-row .date-pair-stack");
|
|
expect(rowPair).toBeInTheDocument();
|
|
|
|
const rowEntries = Array.from(rowPair?.querySelectorAll(".date-pair-entry") ?? []);
|
|
expect(rowEntries).toHaveLength(2);
|
|
expect(rowEntries[0]).toHaveTextContent("table.runsOut");
|
|
expect(rowEntries[0]).toHaveTextContent("2025-02-15");
|
|
expect(rowEntries[1]).toHaveTextContent("table.expiry");
|
|
});
|
|
|
|
it("renders multiple cards", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Dashboard has multiple cards
|
|
const cards = document.querySelectorAll(".card");
|
|
expect(cards.length).toBeGreaterThan(2);
|
|
});
|
|
|
|
it("renders card heads", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have card heads for each section
|
|
const cardHeads = document.querySelectorAll(".card-head");
|
|
expect(cardHeads.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders table headers", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have table head
|
|
const tableHead = document.querySelector(".table-head");
|
|
expect(tableHead).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders table structure", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have table class
|
|
const table = document.querySelector(".table");
|
|
expect(table).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders no meds message for reorder section", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// When no meds, should show empty state
|
|
expect(screen.getByText(/dashboard\.reorder\.noMeds/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage interactions", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext();
|
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
});
|
|
|
|
it("has schedule days options", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have 30, 90, 180 day options
|
|
const select = document.querySelector(".schedule-days-select");
|
|
expect(select).toBeInTheDocument();
|
|
|
|
const options = select?.querySelectorAll("option");
|
|
expect(options?.length).toBe(3);
|
|
});
|
|
|
|
it("can change schedule days", () => {
|
|
const setScheduleDays = vi.fn();
|
|
mockContextValue = createMockAppContext({ setScheduleDays });
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const select = document.querySelector(".schedule-days-select") as HTMLSelectElement;
|
|
expect(select).toBeInTheDocument();
|
|
|
|
fireEvent.change(select, { target: { value: "90" } });
|
|
expect(setScheduleDays).toHaveBeenCalledWith(90);
|
|
});
|
|
|
|
it("renders today doses when skip state is missing from an older app context shape", () => {
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
todayDay: mockTodayDay,
|
|
skippedDoses: undefined,
|
|
markDoseSkipped: undefined,
|
|
undoDoseSkipped: undefined,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText("Today")).toBeInTheDocument();
|
|
expect(document.querySelector(".day-block.today .dose-btn.take")).toBeInTheDocument();
|
|
expect(document.querySelector(".day-block.today .dose-btn.skip")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps the dashboard rendered when notification focus scrolling fails", async () => {
|
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
|
HTMLElement.prototype.scrollIntoView = vi.fn(() => {
|
|
throw new Error("scroll failed");
|
|
});
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
todayDay: mockTodayDay,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter
|
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
|
>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Today")).toBeInTheDocument();
|
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
|
expect(targetDose).toHaveClass("notification-focus-target");
|
|
});
|
|
});
|
|
|
|
it("highlights and scrolls to the notification-linked dashboard dose", async () => {
|
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
todayDay: mockTodayDay,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter
|
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
|
>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
|
const targetRow = document.querySelector('[data-med-id="1"]');
|
|
expect(targetDose).toHaveClass("notification-focus-target");
|
|
expect(targetRow).toHaveClass("notification-focus-target-row");
|
|
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
|
|
});
|
|
});
|
|
|
|
it("supports the shorter dashboard notification query params", async () => {
|
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
todayDay: mockTodayDay,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter
|
|
initialEntries={[`/dashboard?day=${getRouteDateKey(mockTodayDay.date)}&dose=${encodeURIComponent(doseId)}`]}
|
|
>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
|
const targetRow = document.querySelector('[data-med-id="1"]');
|
|
expect(targetDose).toHaveClass("notification-focus-target");
|
|
expect(targetRow).toHaveClass("notification-focus-target-row");
|
|
});
|
|
});
|
|
|
|
it("scrolls to the notification-linked dashboard dose after schedule data loads", async () => {
|
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
|
mockContextValue = createMockAppContext();
|
|
|
|
const { rerender } = render(
|
|
<MemoryRouter
|
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
|
>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(document.querySelector(`[data-dose-id="${doseId}"]`)).toBeNull();
|
|
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
|
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
todayDay: mockTodayDay,
|
|
});
|
|
|
|
rerender(
|
|
<MemoryRouter
|
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
|
>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
|
expect(targetDose).toHaveClass("notification-focus-target");
|
|
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext();
|
|
});
|
|
|
|
it("renders multiple section grids", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const sections = document.querySelectorAll("section.grid");
|
|
expect(sections.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders card head actions", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const cardHeadActions = document.querySelector(".card-head-actions");
|
|
expect(cardHeadActions).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders all table columns", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have all expected table columns
|
|
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/table\.stock(?!Details)/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/table\.stockDetails/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/table\.status/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage with medications", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: {
|
|
Aspirin: mockCoverage.all[0],
|
|
"Vitamin D": mockCoverage.all[1],
|
|
},
|
|
depletionByMed: {
|
|
Aspirin: Date.now() + 25 * 86400000,
|
|
"Vitamin D": Date.now() + 3 * 86400000,
|
|
},
|
|
futureDays: mockFutureDays,
|
|
});
|
|
});
|
|
|
|
it("renders medication rows in overview table", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show medication names (may appear in multiple places)
|
|
const aspirinElements = screen.getAllByText("Aspirin");
|
|
const vitaminDElements = screen.getAllByText("Vitamin D");
|
|
expect(aspirinElements.length).toBeGreaterThan(0);
|
|
expect(vitaminDElements.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders low stock section with low stock medications", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show the low stock medication name
|
|
const vitaminDElements = screen.getAllByText("Vitamin D");
|
|
expect(vitaminDElements.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders taken by badges", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show taken by badge for Aspirin
|
|
const johnBadges = screen.getAllByText("John");
|
|
expect(johnBadges.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders medication icons for reminders and notes", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// 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", () => {
|
|
// Need showFutureDays: true for day-blocks to render
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: {
|
|
Aspirin: mockCoverage.all[0],
|
|
},
|
|
futureDays: mockFutureDays,
|
|
showFutureDays: true,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show day block
|
|
const dayBlocks = document.querySelectorAll(".day-block");
|
|
expect(dayBlocks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("calls openMedDetail when clicking medication row", () => {
|
|
const openMedDetail = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
openMedDetail,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Click on medication row
|
|
const aspirinRow = screen.getAllByText("Aspirin")[0].closest(".table-row");
|
|
if (aspirinRow) {
|
|
fireEvent.click(aspirinRow);
|
|
expect(openMedDetail).toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("calls openUserFilter when clicking taken by badge", () => {
|
|
const openUserFilter = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
openUserFilter,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Click on taken by badge
|
|
const johnBadge = screen.getAllByText("John")[0];
|
|
fireEvent.click(johnBadge);
|
|
expect(openUserFilter).toHaveBeenCalledWith("John");
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage with email notifications", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
settings: {
|
|
...createMockAppContext().settings,
|
|
emailEnabled: true,
|
|
emailStockReminders: true,
|
|
notificationEmail: "test@example.com",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("renders reminder status bar when email enabled", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show reminder status bar
|
|
const statusBar = document.querySelector(".reminder-status-bar");
|
|
expect(statusBar).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides reorder reminder card when reminders are enabled (to avoid redundancy)", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
|
|
// The send reminder button IS shown in the reminder status bar (not the reorder card)
|
|
expect(document.querySelector(".reminder-status-bar")).toBeInTheDocument();
|
|
expect(screen.queryByText(/dashboard\.reorder\.title/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage with shoutrrr notifications", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
settings: {
|
|
...createMockAppContext().settings,
|
|
shoutrrrEnabled: true,
|
|
shoutrrrStockReminders: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("renders notification status bar when shoutrrr enabled", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show status bar
|
|
const statusBar = document.querySelector(".reminder-status-bar");
|
|
expect(statusBar).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows send reminder button when stock reminders are enabled and low stock exists", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText("dashboard.reorder.sendReminder")).toBeInTheDocument();
|
|
});
|
|
|
|
it("sends manual reminder notification on button click", async () => {
|
|
global.fetch = vi.fn().mockImplementation((url: string) => {
|
|
if (url === "/api/reminder/send-email") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true, message: "Notification sent via push" }),
|
|
});
|
|
}
|
|
// Settings refresh after successful send
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({}),
|
|
});
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
|
|
fireEvent.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(authFetchMock).toHaveBeenCalledWith(
|
|
"/api/reminder/send-email",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Notification sent via push")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows error message when manual reminder fails", async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
json: () => Promise.resolve({ error: "No notification channels configured" }),
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
|
|
fireEvent.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No notification channels configured")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage with past days", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
pastDays: mockPastDays,
|
|
futureDays: mockFutureDays,
|
|
showPastDays: false,
|
|
missedPastDoseIds: [`1-0-${Date.now() - 86400000}-John`],
|
|
});
|
|
});
|
|
|
|
it("renders past days toggle when past days exist", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show past days toggle
|
|
const toggle = document.querySelector(".past-days-toggle");
|
|
expect(toggle).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows missed dose warning count", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show warning with missed count
|
|
const warning = document.querySelector(".past-days-warning");
|
|
expect(warning).toBeInTheDocument();
|
|
});
|
|
|
|
it("toggles past days visibility", () => {
|
|
const setShowPastDays = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
pastDays: mockPastDays,
|
|
showPastDays: false,
|
|
setShowPastDays,
|
|
missedPastDoseIds: [],
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const toggle = document.querySelector(".past-days-toggle");
|
|
if (toggle) {
|
|
fireEvent.click(toggle);
|
|
expect(setShowPastDays).toHaveBeenCalledWith(true);
|
|
}
|
|
});
|
|
|
|
it("collapses past days when already expanded", () => {
|
|
const setShowPastDays = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
pastDays: mockPastDays,
|
|
showPastDays: true,
|
|
setShowPastDays,
|
|
missedPastDoseIds: [],
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const toggle = document.querySelector(".past-days-toggle");
|
|
if (toggle) {
|
|
fireEvent.click(toggle);
|
|
expect(setShowPastDays).toHaveBeenCalledWith(false);
|
|
}
|
|
});
|
|
|
|
it("posts the computed dismiss-until payload when clearing missed doses", async () => {
|
|
const loadMeds = vi.fn();
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverageByMed: { Aspirin: { medsLeft: 25, daysLeft: 25 } },
|
|
pastDays: mockPastDays,
|
|
missedPastDoseIds: [`${mockPastDays[0].meds[0].doses[0].id}-John`],
|
|
loadMeds,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissed/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(authFetchMock).toHaveBeenCalledWith(
|
|
"/api/medications/dismiss-until",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
})
|
|
);
|
|
});
|
|
|
|
const body = JSON.parse(((authFetchMock as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
|
|
expect(body).toEqual({
|
|
medicationIds: [1],
|
|
until: mockPastDays[0].date.toISOString().slice(0, 10),
|
|
});
|
|
expect(loadMeds).toHaveBeenCalled();
|
|
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
|
|
message: expect.stringContaining("dashboard.schedules.clearMissedSuccess"),
|
|
tone: "success",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage additional branches", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
});
|
|
|
|
it("calls openShareDialog when share button is clicked", () => {
|
|
const openShareDialog = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
openShareDialog,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /share\.button/i }));
|
|
expect(openShareDialog).toHaveBeenCalled();
|
|
});
|
|
|
|
it("toggles future days visibility", () => {
|
|
const setShowFutureDays = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
futureDays: mockFutureDays,
|
|
showFutureDays: false,
|
|
setShowFutureDays,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const futureToggle = document.querySelector(".future-days-toggle");
|
|
expect(futureToggle).toBeInTheDocument();
|
|
fireEvent.click(futureToggle!);
|
|
expect(setShowFutureDays).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("shows network error on manual reminder fetch failure", async () => {
|
|
global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
settings: {
|
|
...createMockAppContext().settings,
|
|
emailEnabled: true,
|
|
emailStockReminders: true,
|
|
notificationEmail: "test@example.com",
|
|
},
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("dashboard.reorder.sendReminder"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("common.networkError")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("opens medication detail from last stock reminder med link", () => {
|
|
const openMedDetail = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
settings: {
|
|
...createMockAppContext().settings,
|
|
emailEnabled: true,
|
|
emailStockReminders: true,
|
|
lastStockReminderSent: "2026-02-10T10:00:00.000Z",
|
|
lastStockReminderChannel: "email",
|
|
lastStockReminderMedNames: "Aspirin (+1)",
|
|
},
|
|
openMedDetail,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const reminderMedLink = document.querySelector(".reminder-status-bar .med-link") as HTMLElement;
|
|
expect(reminderMedLink).toBeInTheDocument();
|
|
fireEvent.click(reminderMedLink);
|
|
expect(openMedDetail).toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists selected schedule days to localStorage", () => {
|
|
const setScheduleDays = vi.fn();
|
|
const setItemSpy = vi.spyOn(window.localStorage, "setItem");
|
|
mockContextValue = createMockAppContext({ setScheduleDays });
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const select = document.querySelector(".schedule-days-select") as HTMLSelectElement;
|
|
fireEvent.change(select, { target: { value: "180" } });
|
|
|
|
expect(setScheduleDays).toHaveBeenCalledWith(180);
|
|
expect(setItemSpy).toHaveBeenCalledWith("user_1_scheduleDays", "180");
|
|
});
|
|
|
|
it("opens schedule lightbox when clicking medication avatar image", () => {
|
|
const openScheduleLightbox = vi.fn();
|
|
const medsWithImage = [{ ...mockMeds[0], imageUrl: "aspirin.png" }];
|
|
const futureDay = [
|
|
{
|
|
dateStr: "Tomorrow",
|
|
date: new Date(Date.now() + 86400000),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [{ id: "1-0-1", timeStr: "09:00", when: Date.now() + 86400000, usage: 1, takenBy: [] }],
|
|
lastWhen: Date.now() + 86400000,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
mockContextValue = createMockAppContext({
|
|
meds: medsWithImage,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
depletionByMed: { Aspirin: Date.now() + 10 * 86400000 },
|
|
futureDays: futureDay,
|
|
showFutureDays: true,
|
|
manuallyExpandedDays: new Set(["Tomorrow"]),
|
|
openScheduleLightbox,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const avatar = document.querySelector(".day-block .med-avatar.clickable") as HTMLElement;
|
|
expect(avatar).toBeInTheDocument();
|
|
fireEvent.click(avatar);
|
|
expect(openScheduleLightbox).toHaveBeenCalledWith("/api/images/aspirin.png");
|
|
});
|
|
|
|
it("renders and interacts with today day schedule block", () => {
|
|
const markDoseTaken = vi.fn();
|
|
const undoDoseTaken = vi.fn();
|
|
const todayDoseId = "1-0-1000";
|
|
const today = {
|
|
dateStr: "Today",
|
|
date: new Date(),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [{ id: todayDoseId, timeStr: "09:00", when: Date.now() - 1000, usage: 1, takenBy: ["John"] }],
|
|
lastWhen: Date.now() - 1000,
|
|
},
|
|
],
|
|
};
|
|
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
depletionByMed: { Aspirin: Date.now() + 10 * 86400000 },
|
|
todayDay: today,
|
|
markDoseTaken,
|
|
undoDoseTaken,
|
|
takenDoses: new Set<string>(),
|
|
getDoseId: vi.fn((id: string, person: string | null) => (person ? `${id}-${person}` : id)),
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText("Today")).toBeInTheDocument();
|
|
const takeButton = document.querySelector(".day-block.today .dose-btn.take") as HTMLButtonElement;
|
|
expect(takeButton).toBeInTheDocument();
|
|
fireEvent.click(takeButton);
|
|
expect(markDoseTaken).toHaveBeenCalled();
|
|
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
depletionByMed: { Aspirin: Date.now() + 10 * 86400000 },
|
|
todayDay: today,
|
|
markDoseTaken,
|
|
undoDoseTaken,
|
|
takenDoses: new Set<string>([`${todayDoseId}-John`]),
|
|
manuallyExpandedDays: new Set<string>(["Today"]),
|
|
getDoseId: vi.fn((id: string, person: string | null) => (person ? `${id}-${person}` : id)),
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const undoButton = document.querySelector(".day-block.today .dose-btn.undo") as HTMLButtonElement;
|
|
expect(undoButton).toBeInTheDocument();
|
|
fireEvent.click(undoButton);
|
|
expect(undoDoseTaken).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage with expanded past days", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
pastDays: mockPastDays,
|
|
futureDays: mockFutureDays,
|
|
showPastDays: true,
|
|
manuallyExpandedDays: new Set(["Sun, Jan 21"]),
|
|
getDayStockStatus: vi.fn(() => "success"),
|
|
});
|
|
});
|
|
|
|
it("renders past day blocks when showPastDays is true", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show past day block
|
|
const pastDayBlocks = document.querySelectorAll(".day-block.past");
|
|
expect(pastDayBlocks.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage dose interactions", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
});
|
|
|
|
it("calls markDoseTaken when clicking take button", () => {
|
|
const markDoseTaken = vi.fn();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
depletionByMed: { Aspirin: Date.now() + 25 * 86400000 },
|
|
futureDays: mockFutureDays,
|
|
markDoseTaken,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Find and click take button
|
|
const takeBtn = document.querySelector(".dose-btn.take");
|
|
if (takeBtn) {
|
|
fireEvent.click(takeBtn);
|
|
expect(markDoseTaken).toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("calls undoDoseTaken when clicking undo button", () => {
|
|
const undoDoseTaken = vi.fn();
|
|
const doseId = `1-0-${Date.now()}-John`;
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: mockCoverage,
|
|
coverageByMed: { Aspirin: mockCoverage.all[0] },
|
|
depletionByMed: { Aspirin: Date.now() + 25 * 86400000 },
|
|
futureDays: mockFutureDays,
|
|
takenDoses: new Set([doseId]),
|
|
undoDoseTaken,
|
|
getDoseId: vi.fn(() => doseId),
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Find and click undo button
|
|
const undoBtn = document.querySelector(".dose-btn.undo");
|
|
if (undoBtn) {
|
|
fireEvent.click(undoBtn);
|
|
expect(undoDoseTaken).toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage good stock state", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext({
|
|
meds: mockMeds,
|
|
coverage: {
|
|
all: [
|
|
{
|
|
name: "Aspirin",
|
|
medsLeft: 100,
|
|
daysLeft: 100,
|
|
depletionDate: "2025-05-01",
|
|
depletionTime: Date.now() + 100 * 86400000,
|
|
nextDose: null,
|
|
},
|
|
],
|
|
low: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("shows all good message when no low stock", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show all good message
|
|
expect(screen.getByText(/dashboard\.reorder\.allGood/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("DashboardPage bottle package type", () => {
|
|
const bottleMed = {
|
|
id: 3,
|
|
name: "Ibuprofen",
|
|
packageType: "bottle" as const,
|
|
packCount: 0,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 1,
|
|
looseTablets: 100,
|
|
totalPills: 200,
|
|
takenBy: [],
|
|
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00Z" }],
|
|
intakeRemindersEnabled: false,
|
|
notes: null,
|
|
expiryDate: null,
|
|
imageUrl: null,
|
|
updatedAt: null,
|
|
};
|
|
|
|
const bottleCoverage = {
|
|
name: "Ibuprofen",
|
|
medsLeft: 100,
|
|
daysLeft: 50,
|
|
depletionDate: "2025-04-01",
|
|
depletionTime: Date.now() + 50 * 86400000,
|
|
nextDose: null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockAppContext({
|
|
meds: [bottleMed],
|
|
coverage: { all: [bottleCoverage], low: [] },
|
|
coverageByMed: { Ibuprofen: bottleCoverage },
|
|
});
|
|
});
|
|
|
|
it("renders pill count instead of blisters for bottle type", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should show medication name
|
|
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
|
|
|
|
// Should show pills count (bottle shows pillsCount, not blisters)
|
|
expect(screen.getByText(/table\.pillsCount/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows dash for stock details column for bottle type", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// For bottle type, the stock details column shows "—"
|
|
const dashElements = document.querySelectorAll('[data-label="table.stockDetails"]');
|
|
const bottleDetails = Array.from(dashElements).find((el) => el.textContent === "—");
|
|
expect(bottleDetails).toBeInTheDocument();
|
|
});
|
|
});
|