fix: 进度持久化与断点恢复 + VoiceInput 权限/异常加固 + Jev 故障优雅降级
This commit is contained in:
+75
-1
@@ -15,8 +15,9 @@ import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine';
|
||||
import { LocalStorageEventStore } from '../src/engine/eventStore';
|
||||
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
|
||||
import { choiceFlags, narrationFlags } from '../src/engine/flags';
|
||||
import { MemoryProgressStore } from '../src/engine/progress';
|
||||
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
|
||||
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
|
||||
import type { Chapter, ChoiceNode, IdentityProfile, NarrationNode } from '../src/engine/types';
|
||||
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
|
||||
import { MockJevClient } from '../src/services/jev';
|
||||
import type { JevAnswer, JevQuestion } from '../src/services/jev';
|
||||
@@ -397,6 +398,79 @@ async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
|
||||
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(差分路径)');
|
||||
}
|
||||
|
||||
// --- 11. 进度持久化与恢复(真机刷新/热更新断点续玩) ---
|
||||
{
|
||||
const s = new MemoryStore();
|
||||
const progress = new MemoryProgressStore();
|
||||
const eng = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
||||
await walkTo(eng, 'c1-d5'); // 途经 C1-01 身份点与 C1-03 选择点(产生 flag)
|
||||
const snap = progress.load();
|
||||
check(
|
||||
snap?.chapterId === 'chapter1' && snap.nodeId === 'c1-d5',
|
||||
`推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`,
|
||||
);
|
||||
const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
||||
const restored = eng2.restore(progress.load());
|
||||
check(
|
||||
restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId,
|
||||
'重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)',
|
||||
);
|
||||
check(eng2.state.flags.c1_03_motive === 'a_truth', '恢复后 flags 随快照还原(条件文本不断档)');
|
||||
// 内容更新导致持久化节点消失 → 降级该章开头
|
||||
const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress);
|
||||
check(
|
||||
eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} }) &&
|
||||
eng3.currentNode().id === chapters[0].startNodeId,
|
||||
'持久化节点已不存在 → 降级到该章开头(不崩溃)',
|
||||
);
|
||||
check(!eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} }), '持久化章节不存在 → 不恢复');
|
||||
// replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点)
|
||||
eng2.replay('chapter2');
|
||||
const after = progress.load();
|
||||
check(
|
||||
after?.chapterId === 'chapter2' &&
|
||||
after.nodeId === eng2.state.nodeId &&
|
||||
eng2.state.sessionId !== snap?.sessionId,
|
||||
'replay 产生新 sessionId 并重置持久化位置到目标章(跳过条件隐藏节点后的首个可见节点)',
|
||||
);
|
||||
}
|
||||
|
||||
// --- 12. Jev 调用失败优雅降级(CORS / 网络故障不卡死) ---
|
||||
class FailingJev extends MockJevClient {
|
||||
override async ask(): Promise<Record<string, JevAnswer>> {
|
||||
throw new Error('模拟 Jev 故障:CORS / network');
|
||||
}
|
||||
}
|
||||
{
|
||||
// 12a. 讲述流程:缺失检测失败 → 不追问;情绪签名失败 → 沉默/通用档变体;节点照常推进
|
||||
const s = new MemoryStore();
|
||||
const eng = new StoryEngine(chapters, s, new FailingJev());
|
||||
await walkTo(eng, 'c1-02');
|
||||
const r = await eng.submitNarration('去年夏天我离开了家乡,站台上都是雾');
|
||||
check(r !== null && eng.state.pendingFollowUp === null, 'Jev 故障:缺失检测失败降级为不追问,流程继续');
|
||||
check(eng.state.nodeId === 'c1-d4', 'Jev 故障:变体解析失败后节点照常推进');
|
||||
const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode;
|
||||
const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!;
|
||||
const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02');
|
||||
check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体');
|
||||
// 12b. 身份抽取失败 → 全空字段(UI 直接进可手填确认页),不抛出
|
||||
const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev());
|
||||
await walkTo(eng2, 'c1-01');
|
||||
let profile: IdentityProfile | null = null;
|
||||
let threw = false;
|
||||
try {
|
||||
profile = await eng2.extractIdentity('我叫阿黎,29岁,在杭州');
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
check(
|
||||
!threw && profile !== null && Object.values(profile).every((v) => v === ''),
|
||||
'Jev 故障:身份抽取降级为全空字段进手填确认页(不抛出)',
|
||||
);
|
||||
await eng2.confirmIdentity(profile!);
|
||||
check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进');
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} 项校验失败`);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user