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' import { DEFAULT_SETTINGS } from '@shared/types' /** * Тесты 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 { 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 · meal history', () => { it('markMealDone пишет meal-entry в историю', async () => { writeFileSync( statePath(), JSON.stringify({ exercises: [], meals: [ { id: 'm1', name: 'Обед', time: '13:00', icon: 'Soup', enabled: true, days: [], nextFireAt: Date.now() + 60_000 } ], challenges: [], history: [] }), 'utf-8' ) const { markMealDone, getHistory } = await load() expect(markMealDone('m1')).toBeDefined() expect(getHistory()).toMatchObject([ { exerciseId: 'meal:m1', action: 'done', reps: 1, name: 'Обед', source: 'meal' } ]) }) }) 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) }) it('import сохраняет валидные части snapshot и отбрасывает повреждённые записи', async () => { const validExercise = { id: 'x1', name: 'Тест', reps: 10, icon: 'Activity', intervalMinutes: 30, enabled: true, nextFireAt: Date.now() + 1000 } const validMeal = { id: 'm1', name: 'Обед', time: '13:00', icon: 'Soup', enabled: true, days: [], nextFireAt: Date.now() + 1000 } const validChallenge = { id: 'c1', name: 'За убийства', gameId: 'dota2', stat: 'kills', multiplier: 1, exerciseName: 'Отжимания', icon: 'Dumbbell', enabled: true } const { importState, getState, getSettings, getHistory } = await load() expect( importState( JSON.stringify({ exercises: [ validExercise, { ...validExercise, id: 'bad-ex', intervalMinutes: -5 } ], meals: [validMeal, { ...validMeal, id: 'bad-meal', time: '25:00' }], settings: { globalEnabled: false, snoozeMinutes: -1, language: 'xx' }, challenges: [ validChallenge, { ...validChallenge, id: 'bad-challenge', gameId: 'cs2' } ], gamesEnabled: { dota2: true, cs2: true }, history: [ { ts: 100, exerciseId: 'x1', action: 'done', reps: 10 }, { ts: -1, exerciseId: 'x1', action: 'done', reps: 10 }, { ts: 200, exerciseId: 'meal:m1', action: 'done', reps: 1, source: 'meal' } ] }) ) ).toBe(true) const state = getState() expect(state.exercises.map((e) => e.id)).toEqual(['x1']) expect(state.meals.map((m) => m.id)).toEqual(['m1']) expect(state.challenges.map((c) => c.id)).toEqual(['c1']) expect(state.gamesEnabled).toEqual({ dota2: true }) expect(getHistory().map((e) => e.ts)).toEqual([100, 200]) expect(getSettings().globalEnabled).toBe(false) expect(getSettings().snoozeMinutes).toBe(DEFAULT_SETTINGS.snoozeMinutes) expect(getSettings().language).toBe(DEFAULT_SETTINGS.language) }) })