fd055a3a2a
* test(frontend): add vitest test infrastructure - Add vitest, testing-library, jsdom dependencies - Configure vitest with jsdom environment - Add test setup with mocks for fetch, localStorage, matchMedia - Set 75% coverage threshold * Add frontend tests (#61) * Initial plan * Add frontend tests - utilities, hooks, and components (21% coverage) Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Add more component tests (Auth, AboutModal, ExportModal) and useRefill hook tests - 30% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Add useMedicationForm utility function tests - 30% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Add AppHeader tests and more schedule.ts tests - 32% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Changes before error encountered Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Fix page tests and add more tests - 326 tests passing, 34% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Add tests for ProfileModal, UserFilterModal, MedDetailModal - 361 tests, 36% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Add SharedSchedule tests - 366 tests, 39% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Expand page tests - 383 tests, 39% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Add MobileEditModal tests - 409 tests, 40% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Expand Dashboard and Schedule page tests - 427 tests, 40% coverage Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Fix code review issues - remove invalid remindEnabled property Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
556 lines
14 KiB
TypeScript
556 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
buildSchedulePreview,
|
|
calculateCoverage,
|
|
getStockStatus,
|
|
getNextReminderForMed,
|
|
getReminderStatusText
|
|
} from '../../utils/schedule';
|
|
import type { Medication, Coverage, StockThresholds } from '../../types';
|
|
|
|
describe('buildSchedulePreview', () => {
|
|
beforeEach(() => {
|
|
vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('returns empty events for empty medications array', () => {
|
|
const result = buildSchedulePreview([], 'en', false);
|
|
expect(result.events).toEqual([]);
|
|
expect(result.today).toBe(0);
|
|
expect(result.totalBlisters).toBe(0);
|
|
});
|
|
|
|
it('returns empty for non-array input', () => {
|
|
const result = buildSchedulePreview(null as unknown as Medication[], 'en', false);
|
|
expect(result.events).toEqual([]);
|
|
});
|
|
|
|
it('builds events for medication with schedule', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'TestMed',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: ['John'],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-14T09:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const result = buildSchedulePreview(meds, 'en', true);
|
|
expect(result.events.length).toBeGreaterThan(0);
|
|
expect(result.totalBlisters).toBe(1);
|
|
});
|
|
|
|
it('filters out past events when includePast is false', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'TestMed',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: [],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-01T09:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const withPast = buildSchedulePreview(meds, 'en', true);
|
|
const withoutPast = buildSchedulePreview(meds, 'en', false);
|
|
|
|
expect(withPast.events.length).toBeGreaterThanOrEqual(withoutPast.events.length);
|
|
});
|
|
|
|
it('handles invalid date in blister start', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'TestMed',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: [],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: 'invalid-date'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const result = buildSchedulePreview(meds, 'en', true);
|
|
// Should not crash, events for invalid dates are skipped
|
|
expect(Array.isArray(result.events)).toBe(true);
|
|
});
|
|
|
|
it('sorts events by time', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'Morning Med',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: [],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-15T09:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}, {
|
|
id: 2,
|
|
name: 'Evening Med',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: [],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-15T21:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const result = buildSchedulePreview(meds, 'en', false);
|
|
for (let i = 1; i < result.events.length; i++) {
|
|
expect(result.events[i].when).toBeGreaterThanOrEqual(result.events[i - 1].when);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('calculateCoverage', () => {
|
|
beforeEach(() => {
|
|
vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('calculates coverage for medication with schedule', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'TestMed',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: [],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-15T09:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const events = [{ medName: 'TestMed', when: Date.now() }];
|
|
const result = calculateCoverage(meds, events, 'en', 7, 'automatic', new Set());
|
|
|
|
expect(result.all).toHaveLength(1);
|
|
expect(result.all[0].name).toBe('TestMed');
|
|
expect(result.all[0].daysLeft).toBeDefined();
|
|
});
|
|
|
|
it('handles medication with no schedule', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'NoSchedule',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: [],
|
|
blisters: [],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set());
|
|
|
|
expect(result.all).toHaveLength(1);
|
|
expect(result.all[0].daysLeft).toBeNull();
|
|
});
|
|
|
|
it('filters low stock medications', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'LowStock',
|
|
packCount: 0,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 5,
|
|
takenBy: [],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-15T09:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set());
|
|
expect(result.low.length).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('respects manual stock calculation mode', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'TestMed',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: [],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-10T09:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const takenDoses = new Set(['1-0-1710061200000']);
|
|
const result = calculateCoverage(meds, [], 'en', 7, 'manual', takenDoses);
|
|
|
|
expect(result.all).toHaveLength(1);
|
|
});
|
|
|
|
it('handles multiple takenBy people', () => {
|
|
const meds: Medication[] = [{
|
|
id: 1,
|
|
name: 'SharedMed',
|
|
packCount: 1,
|
|
blistersPerPack: 1,
|
|
pillsPerBlister: 30,
|
|
looseTablets: 0,
|
|
takenBy: ['Alice', 'Bob'],
|
|
blisters: [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: '2024-03-15T09:00:00'
|
|
}],
|
|
updatedAt: null
|
|
}];
|
|
|
|
const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set());
|
|
expect(result.all).toHaveLength(1);
|
|
// Daily rate should be doubled for 2 people
|
|
});
|
|
});
|
|
|
|
describe('getStockStatus', () => {
|
|
const thresholds: StockThresholds = {
|
|
lowStockDays: 30,
|
|
normalStockDays: 90,
|
|
highStockDays: 180
|
|
};
|
|
|
|
it('returns out-of-stock when medsLeft is 0', () => {
|
|
const result = getStockStatus(5, 0, thresholds);
|
|
expect(result.level).toBe('out-of-stock');
|
|
expect(result.className).toBe('danger');
|
|
});
|
|
|
|
it('returns out-of-stock when daysLeft is 0', () => {
|
|
const result = getStockStatus(0, 5, thresholds);
|
|
expect(result.level).toBe('out-of-stock');
|
|
expect(result.className).toBe('danger');
|
|
});
|
|
|
|
it('returns high when daysLeft > highStockDays', () => {
|
|
const result = getStockStatus(200, 100, thresholds);
|
|
expect(result.level).toBe('high');
|
|
expect(result.className).toBe('high');
|
|
});
|
|
|
|
it('returns normal when daysLeft >= lowStockDays', () => {
|
|
const result = getStockStatus(50, 100, thresholds);
|
|
expect(result.level).toBe('normal');
|
|
expect(result.className).toBe('success');
|
|
});
|
|
|
|
it('returns low when daysLeft < lowStockDays', () => {
|
|
const result = getStockStatus(20, 100, thresholds);
|
|
expect(result.level).toBe('low');
|
|
expect(result.className).toBe('warning');
|
|
});
|
|
|
|
it('returns normal when daysLeft is null but medsLeft > 0', () => {
|
|
const result = getStockStatus(null, 100, thresholds);
|
|
expect(result.level).toBe('normal');
|
|
expect(result.label).toBe('status.noSchedule');
|
|
});
|
|
});
|
|
|
|
describe('getNextReminderForMed', () => {
|
|
beforeEach(() => {
|
|
vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
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>) => {
|
|
if (options?.count) return `${key} (${options.count})`;
|
|
if (options?.days) return `${key} (${options.days})`;
|
|
return key;
|
|
};
|
|
|
|
it('shows empty stock warning first', () => {
|
|
const emptyMed: Coverage = {
|
|
name: 'Empty',
|
|
medsLeft: 0,
|
|
daysLeft: 0,
|
|
depletionDate: null,
|
|
depletionTime: null,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(7, 30, [], [emptyMed], null, null, null, mockT, 'en');
|
|
expect(result.lines[0].text).toContain('dashboard.reminders.emptyStock');
|
|
expect(result.lines[0].className).toBe('danger-text');
|
|
});
|
|
|
|
it('shows all ok when everything is fine', () => {
|
|
const healthyMed: Coverage = {
|
|
name: 'Healthy',
|
|
medsLeft: 100,
|
|
daysLeft: 60,
|
|
depletionDate: null,
|
|
depletionTime: Date.now() + 60 * 86400000,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(7, 30, [], [healthyMed], null, null, null, mockT, 'en');
|
|
expect(result.lines[0].text).toContain('dashboard.reminders.allOk');
|
|
});
|
|
|
|
it('includes last sent info if available', () => {
|
|
// For healthy meds with no upcoming reminders, it goes to the final fallback
|
|
// which returns allStockOk and includes lastReminder info
|
|
const healthyMed: Coverage = {
|
|
name: 'Healthy',
|
|
medsLeft: 100,
|
|
daysLeft: 200,
|
|
depletionDate: null,
|
|
depletionTime: Date.now() + 200 * 86400000,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(
|
|
7, 30, [], [healthyMed],
|
|
'2024-03-10T10:00:00Z',
|
|
'stock',
|
|
'email',
|
|
mockT,
|
|
'en'
|
|
);
|
|
// Either allOk or allStockOk includes last reminder info
|
|
const hasLastReminder = result.lines.some(l =>
|
|
l.text.includes('lastReminder') ||
|
|
l.text.includes('allOk') ||
|
|
l.text.includes('allStockOk')
|
|
);
|
|
expect(hasLastReminder).toBe(true);
|
|
});
|
|
|
|
it('shows low warning for medications running low', () => {
|
|
const lowMed: Coverage = {
|
|
name: 'RunningLow',
|
|
medsLeft: 20,
|
|
daysLeft: 20,
|
|
depletionDate: null,
|
|
depletionTime: Date.now() + 20 * 86400000,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, 'en');
|
|
expect(result.lines.some(l => l.text.includes('lowWarning') || l.text.includes('needReorder'))).toBe(true);
|
|
});
|
|
|
|
it('handles intake reminder type with push channel', () => {
|
|
const emptyMed: Coverage = {
|
|
name: 'Empty',
|
|
medsLeft: 0,
|
|
daysLeft: 0,
|
|
depletionDate: null,
|
|
depletionTime: null,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(
|
|
7, 30, [], [emptyMed],
|
|
'2024-03-10T10:00:00Z',
|
|
'intake',
|
|
'push',
|
|
mockT,
|
|
'en'
|
|
);
|
|
expect(result.lines[0].className).toBe('danger-text');
|
|
});
|
|
|
|
it('handles both channel type', () => {
|
|
const emptyMed: Coverage = {
|
|
name: 'Empty',
|
|
medsLeft: 0,
|
|
daysLeft: 0,
|
|
depletionDate: null,
|
|
depletionTime: null,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(
|
|
7, 30, [], [emptyMed],
|
|
'2024-03-10T10:00:00Z',
|
|
'stock',
|
|
'both',
|
|
mockT,
|
|
'en'
|
|
);
|
|
expect(result.lines[0].className).toBe('danger-text');
|
|
});
|
|
|
|
it('shows needReorder when below critical threshold', () => {
|
|
const criticalMed: Coverage = {
|
|
name: 'Critical',
|
|
medsLeft: 5,
|
|
daysLeft: 5,
|
|
depletionDate: null,
|
|
depletionTime: Date.now() + 5 * 86400000,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(
|
|
7, 30, [criticalMed], [criticalMed],
|
|
null, null, null, mockT, 'en'
|
|
);
|
|
expect(result.lines.some(l => l.text.includes('needReorder'))).toBe(true);
|
|
});
|
|
|
|
it('shows low warning when below low threshold but above critical', () => {
|
|
const lowMed: Coverage = {
|
|
name: 'Low',
|
|
medsLeft: 20,
|
|
daysLeft: 20,
|
|
depletionDate: null,
|
|
depletionTime: Date.now() + 20 * 86400000,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(
|
|
7, 30, [], [lowMed],
|
|
null, null, null, mockT, 'en'
|
|
);
|
|
expect(result.lines.some(l => l.text.includes('lowWarning'))).toBe(true);
|
|
});
|
|
|
|
it('returns noRemindersNeeded when all ok and no last sent', () => {
|
|
const result = getReminderStatusText(
|
|
7, 30, [], [],
|
|
null, null, null, mockT, 'en'
|
|
);
|
|
expect(result.lines.some(l =>
|
|
l.text.includes('noRemindersNeeded') || l.text.includes('allStockOk')
|
|
)).toBe(true);
|
|
});
|
|
|
|
it('handles empty and critical meds together', () => {
|
|
const emptyMed: Coverage = {
|
|
name: 'Empty',
|
|
medsLeft: 0,
|
|
daysLeft: 0,
|
|
depletionDate: null,
|
|
depletionTime: null,
|
|
nextDose: null
|
|
};
|
|
|
|
const criticalMed: Coverage = {
|
|
name: 'Critical',
|
|
medsLeft: 5,
|
|
daysLeft: 5,
|
|
depletionDate: null,
|
|
depletionTime: Date.now() + 5 * 86400000,
|
|
nextDose: null
|
|
};
|
|
|
|
const lowMed: Coverage = {
|
|
name: 'Low',
|
|
medsLeft: 20,
|
|
daysLeft: 20,
|
|
depletionDate: null,
|
|
depletionTime: Date.now() + 20 * 86400000,
|
|
nextDose: null
|
|
};
|
|
|
|
const result = getReminderStatusText(
|
|
7, 30, [criticalMed], [emptyMed, criticalMed, lowMed],
|
|
null, null, null, mockT, 'en'
|
|
);
|
|
expect(result.lines[0].text).toContain('emptyStock');
|
|
expect(result.lines.length).toBeGreaterThan(1);
|
|
});
|
|
});
|