看门狗判定状态机增加主机侧单元测试(L0.5 层)

为 task_watchdog.c 的判定逻辑(位图取走清零 / 必需掩码 0x37 判定 /
miss_streak 连续 8 次停喂 / 100ms 抽样节流 / tick 回绕安全)补一层
主机侧纯逻辑单元测试,弥补此前只能靠真机或模拟器才能发现回归的空白。

架构(沿用项目既有"共享核心 + 平台后端"做法,参照 cfg_file_store):
- task_watchdog.h 末段新增"平台后端(内部)"声明 3 个钩子
  (wd_platform_feed / _start / _tick_ms),MCU 端以 __weak 提供默认实现,
  行为与重构前逐字一致(@build 0 错误 0 警告,Flash +20B)。
- 主机侧单测以同名强符号覆盖三钩子,即可离线注入/观测,无需碰 MCU 代码。
- 测试文件 #include 生产源文件本身,保证测的是真代码而非副本。
- 测试专用复位 task_wd_test_reset() 仅 TASK_WATCHDOG_UNIT_TEST 下编入,
  MCU 构建零代码零符号。

测试(test/host/watchdog/,MSVC 离线编译,纯标准库驱动):
- 39 项断言全过(退出码 0);注入故障可复现退出码 1。
- run_unit_test.py 直接拼 INCLUDE/LIB 调 cl.exe,绕开本机被禁的 reg.exe。
- stub/ 三桩头覆盖 main.h / iwdg.h / dbg_log.h。

门禁:pre-commit 新增 watchdog-unit-test 本地钩子(L0.5 层),仅被测文件
或其测试变动时触发;测试开发标准流程.md 新增 L0.5 层说明。

