280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
||
* 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,
|
||
type StorageBackendType,
|
||
} 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.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')
|
||
})
|
||
})
|