0.1.38 存档根治·石碑重刻:OPFS降级+错误上浮+回读校验+持久化请求+诊断面板+driver.open锁死修复+exportSave补open

This commit is contained in:
2026-09-13 08:02:04 +08:00
parent 8f1f8d48ce
commit 5030eb04d2
11 changed files with 634 additions and 42 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
const f = new GameFacade(w, 1)
const info = f.about()
expect(info.title).toBe('仙途家族志')
expect(info.version).toContain('0.1.37')
expect(info.version).toContain('0.1.38')
expect(info.modules).toBeGreaterThanOrEqual(11)
expect(info.systems).toBeGreaterThan(0)
expect(info.plugins).toBeGreaterThanOrEqual(3)
+1 -1
View File
@@ -51,7 +51,7 @@ describe('GameEngine 引擎门面', () => {
const e = new GameEngine({ seed: 'engine-5' })
const a = e.about()
expect(a.plugins).toBeGreaterThanOrEqual(3)
expect(a.version).toContain('0.1.36')
expect(a.version).toContain('0.1.38')
})
})
+279
View File
@@ -0,0 +1,279 @@
/**
* 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')
})
})