import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Exercise, HistoryEntry, Meal, QuietHours, Settings } from '@shared/types' import { DEFAULT_SETTINGS } from '@shared/types' /** * Тесты gating-логики scheduler'а. Дёргаем публичный `forceCheck()` (он * сбрасывает lastCheckAt и прогоняет tick → checkDueExercises) и проверяем, * вызвался ли `fireReminder`. Стор/нотификации/meeting/adaptive замоканы. */ const h = vi.hoisted(() => ({ settings: null as Settings | null, exercises: [] as Exercise[], meals: [] as Meal[], history: [] as HistoryEntry[], meetingActive: false, fireReminder: vi.fn(), fireMealReminder: vi.fn(), updateExercise: vi.fn(), updateMeal: vi.fn(), broadcastState: vi.fn(), refreshMeetingState: vi.fn(), adjustNextFireAt: vi.fn((_ex: Exercise, candidate: number) => candidate) })) vi.mock('electron', () => ({ powerMonitor: { on: vi.fn() }, BrowserWindow: { getAllWindows: () => [] } })) vi.mock('./store', () => ({ getSettings: () => h.settings, getExercises: () => h.exercises, getMeals: () => h.meals, getHistory: () => h.history, updateExercise: (id: string, patch: Partial) => { h.updateExercise(id, patch) const ex = h.exercises.find((e) => e.id === id) return ex ? { ...ex, ...patch } : undefined }, updateMeal: (id: string, patch: Partial) => { h.updateMeal(id, patch) const m = h.meals.find((e) => e.id === id) return m ? { ...m, ...patch } : undefined } })) vi.mock('./notifications', () => ({ fireReminder: h.fireReminder, fireMealReminder: h.fireMealReminder })) vi.mock('./state-actions', () => ({ broadcastState: h.broadcastState })) vi.mock('./meeting-detect', () => ({ isMeetingActiveSync: () => h.meetingActive, refreshMeetingState: h.refreshMeetingState })) vi.mock('./adaptive', () => ({ adjustNextFireAt: h.adjustNextFireAt })) function makeExercise(over: Partial = {}): Exercise { return { id: 'ex1', name: 'Приседания', reps: 10, icon: 'Activity', intervalMinutes: 30, enabled: true, nextFireAt: Date.now() - 1000, // due by default ...over } } /** Тихие часы, гарантированно покрывающие текущий момент (без wrap). */ function quietWindowAroundNow(): QuietHours { const now = new Date() const cur = now.getHours() * 60 + now.getMinutes() const fromMin = Math.max(0, cur - 60) const toMin = Math.min(1439, cur + 60) const fmt = (m: number): string => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart( 2, '0' )}` return { enabled: true, from: fmt(fromMin), to: fmt(toMin), days: [] } } async function loadScheduler(): Promise { return import('./scheduler') } function makeMeal(over: Partial = {}): Meal { return { id: 'm1', name: 'Обед', time: '13:00', icon: 'Soup', enabled: true, days: [], nextFireAt: Date.now() - 1000, // due, в пределах grace ...over } } beforeEach(() => { vi.resetModules() h.settings = { ...DEFAULT_SETTINGS } h.exercises = [] h.meals = [] h.history = [] h.meetingActive = false h.fireReminder.mockClear() h.fireMealReminder.mockClear() h.updateExercise.mockClear() h.updateMeal.mockClear() h.broadcastState.mockClear() h.refreshMeetingState.mockClear() h.adjustNextFireAt.mockClear() }) describe('checkDueExercises gating', () => { it('не fire-ит когда globalEnabled=false', async () => { h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false } h.exercises = [makeExercise()] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireReminder).not.toHaveBeenCalled() }) it('не fire-ит внутри тихих часов', async () => { h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() } h.exercises = [makeExercise()] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireReminder).not.toHaveBeenCalled() }) it('не fire-ит когда активна ВКС (meetingAutoPause)', async () => { h.settings = { ...DEFAULT_SETTINGS, meetingAutoPause: true } h.meetingActive = true h.exercises = [makeExercise()] const { forceCheck } = await loadScheduler() forceCheck() expect(h.refreshMeetingState).toHaveBeenCalled() expect(h.fireReminder).not.toHaveBeenCalled() }) it('fire-ит готовое к срабатыванию упражнение и шлёт broadcastState', async () => { h.exercises = [makeExercise()] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireReminder).toHaveBeenCalledTimes(1) expect(h.broadcastState).toHaveBeenCalled() }) it('пропускает выключенные упражнения', async () => { h.exercises = [makeExercise({ enabled: false })] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireReminder).not.toHaveBeenCalled() }) it('не fire-ит упражнение, чьё время ещё не пришло', async () => { h.exercises = [makeExercise({ nextFireAt: Date.now() + 60_000 })] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireReminder).not.toHaveBeenCalled() }) it('soft-cap: при закрытой dailyGoal переносит fire, но не показывает', async () => { h.exercises = [makeExercise({ dailyGoal: 20 })] h.history = [ { ts: Date.now(), exerciseId: 'ex1', action: 'done', actualReps: 25 } ] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireReminder).not.toHaveBeenCalled() // nextFireAt перенесён (на завтра) — updateExercise вызван. expect(h.updateExercise).toHaveBeenCalled() }) it('dailyGoal использует reps snapshot из истории, а не текущие reps упражнения', async () => { h.exercises = [makeExercise({ reps: 25, dailyGoal: 20 })] h.history = [ { ts: Date.now(), exerciseId: 'ex1', action: 'done', reps: 10 } ] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireReminder).toHaveBeenCalledTimes(1) }) it('adaptive: применяет adjustNextFireAt к кандидату', async () => { h.exercises = [makeExercise({ adaptive: true })] const { forceCheck } = await loadScheduler() forceCheck() expect(h.adjustNextFireAt).toHaveBeenCalled() expect(h.fireReminder).toHaveBeenCalledTimes(1) }) }) describe('checkDueMeals', () => { it('fire-ит приём пищи, чьё время наступило (в пределах grace)', async () => { h.meals = [makeMeal()] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireMealReminder).toHaveBeenCalledTimes(1) // Переносит nextFireAt вперёд. expect(h.updateMeal).toHaveBeenCalled() }) it('пропускает выключенный приём пищи', async () => { h.meals = [makeMeal({ enabled: false })] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireMealReminder).not.toHaveBeenCalled() }) it('не fire-ит при globalEnabled=false', async () => { h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false } h.meals = [makeMeal()] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireMealReminder).not.toHaveBeenCalled() }) it('пропущенный давно (> grace) переносится без срабатывания', async () => { h.meals = [makeMeal({ nextFireAt: Date.now() - 10 * 60_000 })] const { forceCheck } = await loadScheduler() forceCheck() expect(h.fireMealReminder).not.toHaveBeenCalled() expect(h.updateMeal).toHaveBeenCalled() // всё равно переносим вперёд }) it('приёмы пищи ИГНОРИРУЮТ тихие часы (в отличие от упражнений)', async () => { h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() } h.exercises = [makeExercise()] h.meals = [makeMeal()] const { forceCheck } = await loadScheduler() forceCheck() // Упражнение подавлено тихими часами... expect(h.fireReminder).not.toHaveBeenCalled() // ...а приём пищи всё равно срабатывает. expect(h.fireMealReminder).toHaveBeenCalledTimes(1) }) })