feat(robustness+ui): отказоустойчивость main, тесты, a11y-полировка, лицензия
Надёжность main-процесса: - глобальные uncaughtException/unhandledRejection (лог + flushNow) - safeHandle/safeOn вокруг всех IPC-хендлеров (не падаем молча, generic-ошибка наружу) - таймаут 4s на tasklist, Atomics.wait вместо busy-spin на exit-записи - единый log.error для фоновых сбоев вместо console.error/тишины Тесты (178 -> 203): meeting-detect, scheduler-gating, store (миграции/карантин/cap). UI/UX: - prefers-reduced-motion через MotionConfig + CSS media-блок - Spinner/Skeleton примитивы, loading-состояния вместо пустых заглушек - aria-live анонсы достижений и выполнения (useAnnounce) - оформленные пустые состояния, клавиатура в меню ExerciseCard Лицензия: проприетарный LICENSE + правка README/CLAUDE.md, счётчик тестов. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
167
src/main/scheduler.test.ts
Normal file
167
src/main/scheduler.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type {
|
||||
Exercise,
|
||||
HistoryEntry,
|
||||
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[],
|
||||
history: [] as HistoryEntry[],
|
||||
meetingActive: false,
|
||||
fireReminder: vi.fn(),
|
||||
updateExercise: 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,
|
||||
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
|
||||
}
|
||||
}))
|
||||
vi.mock('./notifications', () => ({ fireReminder: h.fireReminder }))
|
||||
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')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
h.settings = { ...DEFAULT_SETTINGS }
|
||||
h.exercises = []
|
||||
h.history = []
|
||||
h.meetingActive = false
|
||||
h.fireReminder.mockClear()
|
||||
h.updateExercise.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('adaptive: применяет adjustNextFireAt к кандидату', async () => {
|
||||
h.exercises = [makeExercise({ adaptive: true })]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.adjustNextFireAt).toHaveBeenCalled()
|
||||
expect(h.fireReminder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user