312 lines
9.1 KiB
TypeScript
312 lines
9.1 KiB
TypeScript
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<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 · 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)
|
||
})
|
||
})
|