一、前言
在AI人机交互产品中,感知层是连接大模型和真实用户的第一道关口。大模型本身擅长文本、对话推理,但无法直接感知物理世界里用户是否在场、是否正在操作设备。这类用户在场感知能力,通常由前端视觉模块承担:通过摄像头采集画面,识别人脸、画面运动,以此判断用户状态,驱动交互状态切换。
传统视觉模块测试有一个显著痛点:必须依赖实体摄像头硬件,测试环境搭建繁琐,自动化回归困难。每次修改人脸检测、静默判断逻辑,都需要人工坐在摄像头前,模拟"离开画面、静止不动、重新返回"等场景,手动观察页面表现。这种方式不仅耗费人力,还容易漏测边界场景,版本迭代时回归成本极高,很难适配大模型产品高频迭代的工程节奏。
今天的探讨的核心就是为解决该痛点而实现的无硬件仿真单元测试方案。目标对象是一套运行在浏览器端的用户静默监听状态机,它不需要接入真实摄像头,直接注入JS模拟帧画面,调用页面原生业务函数,使用Headless无头Chrome浏览器自动渲染页面、执行断言,一次性验证全部静默触发、退出的业务规则。这套方案属于AI产品前端感知层自动化工程实践,不替代人脸推理模型本身,而是保障感知状态逻辑的正确性,为上层大模型交互提供稳定可靠的用户状态输入。

二、项目介绍
1. 项目定位
本项目属于浏览器前端业务逻辑单元测试,面向AI人机交互场景下的用户在场状态模块。 整个业务模块运行在一个前端页面,页面内置人脸检测、画面运动检测、静默状态机,当满足超时条件时,页面会弹出黑幕提示,进入静默模式;当用户重新出现、画面产生运动,则退出静默,恢复正常交互。这个状态结果会向上传递给对话大模型,大模型根据用户是否在场,调整对话策略,比如用户离开后暂停对话,用户返回再继续交互。
项目核心诉求:不使用真实摄像头硬件,自动化验证静默状态机的全部边界规则。 常规测试手段存在局限:
- 硬件依赖:测试机器必须配备可用摄像头,CI/自动化服务器大多没有摄像头;
- 人为误差:人工模拟场景,时间控制、动作模拟无法精准复现,61秒这种精确超时场景很难稳定复现;
- 回归成本高:每次修改状态判断代码,所有场景需要重复手动测试;
- 难以捕获内部变量:人工只能观察页面UI(黑幕是否弹出),很难校验内部状态变量quietBy、moScore等。
这套测试脚本的定位,就是绕过硬件采集链路,直接仿真图像帧数据,调用页面原有业务函数,在无头浏览器中自动执行全部用例,收集断言结果,输出测试报告。它测试的不是人脸识别模型的识别准确率,而是基于人脸识别结果、画面运动结果的上层业务状态流转逻辑。
前置概念区分:人脸模型负责输出"是否检测到人脸",状态机负责根据人脸、画面运动、时间,决定是否进入静默。本项目测试对象是后者。
2. 待验证业务规则
原始需求定义的静默监听规则,全部转化为自动化断言用例:

-
- 无人脸持续61秒 → 触发noface类型静默,页面展示黑幕,提示未检测到用户;
-
- 画面静止不动持续61秒(人脸仍在画面中)→ 触发still类型静默,提示画面长时间无动静;
-
- 进入still静默后,即使人脸持续存在,画面不产生新运动,状态机不会自动退出静默,避免界面反复闪烁;
-
- 画面检测到有效运动、用户人脸重新出现、会话结束、关闭摄像头,任意一种事件触发,退出静默状态,隐藏黑幕;
-
- 运动判定规则:单帧画面变化判定为噪声,连续两帧画面存在差异,才判定用户产生有效动作;
-
- 状态机动态调整检测轮询间隔:静默状态降低采样频率(1200ms),正常交互时高频采样(300ms),兼顾性能与响应速度。
3. 整体执行流程
整个程序分为Python主控脚本 + 注入到HTML页面的JS测试脚本两部分,完整执行链路:

