Files
ChronicleOfTheImmortalClan/tests/storage-resilience-0.1.38.test.ts

279 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Map<string, Row>>()
private failNext = false
private openCallCount = 0
setOpenFail(on: boolean): void { this.failNext = on }
getOpenCount(): number { return this.openCallCount }
async open(_name: string): Promise<void> {
this.openCallCount++
if (this.failNext) {
this.failNext = false
throw new Error('OPFS_OPEN_ERROR: simulated failure')
}
}
async close(): Promise<void> {}
async run(sql: string, params: unknown[] = []): Promise<unknown> {
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<string, Row>()
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<T>(sql: string, params: unknown[] = []): Promise<T[]> {
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<unknown> {
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.41 存档根治 · 版本号一致性', () => {
it('slots.ts GAME_VERSION = 0.1.41', 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.41')
})
it('storeHelper metaFromState version = 0.1.41', 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.41')
})
})