灯光模拟HarmonyOS应用实战-20-把灯光模拟应用整理成可回归的测试清单

灯光模拟最难回归的不是某个按钮能不能点,而是一次点击会同时改变灯光状态、倒计时、题号、反馈文字、考试结果和本地历史。首页看起来正常,并不代表超时分支、重复点击保护、随机抽题和重启恢复都正常。
这篇不再按 UI 区域介绍功能,而是把 The_kemusan 的真实入口、灯光命令、理论题、历史存储、资源和工程配置整理成可执行矩阵。每一项都标明应该在哪个层级取证,避免把“读过源码”“构建成功”“模拟器走通”和“真机可用”混成一句结论。
写用例前,先定义四种证据

| 证据层级 | 操作方式 | 能说明什么 | 不能替代什么 |
|---|---|---|---|
| 静态审计 | 对照 ETS、JSON/JSON5、资源文件 | 分支、常量、引用关系存在 | 编译与运行 |
| 构建 | 执行工程的 debug/release 构建 | ArkTS、资源、模块配置可被构建系统接受 | 页面交互正确 |
| 模拟器 | 安装并完整操作主链路 | 大部分状态迁移和本地存储行为 | 真实屏幕、性能、系统差异 |
| 真机 | 冷启动、前后台、重启、长时间操作 | 设备级图标、计时、触控、持久化表现 | 应用市场审核结果 |
回归报告每个结论都应带层级。例如“handleTimeout() 有失败分支”是静态事实;“等待 5 秒后页面显示未合格并写入历史”必须来自模拟器或真机操作。若只写“已验证”,后续无法判断证据到底来自哪里。
从八个首页入口建立冒烟清单
QuestionBank.ets 的 MODULES 是入口真值,不需要临时凭记忆列模块:
export const MODULES: ModuleEntry[] = [
{ id: 'subject1', title: '科一灯光题', badge: '理论', /* ... */ },
{ id: 'subject2', title: '科二灯光基础', badge: '基础', /* ... */ },
{ id: 'subject3Exam', title: '科三灯光模拟', badge: '模拟', /* ... */ },
{ id: 'subject3Flow', title: '科三实操灯光', badge: '实操', /* ... */ },
{ id: 'subject4', title: '科四灯光题', badge: '安全', /* ... */ },
{ id: 'questionBank', title: '题库', badge: '题库', /* ... */ },
{ id: 'history', title: '错题本/历史', badge: '复盘', /* ... */ },
{ id: 'settings', title: '设置', badge: '本机', /* ... */ }
];
第一轮冒烟不追求把每个模块测深,而是确认八个入口都能从首页打开、标题正确、返回首页可用、前一个模块的计时/闪烁不会泄漏到下一个模块。
| 编号 | 入口 | 首屏应出现 | 返回后重点 |
|---|---|---|---|
| S-01 | 科一灯光题 | 理论题面与选项 | 重新进入时题组能初始化 |
| S-02 | 科二灯光基础 | 三个分类标签 | 分类切换后题目属于对应类别 |
| S-03 | 科三灯光模拟 | 状态行、指令面板、六个按钮 | 离开时计时停止 |
| S-04 | 科三实操灯光 | 实操状态行、九个动作 | 离开时交替闪烁停止 |
| S-05 | 科四灯光题 | 安全文明类题面 | 记录 subject 为 subject4 |
| S-06 | 题库 | 学科标签和搜索框 | 关键词清空后结果复位 |
| S-07 | 错题本/历史 | 筛选标签或空态 | 过滤条件不破坏原始记录 |
| S-08 | 设置 | 清空本地练习数据 | 清空后首页统计同步归零 |
科三灯光模拟要覆盖固定首尾、随机中段与五秒时限
startSubjectThreeExam() 先关闭灯光并重置计数,再由 selectLightCommands() 组出固定首题、最多六个随机中间题和固定关闭题:
private selectLightCommands(): LightCommand[] {
const candidates: LightCommand[] = [];
for (let index = 1; index < LIGHT_COMMANDS.length; index++) {
candidates.push(LIGHT_COMMANDS[index]);
}
// Fisher-Yates 打乱 candidates
const result: LightCommand[] = [];
result.push(LIGHT_COMMANDS[0]);
const maxCount = Math.min(RANDOM_SCENE_QUESTION_COUNT, candidates.length);
for (let index = 0; index < maxCount; index++) {
result.push(candidates[index]);
}
result.push(CLOSE_COMMAND);
return result;
}
基于这段真实规则,科三矩阵至少包含:
| 编号 | 前置状态 | 操作 | 预期状态/反馈 | 记录期望 |
|---|---|---|---|---|
| L-01 | 未开始 | 点任意灯光按钮 | 进入演示,提示“不计入考试” | 不新增考试记录 |
| L-02 | 灯光任意 | 点开始考试 | 灯光回到关闭;题号从 1 开始;剩余 5 秒 | 暂不写记录 |
| L-03 | 首题“开启前照灯” | 5 秒内点近光 | 计数加一,随后进入下一题 | 完成整场后统一记录 |
| L-04 | 中间题 | 点错误动作 | 立即结束,显示正确动作 | passed=false,保存误选与正确项 |
| L-05 | 远光/双闪等题 | 不操作至 0 秒 | 结束并提示超时 | 保存错误题 id,误选为空 |
| L-06 | 当前题要求近光且灯光已为近光 | 保持不动至 0 秒 | handleTimeout() 按正确处理 | 正确数增加 |
| L-07 | 当前题为远近交替 | 点交替 | 高低光以 160ms 节拍切换 6 次,最终回近光 | 不产生重复记录 |
| L-08 | 通过全部中段 | 最后一题关闭全部 | 显示考试合格 | 分数、总题数、耗时完整 |
| L-09 | 考试进行中 | 连续快速点两个按钮 | actionLocked 阻止第二次判题 | 只生成一个结果 |
| L-10 | 考试进行中 | 点首页 | 定时器和交替闪烁停止 | 不应误写“合格”记录 |
特别容易漏掉的是 L-06:源码允许“题目要求近光且当前本来就是近光”在超时时视作保持正确。这是业务规则,不应把所有超时统一断言为失败。
科三实操使用固定顺序,不能套用随机题预期
实操模式的 selectPracticalCommands() 只是按数组顺序复制 18 条命令,没有打乱:
private selectPracticalCommands(): PracticalCommand[] {
const result: PracticalCommand[] = [];
for (let index = 0; index < PRACTICAL_COMMANDS.length; index++) {
result.push(PRACTICAL_COMMANDS[index]);
}
return result;
}
因此实操应单独建矩阵:
| 编号 | 场景 | 正确动作 | 核对点 |
|---|---|---|---|
| P-01 | 起步 | 左转向 | 第一条固定为 p_start_left |
| P-02 | 变道结束 | 关闭转向 | ACTION_CLOSE_SIGNAL 能清除转向状态 |
| P-03 | 夜间超车提醒 | 远近交替 | 交替动画完成后回近光 |
| P-04 | 驶回原车道 | 右转向 | 状态行右转向激活 |
| P-05 | 雨雪天气 | 近光加示廓 | 近光状态也应被视为激活 |
| P-06 | 雾天 | 雾灯加双闪 | 使用 ACTION_FOG |
| P-07 | 考试结束 | 关闭全部 | 完成全部 18 条后合格 |
| P-08 | 任意一步误点 | 错误动作 | 立即不合格,不跳过继续 |
| P-09 | 任意一步超时 | 不操作 5 秒 | 实操超时一律失败 |
实操文案中出现“保持 3 秒以上”,但当前判题代码只比较按钮 action,并没有计量转向灯保持时长。回归报告应写成“文案存在 3 秒要求,当前实现未见对应计时判定”,不能用文案替代功能实现。
理论题要回归随机上限、首次选择锁定和记录内容
理论练习从符合学科/类别的题目中打乱,最多取 20 道。答题函数用 selectedAnswer 拒绝同题第二次选择:
private answerTheoryQuestion(answer: string): void {
if (this.selectedAnswer.length > 0 || this.activeQuestions.length === 0) {
return;
}
const question = this.getCurrentQuestion();
const correctAnswer = getQuestionAnswer(question);
const correct = answer === correctAnswer;
this.selectedAnswer = answer;
this.answerCorrect = correct;
this.answerMessage = correct
? '回答正确'
: `回答错误,正确选项:${correctAnswer}. ${this.getOptionText(question, correctAnswer)}`;
this.addSimpleRecord(/* subject、mode、正确项、误选项 */);
}
建议执行下面几组:
| 编号 | 操作 | 预期 |
|---|---|---|
| T-01 | 进入科一/科四 | 题组非空且不超过 20 道 |
| T-02 | 科二切换“图标认知/上车检查/夜间场景” | 题目 category 与当前标签一致 |
| T-03 | 选择正确项 | 反馈“回答正确”,新增合格记录 |
| T-04 | 选择错误项 | 显示正确项,记录 wrongQuestionId、正确文本和误选文本 |
| T-05 | 同一题连续点两个选项 | 第二次点击无效,只新增一条记录 |
| T-06 | 点“下一题” | 清空选中与反馈状态,进入后续题 |
| T-07 | 点“重新开始” | 重新抽题并把索引归零 |
随机测试不能断言每次第一题都不同;随机本身允许重复结果。应断言题目来自正确集合、数量上限正确、每项结构完整。若要验证洗牌分布,需要单独的统计方法,而不是用两次肉眼对比得结论。
题库搜索要覆盖空关键词、学科隔离和 40 条上限
refreshQuestionBankResults() 会先 trim().toLowerCase(),空关键词直接清空结果;非空时只遍历当前学科,并在 40 条时停止。匹配范围包含标题、解析、类别和选项文本。
Q-01 输入全空格 → 结果应为空
Q-02 输入题目中的中文片段 → 返回当前学科匹配项
Q-03 切换学科 → 旧学科结果不能残留
Q-04 输入常见高频词 → 结果最多 40 条
Q-05 输入选项中的文字 → 对应题目也可命中
Q-06 清空输入 → LazyDataSource 重新加载空数组
页面使用 QuestionBankLazyDataSource 并在 setQuestions() 后通知 onDataReloaded()。若数据数组已经变化但列表没刷新,排查重点是 data source 监听和 LazyForEach,而不是继续修改搜索匹配规则。

