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:
198
src/main/store.test.ts
Normal file
198
src/main/store.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
readdirSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Тесты persistence-слоя. Мокаем electron.app.getPath на временную директорию
|
||||
* (новую на каждый тест) и logger. resetModules + dynamic import сбрасывают
|
||||
* module-level `cache`/`storePath`, чтобы тесты не текли друг в друга.
|
||||
*/
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
userData: '',
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => h.userData,
|
||||
getVersion: () => '0.0.0-test'
|
||||
}
|
||||
}))
|
||||
vi.mock('./logger', () => ({ log: h.log }))
|
||||
|
||||
async function load(): Promise<typeof import('./store')> {
|
||||
return import('./store')
|
||||
}
|
||||
|
||||
function statePath(): string {
|
||||
return join(h.userData, 'app-state.json')
|
||||
}
|
||||
|
||||
function corruptFiles(): string[] {
|
||||
return readdirSync(h.userData).filter((f) => f.includes('.corrupt-'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
h.userData = mkdtempSync(join(tmpdir(), 'laude-store-'))
|
||||
h.log.error.mockClear()
|
||||
h.log.warn.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(h.userData, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
describe('store · cold start', () => {
|
||||
it('создаёт app-state.json с примерами упражнений на первом запуске', async () => {
|
||||
const { getState } = await load()
|
||||
const state = getState()
|
||||
expect(state.exercises.length).toBeGreaterThan(0)
|
||||
expect(existsSync(statePath())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · corrupt file quarantine', () => {
|
||||
it('битый JSON уносится в .corrupt-* и стартует чистый state', async () => {
|
||||
writeFileSync(statePath(), '{ this is : not json', 'utf-8')
|
||||
const { getState } = await load()
|
||||
const state = getState()
|
||||
expect(state.exercises.length).toBeGreaterThan(0) // initial
|
||||
expect(corruptFiles().length).toBe(1)
|
||||
expect(h.log.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('валидный JSON, но не объект (массив) — тоже карантин', async () => {
|
||||
writeFileSync(statePath(), '[1,2,3]', 'utf-8')
|
||||
const { getState } = await load()
|
||||
expect(getState().exercises.length).toBeGreaterThan(0)
|
||||
expect(corruptFiles().length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · coerce / migrations', () => {
|
||||
it('подставляет дефолтные settings когда их нет в файле', async () => {
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [], challenges: [], history: [] }),
|
||||
'utf-8'
|
||||
)
|
||||
const { getSettings } = await load()
|
||||
const s = getSettings()
|
||||
expect(s.globalEnabled).toBeDefined()
|
||||
expect(s.notificationMode).toBeDefined()
|
||||
expect(s.snoozeMinutes).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('файл без __schemaVersion грузится без потери данных', async () => {
|
||||
const ex = {
|
||||
id: 'x1',
|
||||
name: 'Тест',
|
||||
reps: 10,
|
||||
icon: 'Dumbbell',
|
||||
intervalMinutes: 30,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now() + 1000
|
||||
}
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [ex], challenges: [], history: [] }),
|
||||
'utf-8'
|
||||
)
|
||||
const { getExercises } = await load()
|
||||
const list = getExercises()
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0].name).toBe('Тест')
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · history cap', () => {
|
||||
it('обрезает историю когда превышен HISTORY_MAX', async () => {
|
||||
const ex = {
|
||||
id: 'x1',
|
||||
name: 'Приседания',
|
||||
reps: 10,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 30,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now()
|
||||
}
|
||||
const big = Array.from({ length: 10_005 }, (_unused, i) => ({
|
||||
ts: i,
|
||||
exerciseId: 'x1',
|
||||
action: 'done' as const,
|
||||
reps: 10
|
||||
}))
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [ex], challenges: [], history: big }),
|
||||
'utf-8'
|
||||
)
|
||||
const { markDone, getHistory } = await load()
|
||||
markDone('x1')
|
||||
// 10005 + 1 = 10006 > 10000 → slice(-9000)
|
||||
expect(getHistory().length).toBe(9000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · clearHistory', () => {
|
||||
it('удаляет записи старше границы и возвращает количество', async () => {
|
||||
const ex = {
|
||||
id: 'x1',
|
||||
name: 'A',
|
||||
reps: 5,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 10,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now()
|
||||
}
|
||||
const history = [
|
||||
{ ts: 100, exerciseId: 'x1', action: 'done' as const },
|
||||
{ ts: 200, exerciseId: 'x1', action: 'done' as const },
|
||||
{ ts: 300, exerciseId: 'x1', action: 'done' as const }
|
||||
]
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({ exercises: [ex], challenges: [], history }),
|
||||
'utf-8'
|
||||
)
|
||||
const { clearHistory, getHistory } = await load()
|
||||
const removed = clearHistory(250)
|
||||
expect(removed).toBe(2)
|
||||
expect(getHistory().map((e) => e.ts)).toEqual([300])
|
||||
})
|
||||
|
||||
it('отказывается чистить без явной границы (защита от полного wipe)', async () => {
|
||||
const { clearHistory } = await load()
|
||||
expect(clearHistory()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · export / import', () => {
|
||||
it('export даёт валидный JSON со схемой; import парсит его обратно', async () => {
|
||||
const { exportState, importState } = await load()
|
||||
const json = exportState()
|
||||
const parsed = JSON.parse(json)
|
||||
expect(typeof parsed.__schemaVersion).toBe('number')
|
||||
expect(parsed.__schemaVersion).toBeGreaterThanOrEqual(1)
|
||||
expect(importState(json)).toBe(true)
|
||||
})
|
||||
|
||||
it('import отклоняет мусор', async () => {
|
||||
const { importState } = await load()
|
||||
expect(importState('not json at all')).toBe(false)
|
||||
expect(importState('42')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user