-
- Python读取原始业务页面kid.html;
-
- 页面替换静态资源地址,在</body>标签前注入测试JS代码和结果输出容器,生成临时测试页面_q_unit.html;
-
- 调用本地Chrome的无头模式--headless=new,加载临时页面;
-
- Chrome加载页面后,等待800ms,JS测试脚本启动,重写Canvas图像读取接口,伪造帧像素数据,模拟人脸检测结果;
-
- 依次执行8组测试场景,调用原有motionTick、quietTick等业务函数,执行断言,将成功/失败标记写入页面#qOut元素;
-
- Chrome执行完毕,通过--dump-dom参数导出页面完整DOM;
-
- Python正则匹配DOM中的QRESULT和QEND标记,提取所有断言结果,统计失败用例,打印测试报告;
-
- 同时保存完整DOM快照_q_unit_dom.html,方便调试查看页面内部状态。
三、核心技术原理
1. Headless Chrome 无头浏览器原理
Headless Chrome是无图形界面运行的Chrome内核,保留完整DOM渲染、JS引擎、API执行能力,只是不渲染可视化窗口。它可以加载HTML页面,执行页面JavaScript代码,访问页面全局变量、DOM元素,还可以导出页面DOM结构。 在自动化测试领域,常见方案有Puppeteer、Playwright,而本项目直接调用Chrome原生可执行文件,使用命令行参数,不引入额外重型依赖库,降低环境依赖,简化部署。
关键用到的参数:
- --headless=new:新版无头模式,相比旧版headless,对现代JS、DOM支持更好;
- --user-data-dir:独立Chrome用户目录,隔离测试环境,避免浏览器缓存、插件干扰测试;
- --virtual-time-budget=4000:虚拟时间预算,控制页面JS执行最大时长,防止测试脚本无限阻塞;
- --dump-dom:页面加载执行完成后,输出完整HTML DOM字符串,是获取JS运行结果的核心。
这种方案的优势:测试运行环境和真实浏览器环境完全一致,和用户实际打开网页的JS运行环境没有差异,不存在测试环境与线上环境不一致的问题,这是单纯Node.js单元测试无法做到的------业务代码依赖浏览器video、Canvas、DOM元素,Node环境没有这些原生API。
2. 图像采集接口Mock仿真原理
原本业务逻辑中,画面运动检测流程是:摄像头video输出画面 → Canvas绘图 → getImageData读取像素数组 → 对比前后两帧像素差异,计算画面变动分数moScore。 测试时没有摄像头,所以直接猴子补丁(Monkey Patch),重写Canvas上下文getImageData方法,伪造返回像素数组。
**Monkey Patch:**运行时替换对象原有方法,在不修改业务源码的前提下,替换外部依赖(摄像头、图像读取)的行为,是前端仿真测试经典技术。
javascript
moCtx.getImageData = function(){
return {data: new Uint8ClampedArray(40 * 30 * 4).fill(px)};
};
这里px变量控制像素值,修改px就模拟画面像素变化:
- px不变:前后两帧像素完全一致,画面静止;
- 修改px:像素数组变化,模拟画面移动。
同时重写drawImage为空函数,跳过真实图像绘制,减少不必要运算。还通过Object.defineProperty伪造video的videoWidth属性,满足原有业务代码读取视频尺寸的逻辑,避免属性不存在抛出异常。
3. 状态机与时间仿真原理
业务核心是事件驱动状态机,状态变量:是否检测人脸F.present、最后看见人脸时间lastSeenAt、最后画面运动时间lastMotionAt,以及当前静默类型quietBy。 状态机的驱动函数quietTick(now, hasMotion),接收当前时间戳、是否存在运动两个参数,内部对比当前时间和各个最后事件的时间戳,计算时间差,判断是否达到61000ms阈值,切换静默状态,操作DOM黑幕元素camSilent。

**状态机核心逻辑说明:**该状态机实现了基于时间和事件的静默检测与恢复,避免界面反复闪烁。
- 状态变量:
- F.present:是否检测到人脸
- lastSeenAt:最后看见人脸的时间戳
- lastMotionAt:最后画面运动的时间戳
- quietBy:当前静默类型(noface / still)
- 驱动函数:quietTick(now, hasMotion)
- 传入当前时间戳 now 和是否存在运动 hasMotion
- 计算时间差,判断是否达到 61000ms 阈值
- 切换静默状态,并操作 DOM 黑幕元素 camSilent
- 进入静默:
- noface:无人脸且 now - lastSeenAt >= 61000ms
- still:有人脸但无运动且 now - lastMotionAt >= 61000ms
- 退出静默:
- 人脸重现(F.present == true)
- 有效运动(hasMotion == true)
- 会话结束或关闭摄像头
在测试脚本中,不需要真实等待61秒。JS内部使用Date.now()构造时间戳,手动修改lastSeenAt、lastMotionAt,人为制造时间差,直接调用quietTick。
关键点:不是休眠等待真实61秒,直接修改时间变量,瞬间跨越时间窗口,大幅缩短测试耗时,原本需要几分钟的场景,4秒内全部跑完。
运动检测逻辑同样内置状态变量moScore(画面变动分数)、moHot(连续变动帧计数)。业务规则规定:连续两帧像素变化,才算有效动作。

