/** * 0.1.38 存档根治测试套件 * * 覆盖点: * 1. SqlarkDriver.open 失败后可重试(不永久锁死) * 2. Stash.save 返回 { ok, error } 而非 void * 3. SaveSlot.saveState 回读校验(写入后 SELECT 确认数据落盘) * 4. OPFS 降级逻辑(probeOpfs 返回 false 时走 kv 后端配置) * 5. 诊断接口返回完整报告 */ import { describe, expect, it, beforeEach } from 'vitest' import { SaveSlot, type SaveDbDriver } from '../src/renderer/game/storage/slots' import { World } from '../src/renderer/game/engine/runtime/World' import type { GameState } from '../src/renderer/game/types/domain' import { setSqlarkBackend, _resetSlotCache, _resetStorageProbe, getStorageBackendType, probeOpfs, } from '../src/renderer/game/storage/db' import { MeSqlark } from '@metona-team/metona-sqlark' // ──────────────────────────────────────────────────────────────────────────── // Mock Driver:可控失败 + 回读校验 // ──────────────────────────────────────────────────────────────────────────── interface Row { [k: string]: unknown } class ControllableDriver implements SaveDbDriver { tables = new Map>() private failNext = false private openCallCount = 0 setOpenFail(on: boolean): void { this.failNext = on } getOpenCount(): number { return this.openCallCount } async open(_name: string): Promise { this.openCallCount++ if (this.failNext) { this.failNext = false throw new Error('OPFS_OPEN_ERROR: simulated failure') } } async close(): Promise {} async run(sql: string, params: unknown[] = []): Promise { const ins = /insert into (\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i.exec(sql) if (ins) { const cols = ins[2].split(',').map((c) => c.trim()) const row: Row = {} cols.forEach((c, i) => (row[c] = params[i])) const t = this.tables.get(ins[1]) ?? new Map() t.set(String(row['id'] ?? ins[1] + t.size), row) this.tables.set(ins[1], t) } const upd = /update\s+(\w+)\s+set/i.exec(sql) if (upd) { const setCol = /set\s+(\w+)\s*=\s*\?/i.exec(sql)![1] const setVal = params[0] const lit = /\bwhere\s+(\w+)\s*=\s*'([^']+)'/i.exec(sql) const ph = /where\s+(\w+)\s*=\s*\?/i.exec(sql) const col = (lit?.[1] ?? ph?.[1]) as string const val = (lit?.[2] as string | undefined) ?? String(params[1]) const t = this.tables.get(upd[1]) if (t) { for (const [k, row] of t.entries()) { if (String(row[col]) === val) t.set(k, { ...row, [setCol]: setVal }) } } } const del = /delete from (\w+)\s+where\s+(\w+)\s*=\s*\?/i.exec(sql) if (del) { const t = this.tables.get(del[1]) if (t) { for (const [k, row] of t.entries()) { if (String(row[del[2]]) === String(params[0])) t.delete(k) } } } return null } async all(sql: string, params: unknown[] = []): Promise { const lower = sql.toLowerCase() const table = /from (\w+)/i.exec(sql)?.[1] if (!table) return [] let rows = [...(this.tables.get(table)?.values() ?? [])].map((r) => ({ ...r })) if (/\bwhere\b/.test(lower)) { const m = /where\s+(\w+)\s*=\s*\?/i.exec(sql) if (m && params[0] !== undefined) { rows = rows.filter((r) => String(r[m[1]]) === String(params[0])) } } if (/\border by/i.test(lower)) { rows = rows.sort((a, b) => Number(b['year']) - Number(a['year']) || Number(b['month']) - Number(a['month'])) } if (/select data/i.test(lower)) rows = rows.map((r) => ({ data: String(r['data']) })) if (/select value/i.test(lower)) rows = rows.map((r) => ({ value: String(r['value']) })) return rows as T[] } async exec(sql: string): Promise { const create = /create table (?:if not exists )?(\w+)/i.exec(sql) if (create && !this.tables.has(create[1])) this.tables.set(create[1], new Map()) const del = /delete from (\w+)/i.exec(sql) if (del) this.tables.set(del[1], new Map()) return null } } // ──────────────────────────────────────────────────────────────────────────── // Mock Backend for metona-sqlark (hybrid memory mode, like real test) // ──────────────────────────────────────────────────────────────────────────── function memoryBackend(): void { setSqlarkBackend({ create: async (cfg: { name: string }) => { const db = new MeSqlark({ name: `it-${cfg.name}-${Math.random().toString(36).slice(2)}`, mode: 'hybrid', diskEngine: 'memory', aria: {} }) await db.init() return { query: (sql: string, params?: unknown[]) => db.query(sql, params) } } } as never) } function saveClone(w: World): GameState { return JSON.parse(JSON.stringify(w.state)) as GameState } // ──────────────────────────────────────────────────────────────────────────── // Tests // ──────────────────────────────────────────────────────────────────────────── describe('0.1.38 存档根治 · P0-C: SqlarkDriver.open 失败后可重试', () => { beforeEach(() => { _resetSlotCache() _resetStorageProbe() }) it('首次 open 失败后,第二次 open 不被永久锁死', async () => { // 用 ControllableDriver 模拟首次失败 const driver = new ControllableDriver() driver.setOpenFail(true) const slot = new SaveSlot(1, driver) // 第一次 open 应该失败 await expect(slot.open()).rejects.toThrow('simulated failure') expect(driver.getOpenCount()).toBe(1) // 第二次 open 应该成功(关键:旧版会返回已 rejected 的 promise,永远锁死) await slot.open() expect(driver.getOpenCount()).toBe(2) }) it('连续多次失败后仍可恢复', async () => { const driver = new ControllableDriver() driver.setOpenFail(true) const slot = new SaveSlot(2, driver) // 第一次失败 await expect(slot.open()).rejects.toThrow() // 第二次也失败(手动再设) driver.setOpenFail(true) await expect(slot.open()).rejects.toThrow() // 第三次成功 await slot.open() expect(driver.getOpenCount()).toBe(3) }) }) describe('0.1.38 存档根治 · P1-F: SaveSlot.saveState 回读校验', () => { it('saveState 后数据确实写入并可回读', async () => { const driver = new ControllableDriver() const slot = new SaveSlot(3, driver) const w = World.create({ seed: 'verify-1', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' }) const id = await slot.saveState(saveClone(w), 'test') expect(id).toBeTruthy() // 回读校验:loadState 应返回有效数据 const loaded = await slot.loadState() expect(loaded).not.toBeNull() expect(loaded!.family.surname).toBe('赵') expect(loaded!.seed).toBe('verify-1') }) it('saveState 数据完整性(关键字段 family/members/seed)', async () => { const driver = new ControllableDriver() const slot = new SaveSlot(4, driver) const w = World.create({ seed: 'verify-2', surname: '钱', familyName: '钱家', motto: 'm', difficulty: 'normal' }) w.advanceMonth() w.advanceMonth() await slot.saveState(saveClone(w), 'advance') const loaded = await slot.loadState() expect(loaded).not.toBeNull() expect(loaded!.family).toBeDefined() expect(loaded!.members).toBeDefined() expect(typeof loaded!.seed).toBe('string') expect(loaded!.year).toBeGreaterThanOrEqual(1) }) }) describe('0.1.38 存档根治 · P0-A: OPFS 降级配置', () => { beforeEach(() => { _resetStorageProbe() _resetSlotCache() memoryBackend() }) it('probeOpfs 在无 navigator.storage 环境返回 false', async () => { // node 环境无 navigator.storage const result = await probeOpfs() expect(result).toBe(false) }) it('getStorageBackendType 在 setSqlarkBackend 后为 mock', () => { // memoryBackend() 已在 beforeEach 中调用了 setSqlarkBackend expect(getStorageBackendType()).toBe('mock') }) }) describe('0.1.38 存档根治 · 存储链路回归(hybrid memory backend)', () => { beforeEach(() => { _resetSlotCache() _resetStorageProbe() memoryBackend() }) it('save→list→load 全链路 6 种子不崩', async () => { for (let k = 0; k < 6; k++) { const slot = (k % 4) + 1 const w = World.create({ seed: `regr-${k}`, surname: '孙', familyName: '孙家', motto: 'm', difficulty: 'normal' }) for (let i = 0; i < 8; i++) w.advanceMonth() const { getSaveSlot } = await import('../src/renderer/game/storage/db') const file = await getSaveSlot(slot) await file.open() await file.saveState(saveClone(w), 'auto') const loaded = await file.loadState() expect(loaded).toBeTruthy() expect(loaded!.year).toBeGreaterThan(0) } }) it('多次 save 后快照不超过 12 份', async () => { const { getSaveSlot } = await import('../src/renderer/game/storage/db') const file = await getSaveSlot(4) await file.open() const w = World.create({ seed: 'trim-1', surname: '李', familyName: '李家', motto: 'm', difficulty: 'normal' }) for (let i = 0; i < 20; i++) { w.state.year = i + 1 await file.saveState(saveClone(w), 'auto') } const snaps = await file.listSnapshots() expect(snaps.length).toBe(12) }) }) describe('0.1.38 存档根治 · 版本号一致性', () => { it('slots.ts GAME_VERSION = 0.1.38', async () => { const { buildSimpleMeta } = await import('../src/renderer/game/storage/slots') const w = World.create({ seed: 'ver-1', surname: '周', familyName: '周家', motto: 'm', difficulty: 'normal' }) const meta = buildSimpleMeta(w.state, 1, 5) expect(meta.version).toBe('0.1.38') }) it('storeHelper metaFromState version = 0.1.38', async () => { const { metaFromState } = await import('../src/renderer/ui/storeHelper') const w = World.create({ seed: 'ver-2', surname: '吴', familyName: '吴家', motto: 'm', difficulty: 'normal' }) const meta = metaFromState(w.state, 1) expect(meta.version).toBe('0.1.38') }) })