Files
medassist-ng/frontend/src/test/utils/schedule.test.ts
T
Daniel Volz fd055a3a2a Feat/frontend tests (#62)
* 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>
2026-01-22 10:25:11 +01:00

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);
});
});