- 第一帧:只建立基准帧,moScore=0;
- 第二帧像素不变:分数保持0;
- 第三帧像素变化:moHot=1(单帧,判定噪声);
- 第四帧继续变化:moHot>=2,判定有效运动。 测试脚本逐帧调用motionTick(),验证这套计数逻辑:单帧变化不触发有效运动,连续两帧变化才判定有效。
4. 跨页面全局函数调用机制
原始kid.html页面,业务函数motionTick、quietTick,变量F、CFG、camSilent、quietBy全部挂载在页面全局window作用域。 注入的测试脚本和业务代码在同一个页面全局上下文,因此可以直接读取、修改全局变量,调用业务原生函数。
这是这套方案的核心设计优势:复用业务真实代码,不是复刻一份简化版逻辑用于测试,断言执行的函数就是线上实际运行的代码,避免"测试代码和业务代码两套逻辑"带来的测试无效问题。
5. 结果采集与断言机制
JS侧实现简单断言工具函数T(描述, 布尔结果, 附加信息):
- 断言通过:输出 ✓;断言失败输出 ✗; 所有断言日志存入数组out,测试结束后统一写入页面<pre id="qOut">容器,使用固定标记QRESULT和QEND包裹测试文本。

Python拿到Chrome导出的完整DOM,使用正则表达式re.search(r"QRESULT(.*?)\nQEND", dom, re.S),提取标记中间的文本,逐行筛选失败用例,统计失败数量,输出报告。 同时保存完整DOM快照,当断言失败时,可以打开_q_unit_dom.html,直接查看页面全部变量、DOM状态,方便定位BUG。

核心步骤说明:
| 核心步骤 | 包含原步骤 |
|---|---|
| JS断言执行 | T()断言函数 → 输出✓/✗ → 日志存入out → 写入#qOut → QRESULT/QEND包裹 |
| Chrome导出DOM | 启动Chrome --dump-dom → 捕获stdout完整DOM |
| Python解析统计 | 正则提取QRESULT/QEND → 逐行筛选失败用例 → 统计数量 |
| 结果分支 | 有失败→保存快照+失败报告;无失败→通过报告 |
四、代码模块拆解
1. Python主控模块
1.1 路径与常量定义
python
D = r"d:\AIWorld\case\test2"
KID = os.path.join(D, "260911.(271)kid.html")
PAGE = os.path.join(D, "_q_unit.html")
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
PROFILE = os.path.join(D, "_q_chrome_unit")
定义业务页面、生成测试页面、Chrome程序路径、独立用户目录。使用原始字符串r""处理Windows反斜杠路径,规避转义问题。
1.2 build函数:页面拼接与脚本注入
build函数读取原始HTML文件,替换资源地址,将JS测试脚本追加到</body>之前,生成_q_unit.html。
javascript
def build():
base = io.open(KID, encoding="utf-8").read()
assert base.count("</body>") == 1
base = base.replace("[https://cdn.tailwindcss.com](https://cdn.tailwindcss.com)", "_tw.js")
base = base.replace("</body>", TEST + "\n</body>", 1)
io.open(PAGE, "w", encoding="utf-8").write(base)
- assert base.count("</body>") == 1:前置校验,保证页面只有一处body结束标签,防止多次替换造成代码重复注入;
- 替换CDN地址为本地静态资源,避免无头浏览器执行测试时请求外网CDN,网络波动导致测试不稳定;
- 一次性替换</body>,注入测试JS和输出pre容器。
1.3 main函数:调用Chrome、提取结果