历史与 Preferences 必须通过“重启后仍存在”来证明
PracticeStore 的存储名是 kemusan_exam_store,键是 exam_history,最多保留 100 条。新增时先读取并排序,再把新记录放到最前面并 flush():
async addRecord(record: PracticeRecord): Promise<PracticeRecord[]> {
const records = await this.listRecords();
records.unshift(record);
const nextRecords = records.slice(0, MAX_HISTORY_COUNT);
await this.putString(HISTORY_KEY, JSON.stringify(nextRecords));
return nextRecords;
}
async clearRecords(): Promise<void> {
await this.putString(HISTORY_KEY, '[]');
}
持久化矩阵应覆盖:
| 编号 | 操作 | 预期 |
|---|---|---|
| D-01 | 完成一道理论题 | 历史第一条时间最新、subject/mode 正确 |
| D-02 | 科三错误结束 | 记录正确动作、误选动作、耗时和题目 id |
| D-03 | 完成多条后切换筛选 | “全部/科一/科二/科三/科四/错题”数量对应原始记录 |
| D-04 | 杀掉应用再启动 | 记录仍可从 Preferences 读取 |
| D-05 | 清空历史 | 列表变空,首页练习/合格/错题统计归零 |
| D-06 | 清空后重启 | 空状态保持,不被旧内存数据恢复 |
| D-07 | 构造超过 100 条 | 只保留最新 100 条 |
| D-08 | 读取旧版缺省字段 | normalizeRecord() 提供 subject/mode 等回退 |
还有一个风险要主动观察:PracticeStore.init/getString/putString 捕获异常后回退或直接返回,UI 可能继续工作但数据没有真正落盘。因此 D-04 是不可省略的;只看到当前会话列表新增,不能证明 Preferences 写入成功。
生命周期与重置项专门防止“离开页面还在跑”
goHome()、openModule() 和 aboutToDisappear() 都会停止倒计时和交替闪烁的两个 interval,而 resetSubjectThreeExam() 负责把相关状态字段恢复初值。这里不能扩展为“所有异步任务都已停止”:答对后进入下一题的两处 setTimeout 尚未保存句柄。建议把这些路径作为独立回归组:
aboutToDisappear(): void {
this.stopTimer();
this.stopAltFlash();
}
private goHome(): void {
this.stopTimer();
this.stopAltFlash();
this.currentPage = PAGE_HOME;
this.pageTitle = '驾考灯光综合助手';
}
| 编号 | 操作 | 应重点观察 |
|---|---|---|
| R-01 | 考试计时中返回首页 | 秒数不再继续变化,也不触发超时记录 |
| R-02 | 交替闪烁中返回首页 | 高低光切换停止 |
| R-03 | 科三返回首页后进入题库 | 前一模块的 timer/flash 已停止 |
| R-04 | 重置灯光 | lightState=closed,未考试时提示“灯光已复位” |
| R-05 | 重置实操 | 题号、总数、反馈、命令数组全部回初始值 |
| R-06 | 应用切到后台再回来 | 记录当前源码行为,并确认没有幽灵倒计时 |
| R-07 | 答对后在 550ms/1000ms 延迟窗口内返回首页或重新开始 | 目标行为是旧回调不得推进新会话;当前实现未取消该任务,需作为风险项验证 |
源码只有页面组件的 aboutToDisappear(),没有专门的前后台恢复策略。R-06 应以设备实测为准,并根据结果决定是否需要在 Ability 生命周期中补暂停/恢复,不要先假设框架会替业务计时器做选择。
资源与工程配置使用静态矩阵,不和业务用例混写
| 编号 | 核对文件 | 静态期望 | 还需什么证据 |
|---|---|---|---|
| C-01 | base/dark color.json | 同名键完整;当前 15 项同值 | 设备切换模式后的实际外观 |
| C-02 | main_pages.json | 只登记 pages/Index | 构建与冷启动 |
| C-03 | EntryAbility.ets | loadContent('pages/Index') 与 profile 一致 | 模拟器启动日志 |
| C-04 | AppScope layered_image.json | 前景和背景文件存在 | 桌面图标实机截图 |
| C-05 | entry layered_image.json | 启动窗图标资源存在 | 冷启动录屏 |
| C-06 | module.json5 | pages、Ability、backup metadata 引用有落点 | 模块构建 |
| C-07 | backup_config.json | allowToBackupRestore=true | 不能证明历史数据已经恢复 |
| C-08 | 根 build-profile | SDK、产品、模块关系存在 | debug/release 构建结果 |
签名路径、证书、profile 和任何口令字段只在受控环境核对,不进入截图或公开报告。静态检查通过以后,工程仍应按实际交付要求运行 debug 构建;准备发布时再补 release 构建和签名包验证。
当前自动化测试只是脚手架,业务矩阵仍需落地
工程的 entry/src/test 与 entry/src/ohosTest 文件存在,但内容仍是模板式 assertContain 示例,没有覆盖 QuestionBank、灯光状态机或 PracticeStore。因此“有 test 目录”不能写成“业务回归已有自动化”。
最适合先自动化的是纯数据规则:
优先级 A:selectLightCommands 的固定首尾、数量和候选范围
优先级 A:getStats 与历史筛选
优先级 A:normalizeRecord 的旧字段回退
优先级 B:题库学科/类别筛选与 20 条上限
优先级 B:搜索匹配与 40 条上限
保留手工:倒计时、交替闪烁、触控、冷启动图标、前后台、重启持久化
目前这些逻辑大多是 Index 私有方法,直接写单元测试会受页面组件限制。后续若要补自动化,先把“抽题、过滤、统计、记录规范化”提取为无 UI 副作用的函数或服务,再写 Hypium 用例;不要为了测试去暴露所有页面私有状态。
按 P0、P1、P2 安排每次发版回归
| 优先级 | 每次必须执行 | 适用时机 |
|---|---|---|
| P0 | 八入口冒烟、科三正确/错误/超时、理论首次选择、历史写入、返回停止计时 | 每次业务或 UI 改动 |
| P1 | 实操 18 步、题库搜索、筛选、清空、应用重启恢复、深浅模式 | 准备测试包 |
| P2 | 100 条上限、旧记录兼容、长时间闪烁、前后台、字体放大、不同真机 | 发布候选包 |
执行顺序建议固定为:先静态检查改动文件,再构建,然后在干净数据状态跑 P0,接着构造有数据状态跑 P1/P2。失败时不要立即清空环境,应先保存题目 id、动作、当前状态、时间和历史记录截图,保证问题能够复现。
问题单可以使用下面这份最小字段:
用例编号:L-04
环境:系统版本 / 设备或模拟器 / 构建模式
前置数据:历史条数、当前灯光、是否首次安装
操作:从首页进入科三模拟 → 开始 → 在指定题点错误动作
预期:立即不合格,显示正确动作,新增一条失败记录
实际:
日志/截图/录屏:
源码定位:handleLightAction → finishLightExam → addSimpleRecord
证据层级:模拟器 / 真机
结语:回归矩阵要跟状态机一起维护
这套清单不是把功能名称排成表格,而是把每条用户路径连接到真实常量、状态字段、处理函数和持久化结果。灯光模拟看似只有几个按钮,实际至少包含随机题组、固定序列、五秒倒计时、交替闪烁、重复点击锁、理论题选择、搜索与 100 条本地历史。
后续新增一个动作时,至少同步更新动作常量、状态映射、判题分支和对应的 L/P 用例;新增一个存储字段时,则同步补写入、旧数据回退、重启恢复和清空用例。让源码变化能追到矩阵,让矩阵结果带着明确证据层级,才算真正可回归。
更多推荐

所有评论(0)