Overworld
指南

测试指南

store 驱动断言(官方推荐)、内置 data-testid 一览与确定性测试要点

官方推荐路径:直接读 store,而不是抓 DOM

Overworld 的所有运行时状态都放在可导入的 zustand store / 引擎对象里, 这是框架的第一断言面。通知、对话、任务等系统本身是无头的(引擎只管队列 与状态,渲染完全由游戏实现),因此"当前有哪些 toast / 对话选项"这类问题, 标准答案是读 store,而不是查询 DOM:

系统断言入口典型断言
通知 toastuseToastStore.getState().toasts队列长度、message / variant
alert / confirmuseAlertStore.getState().current / .queue当前对话框、resolveCurrent(true) 驱动确认
对话dialogue.store.getState()(createDialogueEngine 返回值)当前节点、可选响应列表
任务quests.store.getState()(createQuestEngine 返回值)active 进度、completed 集合
场景useSceneStore.getState()当前场景、附近可交互实体
编辑器useEditorStore.getState()实体工作集、选择集、undo/redo 栈
小地图useMinimapStore.getState().markers标记注册/位置
键盘层useKeyboardStore.getState()活跃层、isKeyBlocked

单测里直接 import 即可;组件测试也一样 —— 断言 store,渲染层只是投影。

Playwright:暴露 debug 句柄 + page.evaluate

E2E 里拿不到模块作用域,推荐模式是游戏在 dev/test 构建里把 store 挂到 window 上(一次装配,处处可测):

// game/debug.ts —— 仅在 dev / E2E 构建中引入
import { useToastStore, useAlertStore } from '@overworld-engine/notifications'
import { useSceneStore } from '@overworld-engine/scene'
import { quests, dialogue } from './engines'

if (import.meta.env.MODE !== 'production') {
  ;(window as any).__ow = { useToastStore, useAlertStore, useSceneStore, quests, dialogue }
}
// e2e/quest.spec.ts
const completed = await page.evaluate(() =>
  (window as any).__ow.quests.store.getState().completed
)
expect(completed).toContain('welcome')

// 驱动交互也走同一条路:确认当前 confirm 对话框
await page.evaluate(() => (window as any).__ow.useAlertStore.getState().resolveCurrent(true))

这条路径对时序不敏感(不用等动画/渲染),是我们对"零人工全自动化验收" 的官方建议。纯 DOM 断言(元素确实渲染出来了、位置/可点性)再用下面的 testid。

DOM 断言:内置 data-testid 一览

引擎自渲染的 DOM 元素(自绘覆盖层)都带稳定的 data-testid,前缀可配:

组件prop(默认值)testid
input<VirtualJoystick>testId(ow-joystick)底盘 ow-joystick;摇杆头 ow-joystick-thumb
minimap<MiniMap>testId(ow-minimap)<canvas> 元素 ow-minimap
editor<EditorPanel>testIdPrefix(ow-editor)面板 ow-editor-panel;模式 ow-editor-mode-select / ow-editor-mode-place;工具 ow-editor-undo / -redo / -duplicate / -delete;实体行 ow-editor-entity-<id>;导出/导入 ow-editor-export / ow-editor-import
editor<EditorToggle>testIdPrefix(ow-editor)按钮 ow-editor-toggle
await page.getByTestId('ow-editor-toggle').click()
await expect(page.getByTestId('ow-editor-panel')).toBeVisible()
await page.getByTestId('ow-editor-mode-place').click()

其余系统(toast/alert/对话/任务 UI)由游戏自己渲染,testid 由游戏自己加; 场景内 3D 元素(NPC 提示、指示器)是 canvas 绘制,不产生 DOM,请用 store 断言 (useSceneStore 的邻近状态)。编辑器面板文案可整体覆写 (configureEditorLabels,见 i18n 指南),断言请认 testid,不要认文案

确定性:注入时钟 / 调度器 / 总线 / 存储 / 传输

可复现测试的要点是把所有"环境源"换成注入版:

  • 时钟:createQuestEngine({ clock }) 控制 startedAt; configureToasts({ clock }) 控制 createdAt。自 v1.2 起,框架内其余 取时间的系统同样接受注入时钟(与本指南并行发布的确定性注入工作), 测试里传一个手动递增的计数器即可获得字节级可复现状态。
  • 调度器:configureToasts({ scheduler }) 接管 toast 自动过期 —— 测试里捕获回调、手动触发,不再和真实 setTimeout 赛跑。
  • 事件总线:createQuestEngine / createDialogueEngine 均接受 events: EventBus。每个测试 new 一个总线,天然隔离,不共享全局 gameEvents
  • 存储:所有 persist 配置接受 storage: () => StateStorage;测试用 createMemoryStorage()(@overworld-engine/core),无 localStorage、无跨用例污染。
  • 网络:createLocalTransportHub()(@overworld-engine/net)的消息投递是 同步的,多人逻辑可以在单进程内做确定性单测,无需 flush 或 sleep。
  • 随机数:任何依赖 Math.random() 的构造函数(掉落表、世界生成、 战斗随机数)都应该把 RngSource 当可选依赖接受;测试传 createSeededRng(seed)(@overworld-engine/core),同种子同结果。
import { EventBus, createMemoryStorage, createSeededRng, type OverworldEventMap } from '@overworld-engine/core'

let now = 0
const engine = createQuestEngine({
  quests: QUESTS,
  conditions,
  effects,
  events: new EventBus<OverworldEventMap>(), // 每用例独立总线
  clock: () => ++now,                        // 确定性时间
  persist: { storage: () => createMemoryStorage() },
})
const loot = createLootTable(POOL, { rng: createSeededRng(1234) }) // 确定性随机

@overworld-engine/test-kit:抓 app 层接线 bug

内核单测(读 store)和 E2E(读 DOM/testid)都测不到装配层的 bug —— 一个 构造函数漏传了必需依赖(比如上面的 rng),第一次真正用到时才崩溃或静默 不生效。这类 bug 内核单测测不到(不经过 app 层的实际装配代码),E2E 又太重、 太慢,不适合每次改动都跑。@overworld-engine/test-kit 补的就是这一层, 纯 Vitest,不需要真实浏览器:

import { createSeededRng } from '@overworld-engine/core'
import { createEventRecorder, renderHook } from '@overworld-engine/test-kit'

// 装配层:验证"没接上 rng 就会崩",接上后确定性可复现
const { events, quests, inventory } = createEngines({ rng: createSeededRng(1234) })
const recorder = createEventRecorder(events)
quests.startQuest('gather-crystals')
inventory.add('crystal', 3)
expect(recorder.events.map((e) => e.event)).toContain('quest:completed')

// React 绑定层:验证按键真的接到了对应 action(不渲染任何 UI/场景)
const { unmount } = renderHook(useInteractKey, 'e', { isInputBlocked: () => false })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'e' }))
expect(recorder.events.map((e) => e.event)).toContain('entity:interact')

renderHook 挂载单个 hook 跑它的 useEffect(基于 react-test-renderer, 不碰 DOM/Canvas/WebGL);hook 本身若摸 window/document,测试文件顶部加 // @vitest-environment jsdom。详见 @overworld-engine/test-kit

惯例小结

  1. 逻辑断言 → store;存在性/可交互断言 → testid;文案断言 → 尽量不做 (文案可被 i18n 覆写)。
  2. 每个用例重置全局单例:useToastStore.getState().dismissAll()resetToastConfig()useEditorStore.getState().clear() 等。
  3. E2E 的 debug 句柄只进 dev/test 构建,不进生产包。

本页目录