- main函数先执行build,然后调用subprocess启动Chrome进程。
- subprocess运行Chrome命令,捕获标准输出stdout,也就是--dump-dom导出的DOM字符串。
- 使用正则捕获测试结果,打印每一条断言,统计失败用例数量,返回退出码:0全部通过,1存在失败用例。
- 退出码可以接入CI流水线,CI系统识别返回码,判定测试是否失败。
2. JS测试脚本模块
2.1 断言与日志工具
javascript
const out = [];
const T = (n, c, d) => out.push((c ? "\u2713 " : "\u2717 ") + n + (d ? " [" + d + "]" : ""));
function dump(){ document.getElementById("qOut").textContent = "QRESULT\n" + out.join("\n") + "\nQEND"; }
- T是断言封装函数,接收用例名称、布尔断言条件、可选调试信息。
- 所有日志缓存数组,最后一次性写入DOM,减少多次DOM操作带来性能波动。
- 同时全局监听window error,捕获页面JS异常,纳入测试结果。
2.2 前置环境重置
javascript
clearInterval(faceTimer); faceTimer = null;
faceOn = true; faceMode = "heuristic"; sid = "unit"; ended = false; paused = false;
Object.defineProperty(video, "videoWidth", {get(){ return 640; }, configurable: true});
resetAlive();
在执行测试用例前,清空原有后台定时器,重置全局状态变量,伪造video视频宽度,调用resetAlive()重置运动检测内部状态,保证每一组用例都从干净初始状态开始,避免上一个用例残留状态污染下一组场景。
2.3 画面运动仿真用例
修改px像素变量,多次调用motionTick(),验证帧差异计数逻辑:
-
- 第一帧仅建立基准,moScore=0;
-
- 第二帧像素不变,无运动;
-
- 第三帧像素变化,hot=1,单帧变化判定噪声;
-
- 第四帧继续变化,hot>=2,判定有效用户动作。
2.4 静默状态8组业务场景用例
本测试验证静默状态机,依次覆盖全部业务分支:无人脸或画面静止超时触发noface/still静默并校验提示,静默下不自动退出;运动、人脸重现、会话结束或关闭摄像头均可退出,同时校验正常300ms与静默1200ms检测间隔。
- 无人脸超时61秒触发noface静默,校验黑幕DOM、提示文案;
- 人脸存在,但画面长时间静止,触发still静默,校验提示文案;
- still静默下,仅时间流逝,无画面运动,状态不会自动退出;
- 传入运动标记,状态机退出静默;
- 人脸消失超时静默,人脸重新出现自动唤醒;
- 会话ended标记置true,强制退出静默;
- 调用closeCam关闭摄像头,静默状态清除;摄像头未开启时,不进入静默;
- 校验CFG配置项:正常检测间隔300ms,静默状态1200ms。

