项目:html5-games | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
《06-部署运行》第 4 节已经给出测试门禁的骨架(五关 CI 与 5 个测试文件的一览表),本篇把测试体系展开到「每个用例守护什么、破坏时谁会红、为什么这样设计」的粒度,并坦诚地给出缺口清单与补测优先级。先立总纲:这个项目的测试哲学是**「守护跨文件契约与纯函数,不模拟玩法」**——79 款游戏的「好玩与否、玩起来对不对」无法用单测表达,团队把自动化投在「配置与文件漂移」「确定性算法」「存储语义」三类最有价值的目标上,游戏层质量则由 lint、构建与人工验收共同兜底。理解这一点,才能理解为什么全项目只有 5 个测试文件却敢声称质量有保障,也才能判断未来该往哪里补测试。
| 维度 | 现状 |
|---|---|
| 测试框架 | Vitest ^2.1.9(vitest.config.ts:environment 'node'、include src/**/__tests__/**/*.test.ts、@ 别名同构建) |
| 测试文件 | 5 个(config 1 / composables 2 / utils 2),合计 30 个用例 |
| 运行方式 | pnpm test(一次性 run)、pnpm test:watch(watch 模式) |
| DOM 覆盖 | 无(node 环境不触碰 DOM;存储测试注入内存桩) |
| 覆盖率统计 | 未配置(vitest 的 --coverage 未启用) |
| E2E / 视觉回归 / a11y 自动化 | 无 |
| 手工验收 | 有——06 篇 SOP 第 4 步清单 + 第 7 节升级抽查 |
node 环境而非 jsdom 是显式决策(《06-部署运行》4.2 已论证:现有测试全部不触碰 DOM,node 启动最快、依赖最少),这决定了「测什么」的边界:能测的是纯 TS 模块与「可用对象桩模拟的浏览器 API」(如 localStorage),不能测的是组件挂载、事件与渲染。5 个测试文件的分布恰好沿着「数据层 → 算法层 → 工具层」排布,与架构分层(见《01-系统架构》第 1 节)同构——测试结构就是架构结构的镜像。
这是全项目最重要的测试文件,文件头注释自我定位为「防漂移哨兵」:总款数 79 与 README、index.html meta、package.json description 三处文案联动,调整游戏列表后必须同步,测试在 CI 阶段拦截任何一侧的漂移。八个用例按守护目标拆解如下。
| 用例 | 断言内容 | 拦截的典型事故 |
|---|---|---|
| 总数为 79 | expect(localGames).toHaveLength(79) | 加游戏忘了改文案/文案改了忘了加游戏(双向) |
| id 无重复 | Set 大小 === 数组长度 | 复制条目改标题忘改 id——会导致 localGamesMap 静默覆盖、路由指向同一款 |
| sortOrder 无重复 | 同上 | 排序键撞车会让「默认排序」出现不稳定顺序 |
| 必填字段非空、category 合法 | 每条的 id 格式(^[a-z0-9-]+$)、title/description/tags 非空、category 在四分类白名单 | 漏写 tags 导致搜索盲区;category 手滑写成非法值导致分类计数与筛选丢失该游戏 |
| 分类非空且分类色合法 | 每个分类至少一款、color 匹配 ^#[0-9a-fA-F]{6}$ | 删光某分类的游戏后侧栏出现空分类;颜色写成三位 hex 让 rgba 拼接出错 |
| id 与文件一一对应(双向) | 用第二个 import.meta.glob('/src/games/*.vue') 取磁盘文件 id 集合:配置每个 id 都有文件(缺文件红)+ 文件集合大小等于配置集合大小(孤儿文件红) | 本测试的核心:新建了 .vue 忘登记、登记了忘建文件、文件名大小写与 id 不一致(如 Snake.vue vs snake)——三者都会让「文件落盘即注册」机制在运行时表现为「游戏模块缺失」或「不可达的 chunk」,在 CI 阶段被拦下 |
| 核心精选 ≥4 | group === '核心精选' 计数下限 | 把精选组游戏移出分组后首页横带缺排(HallHero 取该组前 4) |
| 分类 Map 全覆盖 | localGameCategoryMap.size === localGameCategories.length | 改分类定义时 Map 构建被误删 |
值得点出的实现细节:双向对应断言没有 import registry.ts 而是在测试文件里重新执行了一次 glob(第 43 行)——两个 glob(registry 的 @/games/*.vue 与测试的 /src/games/*.vue)在 Vite 下解析出同一批 key,但如果未来有人改动 registry 的 glob 模式(比如收窄子目录),测试不会报警,这是一个已知的测试-实现轻微解耦(补测建议见第 4 节 P2)。
| 用例 | 断言内容 | 守护价值 |
|---|---|---|
| 同一 seedKey 计划完全确定 | createBgmPlan('五子棋') toEqual 自身 | 「每款游戏一首专属 BGM」的根基——若有人把 PRNG 换成 Math.random,曲子将每次进入游戏都变 |
| hashKey 稳定且为 32 位无符号 | 同输入同输出、值域 (0, 0xffffffff] | FNV-1a 实现回归(如误用 >>> 与 >>、漏 Math.imul) |
| mulberry32 确定性且落在 0..1 | 同种子两个实例 100 次输出逐位相同、值域 [0,1) | PRNG 实现回归 |
| 计划参数落在设计区间 | tempo 82-118、rootMidi 45-52、调式三选一、progression 4 元、arpWave 二选一 | 曲谱「好听域」的边界——参数越界意味着音质失控(如 tempo 200 的噪音) |
| 三层编制结构 | 32 步 ×3 轨、低音 ≥8 步、铺底恰好 2 次、全部 MIDI 在 21-96 可听区间 | 编曲骨架被改坏(如铺底从「每两小节第 1 步」变成每步,会变成持续轰鸣) |
| 80 款标题至少分化 4 种速度、6 首曲子 | 8 个代表标题的 plan 集合大小 | 种子空间坍缩(如 hash 退化导致多数游戏同曲)——用例名仍写「80 款」而入参只有 8 个标题,措辞是历史遗留,见《13-技术债务与演进建议》E-07 |
这组测试是「纯函数设计红利」的教科书案例:createBgmPlan 刻意与 WebAudio 运行时解耦(《07-模块详解》第 4 节),才能在 node 环境下对曲谱做结构断言。修改 useGameBgm 的纯函数区(16-123 行)必须先跑这个文件。
| 用例 | 断言内容 | 拦截事故 |
|---|---|---|
| 读缺失键返回 0 | readHighScore('h5g:missing-best') === 0 | 读取端把 null 透传为 NaN 污染 HUD 与比较逻辑 |
| 写入后可读回 | 42 进 42 出 | 序列化格式漂移(如写成带引号 JSON) |
| 非数值内容按 0 处理 | 存 'abc' 读 0 | 玩家手工改坏数据后游戏崩溃(自愈语义) |
| storage 抛异常读取降级 0 / 写入静默 | 注入 throw 的桩,not.toThrow() | 隐私模式/Safari 配额满时中断游戏——try/catch 是「fire-and-forget 存档」语义的落地 |
实现上有一处值得学习的手法:node 环境没有 localStorage,测试在 beforeEach 里向 globalThis 注入一个基于 Map 的内存桩(8-16 行),并单独注入「抛异常版」桩覆盖降级路径——用最小桩而非引入 jsdom,保持了环境的轻量。
usePagination 的 7 个用例完整覆盖其公开契约:total=0 时 totalPages 兜底为 1(防除零)、80/24=4 页、setPage 的三向 clamp(0→1、999→末页、2.7→2 向下取整)、total 变化默认重置(resetOnTotalChange=true 语义)、显式关闭重置时仅 clamp(GamesPage 实际使用的配置)、setPageSize 回第 1 页、initialPage 生效。它与 GamesPage.vue 的 resetOnTotalChange: false 用法一一对应——测试文件就是这份「为什么页面要关掉默认重置」的活文档。keyboard.test.ts 用 it.each 扫 INPUT/TEXTAREA/SELECT 三类标签 + contentEditable + 普通元素 + target 为空四个分支,守护 isTypingTarget 的判定边界——这个小函数是全局快捷键不与搜索框打架的唯一防线(useListShortcuts.ts 第 31 行消费)。
CI(.github/workflows/ 的 verify job,push main/master 与全部 PR 触发)按序执行五关,任一失败即红:pnpm install --frozen-lockfile → pnpm typecheck(vue-tsc --noEmit)→ pnpm lint(eslint .)→ pnpm test(vitest run)→ pnpm build(vite build)。本地等价命令是「提交前五连」:pnpm typecheck && pnpm lint && pnpm test && pnpm build。五关各有分工,逐关说明其捕捉半径与盲区。
typecheck 关:tsconfig.json strict 全开(strict、noUnusedLocals、noUnusedParameters、noFallthroughCasesInSwitch、forceConsistentCasingInFileNames),include 覆盖 src/**/*.ts 与 src/**/*.vue。捕捉半径是框架层全部类型错误;盲区是游戏层——74 款 JS SFC 没有 lang="ts",vue-tsc 对它们只按空壳 DefineComponent 声明处理(env.d.ts 的 *.vue 模块声明),不检查内部,这是《13-技术债务与演进建议》E-05 的主体。allowImportingTsExtensions、moduleResolution: bundler、types: ["vite/client"] 三项与 Vite 的模块语义对齐,引用 vite.config.ts 时走 tsconfig.node.json 的 composite 引用。
lint 关:eslint.config.js 平面配置,规则分四层——js.configs.recommended 基础 + tseslint.configs.recommended + pluginVue.configs['flat/essential'] + eslint-config-prettier 收尾(关闭与 Prettier 冲突的格式规则)。三个针对项目实际的定制:游戏 SFC 的 vue/multi-word-component-names 与 vue/no-reserved-component-names 关闭(单文件游戏名如 snake 不合多词规范,强制改名毫无收益);@typescript-eslint/no-explicit-any 定为 error(全仓库唯一的 error 级 TS 规则,保住框架层类型底线);未用变量以 ^_ 前缀豁免(游戏算法里常见的占位参数)。ignores 覆盖 dist、node_modules、*.d.ts、public、.archive。lint 的捕捉半径是语法层错误、未用变量、Vue essential 档的模板反模式(如 v-for 缺 key)——它是 74 款 JS 游戏唯一的自动化关卡。盲区:eslint-config-prettier 只是「让位」不是「执行」,格式一致性靠 pnpm format 手动触发,CI 并不校验格式(补测建议 P1)。
test 关与 build 关:test 关即第 2 节的 30 个用例;build 关的价值常被低估——vite build 成功意味着全部 79 个游戏 SFC 编译通过、import.meta.glob 解析成功、worker 打包正常,它实际上是对全部源码(含 JS 游戏层)的一次完整语法与模块解析检查,是 JS SFC 唯一被「全量编译」的机会。五关之后产物落盘,配合 pnpm preview 的人工抽查构成完整发布流程(见《06-部署运行》第 2、7 节)。
以下按「投入产出比」排序给出优先级,P0 最先。所有建议都延续第 0 节的哲学:测契约与不变量,不为玩法写脆弱断言。
| 优先级 | 缺口 | 现状证据 | 建议动作 |
|---|---|---|---|
| P0 | 音效名契约无测试 | render() 的 GameSfx 16 项 switch 与游戏调用点之间无校验,gobang.vue 250/253/257 行的 'lose'/'draw' 越界调用已实际发生(无声 bug,见《13-技术债务与演进建议》E-06) | 新增扫描式测试:正则提取全部 src/games/*.vue 中 play('...') 字面量,断言是 GameSfx 子集——一个用例永久拦住此类回归 |
| P0 | 存档键命名契约无测试 | 8 处历史短键 + memory-match 动态键(《04-数据模型》2.3),新游戏模仿短键无拦截 | 扫描式测试:提取 BEST_KEY = 'h5g:...' 常量,断言键值等于 h5g:<文件stem>-best 或命中显式维护的「历史豁免清单」——豁免清单让债务可见且不再增长 |
| P1 | useListFilter 无测试 | 列表三件套中 usePagination/keyboard 有测试,最复杂的 useListFilter(防抖双状态 + URL 回写 + resolveInitial)没有 | node 环境可测大半:reset/clearAll 语义、resolveInitial 对 URLSearchParams 的解析(注入 location 桩);防抖与 replaceState 需 fake timers,中等工作量 |
| P1 | Prettier 格式不进 CI | lint 用 eslint-config-prettier 让位但不校验,pnpm format 手动 | CI 或 pre-commit 增加 prettier --check "src/**/*.{ts,vue,js,css,json}",一行命令消除格式噪音 |
| P1 | 依赖审计缺失 | 无 pnpm audit/Dependabot/Renovate 痕迹 | CI 增加审计步骤或在 GitHub 开启 Dependabot;安全依据见《08-安全与权限设计》第 6 节 |
| P2 | registry 与测试的 glob 双份 | games.test.ts 第 43 行自行 glob,不消费 registry.ts | 改为 import gameModules 做同一断言,让「registry 的 glob 模式被改坏」也进入测试半径 |
| P2 | 游戏层无冒烟挂载测试 | 74 款 JS SFC 的运行时错误只能靠 onErrorCaptured 在线上兜底 | 若要补,引入 jsdom + 逐款 defineAsyncComponent 挂载冒烟(断言不抛异常、根节点渲染出 GameShell),预计显著增加测试时长与依赖,属显式决策(《06-部署运行》4.2 的边界提醒同样适用) |
| P3 | E2E / 视觉回归 / a11y 无 | 无 Playwright 等 | 对纯静态小站收益有限,建议仅在出现「大厅回归类」事故后再评估;a11y 至少保持 06 篇手工清单中的键盘导航用例 |
补测时的两个技术提醒:其一,现有 node 环境若因 P2 引入组件测试需切 jsdom,vitest.config.ts 的 environment 字段改动会影响全部现有文件(highScore 的 localStorage 桩在 jsdom 下依然可用,无需重写);其二,扫描式测试(P0 两条)读取源码文本即可,无需解析 AST,import.meta.glob 加 query: '?raw' 或直接 fs 读文件都行——保持它们的简单性,才有长期维护价值。
把自动化之外的手段也算上,项目的质量保障实际是五道防线:构建期(glob 一致性由 CI 测试守护、SFC 编译由 build 关守护)→ 静态检查(typecheck 管框架层、lint 管全仓库)→ 单测(契约与纯函数 30 例)→ 运行时隔离(onErrorCaptured + ErrorFallback + router.onError 三级兜底,见《01-系统架构》2.3,让漏网的缺陷表现为「单游戏错误卡」而非全站白屏)→ 人工验收(06 篇 SOP 手工清单 + 升级五连)。这套组合的得意之处在于:它接受「游戏玩法无法自动化验证」的现实,用错误隔离把人工验证的最坏后果限制住——79 款游戏任何一款写坏,爆炸半径都是单款。接手者的日常动作因此可以简化为:改代码 → 提交前五连 → 按 06 篇清单抽查所改游戏及其分类的代表款。若某次改动触碰了《05-核心流程》第 6 节定义的「危险修改区」(registry、GameShell 插槽语义、全局 composable 单例),则升级为全范式回归(Canvas/DOM/Pixi 每类至少一款实玩)。