顺带改善:task_watchdog.c 的节流基线 s_last_tick 由函数内 static 提为文件
static(可测性 + 模块状态归一)。
This commit is contained in:
2026-10-01 06:57:01 +08:00
parent 0ac68605dd
commit 73941da6b1
18 changed files with 1268 additions and 107 deletions
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""看门狗判定状态机的主机侧单元测试运行器(纯标准库)。
做什么:
用本机 MSVC 编译 test/host/watchdog/watchdog_test.c(它以 #include 纳入
**生产源文件** App/util/task_watchdog.c),在 PC 上离线跑完整判定状态机,
失败即非 0 退出。零硬件、零烧录、毫秒级。
为什么要主机侧单测(而不是只用板端套件):
task_watchdog.c 的判定逻辑(位图取走清零 / 掩码 / miss_streak 累计 / 节流)
是纯逻辑,与硬件无关;板端套件只能"在真机上跑一遍",无法穷举边界(节流窗口
99/100ms、tick 回绕、连续 8 次缺席的第 8/9 次分界)。主机侧单测把这些一次性
钉死,且不占 MCU 的 RAM(项目 RAM 已 95%)。
实现要点(为何不用 vcvars64.bat):
vcvars64.bat 内部调用 reg.exe 探测环境;本机策略禁止 reg.exe。故这里**直接
拼 INCLUDE/LIB 环境变量**并绝对路径调用 cl.exe——只需 3 个已知目录
(MSVC 版本 / Windows SDK include / SDK lib),无副作用、可重复。
用法(仓库根目录):
python test/host/watchdog/run_unit_test.py
或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/watchdog/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败)。
设计依据:docs/测试说明/看门狗单元测试说明.md
创建日期:2026-10-01
"""
import glob
import os
import subprocess
import sys
import tempfile
# ----------------------------- 路径 -----------------------------
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
STUB = os.path.join(HERE, "stub")
SRC = os.path.join(HERE, "watchdog_test.c")
APP_UTIL = os.path.join(ROOT, "App", "util") # task_watchdog.c 所在目录
VS_ROOTS = [
r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools",
r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools",
r"C:\Program Files\Microsoft Visual Studio\2022\Community",
r"C:\Program Files\Microsoft Visual Studio\2022\Professional",
]
SDK_ROOTS = [r"C:\Program Files (x86)\Windows Kits\10"]
def _first(pattern):
hits = sorted(glob.glob(pattern))
return hits[-1] if hits else None
def _decode(b):
"""把 subprocess 字节输出解码为字符串:本机 MSVC 输出为 GBK(代码页 936),
测试程序自身输出为 UTF-8;按 UTF-8 优先、失败回退 GBK 处理。"""
if isinstance(b, str):
return b
try:
return b.decode("utf-8")
except UnicodeDecodeError:
return b.decode("gbk", errors="replace")
def locate_toolchain():
"""返回 (cl.exe, include_dirs, lib_dirs);找不到返回 (None, [], [])。"""
cl = None
for vs in VS_ROOTS:
c = _first(os.path.join(vs, r"VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe"))
if c:
cl = c
break
if not cl:
return None, [], []
msvc_ver_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(cl))))
# cl.exe = <vs>\VC\Tools\MSVC\<ver>\bin\Hostx64\x64\cl.exe
msvc_ver_dir = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(cl)))) # -> ...\MSVC\<ver>
inc = [
os.path.join(msvc_ver_dir, "include"),
]
lib = [
os.path.join(msvc_ver_dir, "lib", "x64"),
]
for sdk in SDK_ROOTS:
sdk_inc = _first(os.path.join(sdk, r"Include\*"))
sdk_lib = _first(os.path.join(sdk, r"Lib\*"))
if sdk_inc:
inc += [
os.path.join(sdk_inc, "ucrt"),
os.path.join(sdk_inc, "shared"),
os.path.join(sdk_inc, "um"),
]
if sdk_lib:
lib += [
os.path.join(sdk_lib, "ucrt", "x64"),
os.path.join(sdk_lib, "um", "x64"),
]
return cl, inc, lib
def main():
cl, inc, lib = locate_toolchain()
if not cl:
print("[ENV] 找不到 MSVC cl.exe(需要 VS2019/2022 的 C++ 工具链)",
file=sys.stderr)
return 2
tmp = tempfile.gettempdir()
objdir = os.path.join(tmp, "wd_test_obj")
os.makedirs(objdir, exist_ok=True)
exe = os.path.join(tmp, "watchdog_unit_test.exe")
env = dict(os.environ)
env["INCLUDE"] = os.pathsep.join(inc)
env["LIB"] = os.pathsep.join(lib)
env["PATH"] = os.path.dirname(cl) + os.pathsep + env.get("PATH", "")
cmd = [
cl, "/nologo", "/W4", "/std:c11", "/utf-8",
# C4819("源代码包含不能在当前代码页中表示的字符")是**纯编码诊断**:
# 本文件确为 UTF-8(带 BOM,已核验),且已显式传 /utf-8,字节解码无误、
# 编译产物正确。但在"只设 INCLUDE/LIB、不调 vcvars"的调用方式下,
# MSVC 的代码页探测会退化为 `代码页(0)` → 恒定误报 10 条。
# 该文件仅主机侧编译(ARMCC 从不编译 test/),故显式屏蔽,保持门禁输出干净。
"/wd4819",
"/I", STUB, # stub 头优先于真实头(覆盖 main.h/iwdg.h/dbg_log.h)
"/I", APP_UTIL, # task_watchdog.c / task_watchdog.h
"/I", HERE, # (保留)本目录
SRC,
"/Fe:" + exe,
"/Fo:" + objdir + os.sep,
]
print("=== 编译并运行看门狗单元测试 ===")
cp = subprocess.run(cmd, env=env, capture_output=True)
cout = _decode(cp.stdout)
cerr = _decode(cp.stderr)
if cout:
sys.stdout.write(cout)
if cerr:
sys.stderr.write(cerr)
if cp.returncode != 0:
print("[ENV] 编译失败", file=sys.stderr)
return 2
run = subprocess.run([exe], env=env, capture_output=True)
out = _decode(run.stdout)
sys.stdout.write(out)
errout = _decode(run.stderr)
if errout:
sys.stderr.write(errout)
if "=== Task Watchdog Tests:" not in out:
print("[ENV] 未产生测试汇总行", file=sys.stderr)
return 2
if run.returncode == 0:
print("[OK] 单元测试全部通过")
return 0
print("[FAIL] 有测试用例未通过", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())