258 lines
8.6 KiB
TypeScript
258 lines
8.6 KiB
TypeScript
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<Exercise>) => {
|
||
h.updateExercise(id, patch)
|
||
const ex = h.exercises.find((e) => e.id === id)
|
||
return ex ? { ...ex, ...patch } : undefined
|
||
},
|
||
updateMeal: (id: string, patch: Partial<Meal>) => {
|
||
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> = {}): 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<typeof import('./scheduler')> {
|
||
return import('./scheduler')
|
||
}
|
||
|
||
function makeMeal(over: Partial<Meal> = {}): 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)
|
||
})
|
||
})
|