核心场景说明:
| 核心场景 | 测试点说明 |
|---|---|
| 触发静默条件 | 无人脸61s→noface静默;人脸静止61s→still静默 |
| 静默中校验 | 校验黑幕DOM与提示文案;still静默下仅时间流逝不自动退出 |
| 触发退出条件 | 传入运动标记退出;人脸重现自动唤醒;会话ended强制退出;closeCam清除静默 |
| 退出后校验 | 静默状态清除,界面恢复正常 |
| 配置校验 | 正常检测间隔300ms,静默状态1200ms |
五、项目工程意义
1. 填补AI前端测试空白
核心意义是填补AI前端感知层自动化测试空白,在大模型工程体系里,大家普遍关注模型微调、RAG、向量库、后端接口测试,但前端感知层(人脸、语音、用户在场状态)自动化测试经常被忽略。 感知模块作为大模型输入源,如果状态逻辑出错,会造成上层对话业务异常:用户离开大模型仍持续发言,或者用户在场时错误休眠、频繁弹窗闪烁,严重影响交互体验。
传统方案只能人工验收,本方案把感知状态逻辑纳入自动化回归,每次提交代码自动跑一遍全部场景,提前捕获状态机逻辑BUG。属于大模型应用端工程质量保障的一环,不是模型本身训练环节,是模型应用交互层的测试体系。
2. 消除硬件依赖
适配CI持续集成,CI服务器(如Gitlab CI、Github Actions、Jenkins)基本没有摄像头设备,无法运行依赖硬件的视觉交互测试。 这套Mock仿真方案不需要任何图像采集硬件,仅依赖Chrome浏览器,可直接部署在CI流水线。
代码提交后自动执行单元测试,一旦状态规则被改动导致用例失败,流水线阻断,阻止有问题代码合并到主分支。大幅降低版本迭代风险。
3. 低成本高保真测试
这里要重点区分两个测试目标,模型精度与业务逻辑:
- 人脸模型精度测试:需要大量真实图片数据集,测试模型能不能准确识别人脸;
- 状态机业务逻辑测试:假设人脸模型输出结果正确,测试拿到"有人/无人"信号之后,业务状态流转是否符合产品规则。
本项目解决的是第二类测试。不需要大量标注图像数据集,成本很低。即使后续替换底层人脸检测模型,上层状态机的业务规则不变,这套测试用例可以直接复用,快速验证新模型接入后状态流转是否正常。
4. 稳定复现边界时序场景
时序类BUG是状态机最难调试的一类问题,比如刚好61秒边界、人脸消失瞬间同时画面产生运动、会话结束和静默触发同时发生等竞态场景。人工很难稳定复现,而脚本可以精准控制时间戳,每次执行时序完全一致,100%稳定复现边界场景。
5. 可迁移的AI感知交互场景
这套无头浏览器 + 页面JS注入 + Mock浏览器API的测试范式,可以迁移到同类AI前端感知场景:语音VAD静音检测、手势识别、视线检测、页面交互活跃度状态机等。
凡是运行在浏览器、依赖摄像头/麦克风API、带有时间状态判断的AI前端模块,都可以借鉴这套思路,绕过硬件做业务逻辑单元测试。
六、应用实践示例
1. 基础可运行示例解析
下面提供极简示例,演示同原理:Mock canvas + headless chrome测试一个简易状态机,方便理解这套技术范式。新建demo.html:
html
<!DOCTYPE html>
<html>
<body>
<pre id="out"></pre>
<script>
// 简易状态机:无人脸超过3秒进入静默
let quietBy = "";
let lastFace = Date.now();
function quietTick(now, hasFace){
if(!hasFace && now - lastFace > 3000){
quietBy = "noface";
}else{
quietBy = "";
if(hasFace) lastFace = now;
}
}
// 测试脚本
(function(){
const log = [];
function T(name, cond){
log.push(cond ? "✓ " + name : "✗ " + name);
}
let now = Date.now();
quietTick(now, false);
quietTick(now + 3100, false);
T("无人脸3.1s,进入静默", quietBy === "noface");
quietTick(now+3200, true);
T("人脸出现,退出静默", quietBy === "");
document.getElementById("out").textContent = log.join("\n");
})();
</script>
</body>
</html>
Python调用Chrome执行demo.html的简易脚本:
python
import subprocess, re
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
dom = subprocess.run([CHROME,"--headless=new","--dump-dom","file:///d:/demo.html"],
capture_output=True,text=True).stdout
res = re.search(r"<pre id=\"out\">(.*?)</pre>", dom, re.S)
print(res.group(1))
运行后,会输出断言结果,验证简易静默状态机。这个示例剥离了复杂Canvas像素Mock,保留核心架构:页面内状态机 + 注入JS用例 + Chrome导出DOM拿到断言结果。可以作为入门模板,扩展到更复杂的视觉感知逻辑。
2. 静默监听逻辑自测说明
首先复制我们现有的前端html生成测试页,在headless Chrome里直接调真实的 quietTick/motionTick/closeCam,用真实 60 秒阈值断言18条静默、变黑与唤醒行为,几秒出结果。
python
# -*- coding: utf-8 -*-
"""临时:验证「静默监听」状态机(不用摄像头,直接调 kid.html 里的真实函数)。
生成 _q_unit.html(kid.html 的副本 + 一段测试脚本),用 headless Chrome 跑一遍、
把断言结果写到页面里,再用 --dump-dom 抓出来。断言用的是真实的 1 分钟阈值:
· 没人脸 61 秒 → 进静默(noface)
· 画面 61 秒没动静 → 进静默(still),且人脸还在也不会自己醒
· 有动静 / 人回来 / 会话结束 / 关摄像头 → 退出静默
· 动静分:两帧不变=0,连着两帧有差异才算「有人在动」
"""
import io
import os
import re
import subprocess
import sys
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
D = r"d:\AIWorld\case\test2"
KID = os.path.join(D, "260911.(271)kid.html")
PAGE = os.path.join(D, "_q_unit.html")
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
PROFILE = os.path.join(D, "_q_chrome_unit")
TEST = r"""
<script>
(function(){
const out = [];
const T = (n, c, d) => out.push((c ? "\u2713 " : "\u2717 ") + n + (d ? " [" + d + "]" : ""));
function dump(){ document.getElementById("qOut").textContent = "QRESULT\n" + out.join("\n") + "\nQEND"; }
window.addEventListener("error", e => out.push("\u2717 JS 报错:" + (e.message || e.type)));
setTimeout(function(){
try{
clearInterval(faceTimer); faceTimer = null; // 停掉后台检测,别来改被断言的状态
faceOn = true; faceMode = "heuristic"; sid = "unit"; ended = false; paused = false;
Object.defineProperty(video, "videoWidth", {get(){ return 640; }, configurable: true});
// ---- 1) 动静分:两帧完全一样 = 没动静;连着两帧有差异 = 有人在动 ----
resetAlive();
let px = 100;
moCtx.drawImage = function(){};
moCtx.getImageData = function(){ return {data: new Uint8ClampedArray(40 * 30 * 4).fill(px)}; };
motionTick();
T("第一帧只建基准,不算动", moScore === 0 && moHot === 0, "moScore=" + moScore + " hot=" + moHot);
motionTick();
T("画面完全不变 → 动静分 0、不刷新计时", moScore === 0 && moHot === 0, "moScore=" + moScore + " hot=" + moHot);
px = 112;
const mv1 = motionTick();
T("画面变了 → 判为在动(动静分 12)", mv1 === true && Math.abs(moScore - 12) < 0.01, "moScore=" + moScore);
T("只动一帧 → 只记 1 帧(噪点不算)", moHot === 1, "hot=" + moHot);
px = 124;
motionTick();
T("连着两帧都在动 → 认定真有人在动", moHot >= 2, "hot=" + moHot);
// ---- 2) 1 分钟没人脸 → 进静默 + 预览变黑 ----
let now = Date.now();
quietBy = ""; camSilent.classList.add("hidden");
F.present = false; lastSeenAt = now - 61000; lastMotionAt = lastVoiceAt = now;
quietTick(now, false);
T("1 分钟没人脸 → 进静默", quietBy === "noface", "by=" + quietBy);
T("摄像头预览变黑(黑幕显示)", camSilent.classList.contains("hidden") === false);
T("黑幕文案说明原因", camSilentText.textContent.indexOf("没看到你") >= 0, camSilentText.textContent);
// ---- 3) 画面 1 分钟没动静 → 进静默(人脸还在也算) ----
quietBy = ""; camSilent.classList.add("hidden");
F.present = true; lastSeenAt = now; lastMotionAt = lastVoiceAt = now - 61000;
quietTick(now, false);
T("画面 1 分钟没动静 → 进静默", quietBy === "still", "by=" + quietBy);
T("黑幕文案说明原因", camSilentText.textContent.indexOf("没动静") >= 0, camSilentText.textContent);
// ---- 4) 人脸还在但一动不动 → 不能自己醒(否则会来回闪) ----
quietTick(now + 100, false);
quietTick(now + 200, false);
T("人脸在但不动 → 不自己醒", quietBy === "still", "by=" + quietBy);
// ---- 5) 动一下 → 醒 ----
quietTick(now + 300, true);
T("有动静 → 醒来、黑幕收起", quietBy === "" && camSilent.classList.contains("hidden"), "by=" + quietBy);
// ---- 6) 人走开 → 静默;人回来 → 自己醒 ----
now = Date.now();
F.present = false; lastSeenAt = now - 61000; lastMotionAt = lastVoiceAt = now;
quietTick(now, false);
T("人走开 1 分钟 → 进静默", quietBy === "noface", "by=" + quietBy);
F.present = true;
quietTick(now + 100, false);
T("人回来 → 自己醒(不用孩子操作)", quietBy === "" && camSilent.classList.contains("hidden"), "by=" + quietBy);
// ---- 7) 会话结束 → 退出静默 ----
now = Date.now();
F.present = false; lastSeenAt = now - 61000; lastMotionAt = lastVoiceAt = now;
quietTick(now, false);
const inQuiet = (quietBy === "noface");
ended = true;
quietTick(now + 100, false);
T("会话结束 → 退出静默、黑幕收起", inQuiet && quietBy === "" && camSilent.classList.contains("hidden"),
"by=" + quietBy);
ended = false;
// ---- 8) 关摄像头 → 静默一并收掉;摄像头没开时也不进静默 ----
F.present = false; lastSeenAt = Date.now() - 61000; lastMotionAt = lastVoiceAt = Date.now();
quietTick(Date.now(), false);
const inQuiet2 = (quietBy === "noface");
closeCam();
T("关掉摄像头 → 静默一并收掉", inQuiet2 && quietBy === "" && camSilent.classList.contains("hidden"),
"by=" + quietBy);
quietBy = ""; lastSeenAt = Date.now() - 61000;
quietTick(Date.now(), false);
T("摄像头没开 → 不进静默", quietBy === "", "by=" + quietBy);
T("静默时检测节奏 1200ms / 平时 300ms", CFG.faceEveryQuietMs === 1200 && CFG.faceEveryMs === 300,
CFG.faceEveryMs + " / " + CFG.faceEveryQuietMs);
}catch(e){
out.push("\u2717 抛异常:" + (e && e.message));
}
dump();
}, 800);
})();
</script>
<pre id="qOut" style="position:fixed;left:0;top:0;z-index:99;font-size:11px">waiting</pre>
"""
def build():
base = io.open(KID, encoding="utf-8").read()
assert base.count("</body>") == 1
base = base.replace("https://cdn.tailwindcss.com", "_tw.js")
base = base.replace("</body>", TEST + "\n</body>", 1)
io.open(PAGE, "w", encoding="utf-8").write(base)
def main():
build()
dom = subprocess.run(
[CHROME, "--headless=new", "--no-first-run", "--user-data-dir=" + PROFILE,
"--virtual-time-budget=4000", "--dump-dom", "file:///" + PAGE.replace("\\", "/")],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120).stdout or ""
m = re.search(r"QRESULT\n(.*?)\nQEND", dom, re.S)
if not m:
print("没抓到结果,页面输出前 600 字:")
print(dom[:600])
return 2
body = m.group(1)
print(body)
bad = [ln for ln in body.splitlines() if ln.startswith("\u2717")]
print("")
print("结果:%s" % ("全部通过" if not bad else "有 %d 项没过" % len(bad)))
io.open(os.path.join(D, "_q_unit_dom.html"), "w", encoding="utf-8").write(dom)
return 0 if not bad else 1
if __name__ == "__main__":
sys.exit(main())
运行输出:
✓ 第一帧只建基准,不算动 moScore=0 hot=0
✓ 画面完全不变 → 动静分 0、不刷新计时 moScore=0 hot=0
✓ 画面变了 → 判为在动(动静分 12) moScore=12
✓ 只动一帧 → 只记 1 帧(噪点不算) hot=1
✓ 连着两帧都在动 → 认定真有人在动 hot=2
✓ 1 分钟没人脸 → 进静默 by=noface
✓ 摄像头预览变黑(黑幕显示)
✓ 黑幕文案说明原因 1 分钟没看到你,豆豆先安静等你回来
✓ 画面 1 分钟没动静 → 进静默 by=still
✓ 黑幕文案说明原因 1 分钟没动静,豆豆先安静听一会儿
✓ 人脸在但不动 → 不自己醒 by=still
✓ 有动静 → 醒来、黑幕收起 by=
✓ 人走开 1 分钟 → 进静默 by=noface
✓ 人回来 → 自己醒(不用孩子操作) by=
✓ 会话结束 → 退出静默、黑幕收起 by=
✓ 关掉摄像头 → 静默一并收掉 by=
✓ 摄像头没开 → 不进静默 by=
✓ 静默时检测节奏 1200ms / 平时 300ms 300 / 1200
结果:全部通过
七、总结
本项目是AI大模型应用前端感知层的高质量工程实践,它巧妙利用Headless Chrome浏览器、Monkey Patch接口仿真、全局状态注入,绕开摄像头硬件限制,对浏览器端用户静默监听状态机做完整自动化单元测试。
整套方案的核心思想:区分模型推理层和业务状态控制层,对状态控制层做时序自动化回归。大模型应用不只是大模型本身,完整产品链路包含感知、状态管理、对话调度、UI渲染多个环节。感知状态机负责采集用户在场信号,作为输入传递给大模型,状态流转逻辑一旦出错,直接影响人机交互体验。