mirror of
https://github.com/motajs/template.git
synced 2026-10-22 07:10:17 +08:00
test(06-09): cover CoreState top-level saveables round trips
- add data-state/test/saveablesRoundTrip.test.ts: public CoreState.saveState/loadState over 5 saveables x 3 compressions, codes 112/113/177/178 - append 06-09 section to 06-COVERAGE-MAP.md (11 codes, create-or-append) - register #06-09-1..5 suspected defects and the pre-existing test:ci blocker in 06-TEST-FINDINGS.md
This commit is contained in:
parent
c707c87add
commit
502d87ba26
@ -82,9 +82,9 @@
|
||||
| 119 | enemy/saveLoad.test.ts | `warns code 119 when the prefab is missing during loadState` | 06-09 |
|
||||
| 120 | enemy/saveLoad.test.ts | `warns code 120 when a special is missing during loadState` | 06-09 |
|
||||
| 122 | map/saveLoad.test.ts | `warns code 122 when a floor is missing during loadState` | 06-09 |
|
||||
| 124 | map/saveLoad.test.ts | `warns code 124 when a floor or its layers are missing in the reference` | 06-09 |
|
||||
| 124 | map/saveLoad.test.ts | `warns code 124 when the compression reference is missing` | 06-09 |
|
||||
| 177 | data-state/test/saveablesRoundTrip.test.ts | `warns code 177 when the save data misses a saveable key` | 06-09 |
|
||||
| 178 | data-state/test/saveablesRoundTrip.test.ts | `warns code 178 when the save data has keys that are not loaded` | 06-09 |
|
||||
| 178 | data-state/test/saveablesRoundTrip.test.ts | `warns code 178 when the save data misses a saveable key`(文案相反的正确预期见 `it.skip`,`#06-09-5`) | 06-09 |
|
||||
|
||||
## 06-01 战斗系统(packages-user/data-system/src/combat)
|
||||
|
||||
@ -234,3 +234,7 @@ D-30:排除名称含 legacy 的接口/方法。
|
||||
D-32:全部 `saveState`/`loadState` 测试集中本计划,其它计划不测(06-07 播放所需状态重置除外)。
|
||||
D-45:`@system/replay` 须已注册(执行前 Task 1 门禁确认);未注册即阻断。录像新码 176 归 06-07。
|
||||
排除清单(存档无关/元数据字段)见 `06-SAVE-EXCLUSIONS.md`。
|
||||
|
||||
执行结果:6 个测试文件 41 通过 / 6 跳过(skip 均为 `#06-09-1..5` 的正确预期疑似缺陷,经临时取消
|
||||
skip 验证确为真实失败);本计划可达码 55/58/59/112/113/119/120/122/124/177/178 全部有触发断言。
|
||||
`pnpm test:ci` 存在**先于本计划**的既有失败(`#06-09` 阻断项),本计划未新增失败。
|
||||
|
||||
@ -110,3 +110,42 @@ flag 的存读档往返归 06-09。D-30:排除名称含 legacy 的接口/方
|
||||
| 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `mover.ts` `ObjectMover.backward`(`Special` 步的移动方向推导) | 连续后退多个步骤时方向来回摆动,净位移为零且朝向被翻转 | 朝向 `Down` 的 mover 调用 `backward(2)` → `start()` → `await controller.onEnd`,最终坐标为 `(0, 0)` 而非 `(0, -2)`,`faceDirection` 变为 `Up` | `prepareStep` 的后退分支把 `moveDirection` 设为 `opposite(dir)`,而 `getCurrentDirection` 又优先读取非 `Unknown` 的 `moveDirection`;下一步据此再次取反,形成交替 | 多步后退(`backward(count>1)`)无法沿同一轴连续移动,并把 `faceDirection` 翻转;单步后退正常 | 后退步的基准方向应取当前朝向 `faceDirection` 而非被翻转后的 `moveDirection`,或在 `getCurrentDirection` 中区分「停顿时的移动方向」与「本步刚设置的临时移动方向」 | `mover.test.ts` `keeps retreating along the same axis across multiple backward steps`(#06-08-1) | 低 |
|
||||
|
||||
## #06-09 存档(独立系统)
|
||||
|
||||
本计划按 D-43 三阶段(构件 → 组合/流水线 → 完整/集成)执行,6 个测试文件全部跑绿
|
||||
(阶段 1:enemy 7 通过;hero 16 通过 / 3 跳过;阶段 2:map 9 通过 / 1 跳过、replay 4 通过 / 1 跳过、
|
||||
flag 3 通过;阶段 3:CoreState 顶层 7 通过 / 1 跳过)。
|
||||
D-45:公开 `CoreState.saveState(compression)` / `loadState(state, compression)` 对 5 个 saveable
|
||||
(`@system/hero`/`flags`/`maps`/`enemy`/`replay`)× 3 档压缩整体往返通过;D-10 未触碰 IndexedDB。
|
||||
本计划可达码 55/58/59/112/113/119/120/122/124/177/178 均已有触发断言(见 `06-COVERAGE-MAP.md` 06-09 小节)。
|
||||
发现 5 处疑似缺陷(`#06-09-1..5`),按 D-05 以正确预期的 `it.skip` 用例登记(6 条 skip,
|
||||
经临时取消 skip 验证确为真实失败),详细如下。
|
||||
|
||||
| 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `hero/equipStore.ts` `EquipmentState.loadState` | 数值加成读档丢失;`NoCompression` 下百分比表被误写入数值表 | 装备定义 `value={atk:5}`;`saveState(NoCompression)` → 修改修饰器 → `loadState` → `getModifiers()` 中再无该数值修饰器;`LowCompression` 往返后未修改的基础加成也丢失 | `loadNoCompression` 两次遍历 `state.percentage`(第一处应为 `state.value`);`loadDiff` 先 `clear()` 且仅用存档差异重建,未回退到 `item.equip` 原始定义 | 装备数值加成无法经存档恢复;Low/High 下所有未修改的装备加成丢失,读档后属性偏低 | `loadNoCompression` 第一循环改遍历 `state.value`;`loadDiff` 以 `item.equip.value/percentage` 为基准再叠加 `state.value` 差异 | `hero/saveLoad.test.ts` `restores a value modifier on the same instance`、`keeps unchanged value modifiers for compressed snapshots` | 高 |
|
||||
| `hero/equipment.ts` `HeroEquipment.saveState` | 存档未深拷贝,`equipped`/`slots` 直接暴露内部引用;随后修改活对象会污染已取出的存档 | `equip(uid,0)` → `saved=equipment.saveState()` → `equipment.unequip(0)` → `equipment.loadState(saved)`:`getEquipped(0)` 为 undefined、`slots` 变为空 | `saveState()` 返回 `{ equipped: this.equips, slots: this.slots }` 未拷贝;`loadState` 又 `this.slots.length=0` 后从同一数组读回 | 内存中直接持有 saveState 结果再继续操作装备的调用方(自动存档栈)会拿到被污染的存档 | `saveState` 返回 `new Map(this.equips)` 与 `[...this.slots]` | `hero/saveLoad.test.ts` `returns an equipment snapshot independent from the live state` | 中 |
|
||||
| `map/dynamicTile.ts` `DynamicTile.loadState` | 读档不恢复存档中的图块数字,仅恢复事件 | `tile=layer.createDynamic(1,1,0)` → `saved=tile.saveState()` → `tile.set(2)` → `tile.loadState(saved)`:`tile.num()` 仍为 2 | `loadState` 只 `restoreDefaultEvents()` 并覆盖事件,未处理 `save.num` | 同实例读档时图块数字不回到存档点;`MapLayer.loadDynamics` 因先 `createDynamic(block.num)` 掩盖该问题 | `loadState` 按 `save.num` 调用 `set()`(或写入 `tileNum` 并同步 raw) | `map/saveLoad.test.ts` `restores the tile num on the same instance` | 中 |
|
||||
| `replay/array.ts` `ReplayArray.saveState` / `loadState` | `loadState` 不恢复录像长度与索引,往返后 `length` 仍为修改后的值 | `array.add(1,[10])` → `saved=array.saveState()` → `array.add(2,[20])` → `array.loadState(saved)`:`array.length` 为 2(正确 1) | `loadState` 只替换命令/参数缓冲区与位宽,未设置 `length`/`paramUsed`、未 `rebuildIndexArray`;`IReplayArraySave` 本身不含 length | 直接用 `ReplayArray` 存读档的调用方读档后仍看到修改后的录像;`ReplaySystem.loadState` 走 `setReplayArray` 不受影响 | 存档补上 `length`(或由缓冲区推导),`loadState` 设置 `length` 并 `rebuildIndexArray` | `replay/saveLoad.test.ts` `restores the recorded length on the same instance` | 中 |
|
||||
| `data-state/src/core.ts` `CoreState.loadState` 码 178 | 码 178 判定与文案相反:缺失 saveable key 同时触发 177/178,存档含「多出的 key」时不告警 | `snapshot.set('@system/extra', null)` → `loadState` 无码 178;`snapshot.delete('@system/flags')` → 同时观测到 177 与 178 | `remain = total.difference(loaded)` 取的是「saveables 中缺失于存档」的键(=177 语义),而非「存档中出现但未加载」的键 | 178 失去独立诊断意义,冗余并可能掩盖真正的版本不一致问题 | 改为 `new Set(state.keys()).difference(new Set(this.saveables.keys()))`,或修正文案 | `data-state/test/saveablesRoundTrip.test.ts` `warns code 178 when the save data has keys that are not loaded` | 低 |
|
||||
|
||||
### 阻断项(非 06-09 可达码,超出本计划范围)
|
||||
|
||||
**现象**:`pnpm test:ci` 存在**先于本计划、与存读档无关**的既有失败:6 个测试文件失败 / 15 个用例失败
|
||||
(`data-base/src/hero/equipment.test.ts`、`follower.test.ts`、`items.test.ts`、`mover.test.ts`,
|
||||
以及因未处理 rejection 计为文件失败的 `data-state/test/dataClosure.test.ts`、`nodeTracer.test.ts`)。
|
||||
|
||||
**根因**:用户提交 `cee8439`(feat: 录像记录 & CoreState 存储接口)在
|
||||
`data-base/src/hero/equipment.ts`、`items.ts`、`mover.ts` 中接入了
|
||||
`this.state.replaySystem.route.add(...)` 录像记录,但这些既有测试的假 `IDataCommon`
|
||||
不含 `replaySystem`,导致 `Cannot read properties of undefined (reading 'route')`。
|
||||
该提交同步更新了 `data-state` 的两个测试文件,但未更新上述 `data-base` 测试。
|
||||
|
||||
**影响**:D-44(c)「`pnpm test:ci` 全绿」在 06-09 执行时无法达成;本计划在**不改动这些越界文件**
|
||||
(硬约束:仅允许新增 6 个 `*.test.ts` 与规划产物)的前提下即**无法修复**。经复核:本计划新增的
|
||||
6 个文件 41 通过 / 6 跳过,**未新增任何失败**,失败集合与改动前完全一致。
|
||||
|
||||
**建议修复方向**:由用户在该提交归属的计划中补齐上述 4 个 `data-base` 测试的假 `replaySystem`
|
||||
(或为生产侧 `this.state.replaySystem` 增加空值守卫),随后重跑 `pnpm test:ci` 应可全绿。
|
||||
**严重度**:高(阻塞 06-09 的 D-44(c) 提交门禁与后续完整里程碑验证)。
|
||||
|
||||
256
packages-user/data-state/test/saveablesRoundTrip.test.ts
Normal file
256
packages-user/data-state/test/saveablesRoundTrip.test.ts
Normal file
@ -0,0 +1,256 @@
|
||||
// 测试 CoreState 顶层存读档:经公开 saveState/loadState 对 5 个 saveable 做整体往返
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { logger } from '@motajs/common';
|
||||
import {
|
||||
type IEnemyAttr,
|
||||
type IHeroAttr,
|
||||
SaveCompression
|
||||
} from '@user/data-common';
|
||||
import {
|
||||
Enemy,
|
||||
type IGameMap,
|
||||
type IHeroStateSave,
|
||||
type IMapLayer,
|
||||
type IMapStoreSave
|
||||
} from '@user/data-base';
|
||||
import { CoreState, createCoreState } from '../src/core';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
value: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
});
|
||||
|
||||
/** CoreState 已注册的 5 个可存档 id */
|
||||
const SAVEABLE_IDS = [
|
||||
'@system/hero',
|
||||
'@system/flags',
|
||||
'@system/maps',
|
||||
'@system/enemy',
|
||||
'@system/replay'
|
||||
] as const;
|
||||
|
||||
/** 存读档测试覆盖的三档压缩级别 */
|
||||
const COMPRESSIONS = [
|
||||
SaveCompression.NoCompression,
|
||||
SaveCompression.LowCompression,
|
||||
SaveCompression.HighCompression
|
||||
] as const;
|
||||
|
||||
/** 构造一个带合成属性的怪物模板对象 */
|
||||
function createEnemy(hp = 20): Enemy<IEnemyAttr> {
|
||||
return new Enemy<IEnemyAttr>('closure-enemy', 1, {
|
||||
hp,
|
||||
atk: 8,
|
||||
def: 5,
|
||||
money: 2,
|
||||
exp: 3,
|
||||
point: 1,
|
||||
guard: new Set()
|
||||
});
|
||||
}
|
||||
|
||||
interface SeededState {
|
||||
map: IGameMap;
|
||||
layer: IMapLayer;
|
||||
}
|
||||
|
||||
/** 为 5 个 saveable 写入已知关键状态 */
|
||||
function seedState(state: CoreState): SeededState {
|
||||
state.hero.getModifiableAttribute().set('hp', 88);
|
||||
state.flags.setFieldValue('score', 7);
|
||||
const map = state.maps.createMap('F1', 2, 2);
|
||||
const layer = map.addLayer();
|
||||
layer.setZIndex(0);
|
||||
map.setActiveStatus(true);
|
||||
state.maps.compareWith(
|
||||
new Map([['F1', new Map([[0, new Uint32Array(4)]])]])
|
||||
);
|
||||
layer.setBlock(5, 0, 0);
|
||||
state.enemyManager.addPrefab(createEnemy());
|
||||
state.enemyManager.compareWith(new Map([[1, createEnemy()]]));
|
||||
state.enemyManager.modifyPrefabAttribute(1, prefab => {
|
||||
prefab.setAttribute('hp', 30);
|
||||
return prefab;
|
||||
});
|
||||
state.replaySystem.record(2, 7);
|
||||
return { map, layer };
|
||||
}
|
||||
|
||||
/** 保存后修改每个 saveable 的关键状态,用于验证读档恢复 */
|
||||
function mutateState(state: CoreState, seeded: SeededState): void {
|
||||
state.hero.getModifiableAttribute().set('hp', 1);
|
||||
state.flags.setFieldValue('score', 0);
|
||||
seeded.layer.setBlock(0, 0, 0);
|
||||
state.enemyManager.modifyPrefabAttribute(1, prefab => {
|
||||
prefab.setAttribute('hp', 99);
|
||||
return prefab;
|
||||
});
|
||||
state.replaySystem.record(9, 1);
|
||||
}
|
||||
|
||||
/** 逐 saveable 断言关键字段回到存档点 */
|
||||
function assertRestored(state: CoreState, seeded: SeededState): void {
|
||||
expect(state.hero.getModifiableAttribute().getBaseAttribute('hp')).toBe(88);
|
||||
expect(state.flags.getFieldValue<number>('score')).toBe(7);
|
||||
expect(seeded.layer.getBlock(0, 0)).toBe(5);
|
||||
expect(state.enemyManager.getPrefab(1)!.getAttribute('hp')).toBe(30);
|
||||
expect(state.replaySystem.route.length).toBe(1);
|
||||
expect(state.replaySystem.route.get(0)).toEqual({
|
||||
command: 2,
|
||||
params: [7],
|
||||
index: 0
|
||||
});
|
||||
}
|
||||
|
||||
describe('CoreState top-level save and load round trips', () => {
|
||||
// 验证 5 个已注册 saveable 在三个压缩档下均经公开入口整体往返
|
||||
it('round-trips every registered saveable across all compressions', () => {
|
||||
for (const compression of COMPRESSIONS) {
|
||||
const state = createCoreState();
|
||||
const seeded = seedState(state);
|
||||
|
||||
const snapshot = state.saveState(compression);
|
||||
expect([...snapshot.keys()].sort()).toEqual(
|
||||
[...SAVEABLE_IDS].sort()
|
||||
);
|
||||
|
||||
mutateState(state, seeded);
|
||||
state.loadState(snapshot, compression);
|
||||
|
||||
assertRestored(state, seeded);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证快照以 5 个 id 为键且与活对象分离
|
||||
it('keeps a snapshot separated from the live saveables', () => {
|
||||
const state = createCoreState();
|
||||
const seeded = seedState(state);
|
||||
|
||||
const snapshot = state.saveState(SaveCompression.NoCompression);
|
||||
expect([...snapshot.keys()].sort()).toEqual([...SAVEABLE_IDS].sort());
|
||||
|
||||
state.hero.getModifiableAttribute().set('hp', 1);
|
||||
seeded.layer.setBlock(0, 0, 0);
|
||||
|
||||
const heroSave = snapshot.get(
|
||||
'@system/hero'
|
||||
) as IHeroStateSave<IHeroAttr>;
|
||||
const mapSave = snapshot.get('@system/maps') as IMapStoreSave;
|
||||
expect(heroSave.attribute.hp).toBe(88);
|
||||
expect(mapSave.floors.get('F1')!.layers.get(0)!.fullMap![0]).toBe(5);
|
||||
});
|
||||
|
||||
// 验证 getSaveableContent 对 5 个 id 返回同一实例,未知 id 返回 null
|
||||
it('resolves the registered saveables by id through getSaveableContent', () => {
|
||||
const state = createCoreState();
|
||||
|
||||
for (const id of SAVEABLE_IDS) {
|
||||
expect(state.getSaveableContent(id)).not.toBeNull();
|
||||
}
|
||||
expect(state.getSaveableContent('@system/hero')).toBe(state.hero);
|
||||
expect(state.getSaveableContent('@system/flags')).toBe(state.flags);
|
||||
expect(state.getSaveableContent('@system/maps')).toBe(state.maps);
|
||||
expect(state.getSaveableContent('@system/enemy')).toBe(
|
||||
state.enemyManager
|
||||
);
|
||||
expect(state.getSaveableContent('@system/replay')).toBe(
|
||||
state.replaySystem
|
||||
);
|
||||
expect(state.getSaveableContent('@missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreState save and load guards', () => {
|
||||
// 验证重复注册同一 saveable id 时经 logger.catch 观测到警告码 112
|
||||
it('warns code 112 when adding a duplicate saveable id', () => {
|
||||
const state = createCoreState();
|
||||
|
||||
const result = logger.catch(() =>
|
||||
state.addSaveableContent('@system/hero', state.hero)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(112);
|
||||
expect(state.getSaveableContent('@system/hero')).toBe(state.hero);
|
||||
});
|
||||
|
||||
// 验证为未添加的对象绑定执行器时经 logger.catch 观测到警告码 113
|
||||
it('warns code 113 when binding an executor to an unadded saveable', () => {
|
||||
const state = createCoreState();
|
||||
const fake = {
|
||||
saveState: (_compression: SaveCompression) => null,
|
||||
loadState: (_state: unknown, _compression: SaveCompression) => {}
|
||||
};
|
||||
|
||||
const result = logger.catch(() =>
|
||||
state.bindSaveableExecuter(fake as never, { afterLoad: () => {} })
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(113);
|
||||
});
|
||||
|
||||
// 验证存档缺失某个 saveable key 时经 logger.catch 观测到警告码 177
|
||||
it('warns code 177 when the save data misses a saveable key', () => {
|
||||
const state = createCoreState();
|
||||
const snapshot = new Map(
|
||||
state.saveState(SaveCompression.NoCompression)
|
||||
);
|
||||
snapshot.delete('@system/flags');
|
||||
|
||||
const result = logger.catch(() =>
|
||||
state.loadState(snapshot, SaveCompression.NoCompression)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(177);
|
||||
});
|
||||
|
||||
// 验证存档缺失 saveable key 时同时观测到警告码 178(当前实现的实际触发路径)
|
||||
it('warns code 178 when the save data misses a saveable key', () => {
|
||||
const state = createCoreState();
|
||||
const snapshot = new Map(
|
||||
state.saveState(SaveCompression.NoCompression)
|
||||
);
|
||||
snapshot.delete('@system/flags');
|
||||
|
||||
const result = logger.catch(() =>
|
||||
state.loadState(snapshot, SaveCompression.NoCompression)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(178);
|
||||
});
|
||||
|
||||
// 疑似 bug:码 178 的判定与文案相反,对「存档多出的 key」不告警,详见 06-TEST-FINDINGS.md #06-09-5
|
||||
it.skip('warns code 178 when the save data has keys that are not loaded', () => {
|
||||
const state = createCoreState();
|
||||
const snapshot = new Map(
|
||||
state.saveState(SaveCompression.NoCompression)
|
||||
);
|
||||
snapshot.set('@system/extra', null);
|
||||
|
||||
const result = logger.catch(() =>
|
||||
state.loadState(snapshot, SaveCompression.NoCompression)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(178);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user