06 测试报告
受众:工程师与 AI Agent。本册汇总 Podcast 项目两条交付线的测试结果:后端 Catalog API(
podcast-api/,Vitest)与 桌面端(desktop/,CEF + Qt,自研压测/计时夹具)。 真实性原则:所有数字与结论均给出file_path:line锚点,数据源为已核验的测试代码、夹具与压测/覆盖率产物。读不到的内容标注"未验证/待确认",不臆造。 关联分册:API 行为见《03 API 设计》、数据库与数据集见《02 数据模型》、桌面端架构见《05 桌面端》。本册可独立读懂测试核心。
1. 测试策略与分层
测试目标分两层:功能正确性(端点契约、错误码、鉴权、配额)与 数据一致性(API 返回必须与源数据集 JSONL 逐字段一致)。桌面端额外覆盖 稳定性(崩溃/挂起/进程泄漏)与 性能(启动/关闭耗时)。
| 层 | 位置 | 连接对象 | 验证内容 | 锚点 |
|---|---|---|---|---|
| 单元 | podcast-api/tests/unit/ | 纯函数(无 IO) | cursor 编解码、envelope、auth 解析、Zod schema、duration/日期格式化 | podcast-api/docs/TEST-PLAN.md:9 |
| 集成 | podcast-api/tests/integration/ | Route Handler 直调 + 真实 PG | 端点契约/过滤/排序/翻页/错误码/配额 | podcast-api/docs/TEST-PLAN.md:10 |
| 一致性 | podcast-api/tests/consistency/ | Route Handler + 真实 PG + 源 JSONL fixture | API/DB 响应 vs 源数据逐字段比对 + 聚合对账 | podcast-api/docs/TEST-PLAN.md:11 |
| 压力 | desktop/tests/stress-test.mjs | 签名版 .app(CDP 驱动) | 500 轮"启动→页面切换→关闭"生命周期稳定性 | desktop/tests/stress-test.mjs:2 |
| 性能 | desktop/tests/perf-timing.mjs | 签名/开发版 .app(perf_trace 打点 + CDP) | 启动/关闭毫秒级 A/B 计时 | desktop/tests/perf-timing.mjs:6 |
框架与门禁(podcast-api/vitest.config.ts):
| 项 | 配置 | 锚点 |
|---|---|---|
| 测试框架 | vitest@4(vitest run) | podcast-api/package.json scripts.test |
| 环境 | happy-dom;集成/一致性套件用 // @vitest-environment node 覆盖 | vitest.config.ts:16 |
| include | tests/**/*.{test,spec}.{ts,tsx} | vitest.config.ts:19 |
| testTimeout / hookTimeout | 30,000ms(聚合 count(*) 跨 22.8M 行需放宽) | vitest.config.ts:22-23 |
| 覆盖率 provider | v8;include src/lib/** + src/app/api/**;exclude src/lib/db/schema.ts | vitest.config.ts:25-30 |
| 覆盖率阈值 | lines/functions/branches/statements 均 70%(不放宽) | vitest.config.ts:31 |
集成/一致性套件要求
DATABASE_URL指向已装载完毕全量数据的库(293,781 播客 / 22,867,469 剧集),通过tests/helpers/{api,db,fixtures}.ts提供callRoute / expectOk / expectFail / TOKENS / pg等工具(podcast-api/tests/helpers/api.ts:7,66,79,88)。
2. API 测试结果
2.1 总览:381 个测试全绿
| 指标 | 结果 | 说明 |
|---|---|---|
| 测试总数 | 381 全绿 | 16 个测试文件 |
| 测试文件 | 16 个 | unit 6 + integration 9 + consistency 1 |
| lint / typecheck | 通过(npm run lint && npm run typecheck) | podcast-api/docs/TEST-PLAN.md:54 通过标准 4 |
16 文件构成(实际目录核验):
tests/unit/(auth/cursor/envelope/format/health.service/validations,6 个)+tests/integration/(auth/categories/downloads/episode-detail/episodes/health/languages/progress/shows,9 个)+tests/consistency/consistency.test.ts(1 个)。
2.2 覆盖率(实测,coverage/index.html "All files")
| 维度 | 比率 | 计数 | 阈值 | 余量 |
|---|---|---|---|---|
| Statements | 94.7% | 429 / 453 | 70% | +24.7pt |
| Branches | 92.83% | 246 / 265 | 70% | +22.83pt |
| Functions | 100% | 63 / 63 | 70% | +30pt |
| Lines | 93.92% | 371 / 395 | 70% | +23.92pt |
锚点:
podcast-api/coverage/index.html("All files 94.7% Statements 429/453 / 92.83% Branches 246/265 / 100% Functions 63/63 / 93.92% Lines 371/395")。覆盖范围src/lib/**+src/app/api/**,排除纯声明文件schema.ts(vitest.config.ts:28)。
2.3 按层用例分布
| 层 | 用例数 | 主要文件与覆盖面 |
|---|---|---|
| 单元 | 62 | format(15)validations(17)auth(12)cursor(11)envelope(4)health.service(2) |
| 集成 | 114 | shows / episodes / downloads / progress / auth / categories / episode-detail / languages / health |
| 一致性 | 205 | 见 §3 |
| 合计 | 381 | — |
单元/集成的"逻辑用例"经
it.each参数化后展开为运行时用例(例:integration/auth.test.ts:53,61的it.each(ROUTES)对每条受保护路由各跑一遍 401 检查),故运行时用例数高于源码中it()字面行数。一致性 205 的精确推导见 §3.1。
2.4 集成层关键用例摘录(按端点)
| 端点 / 主题 | 代表用例 | 期望 | 锚点 |
|---|---|---|---|
| 鉴权(全路由) | 缺 / 非法 Bearer | 401 / code 1001;合法 token 回显 request_id | tests/integration/auth.test.ts:53,61,68,78 |
languages | 首项 {id:all} | count=总数;各语言 count 之和=总数;降序 | tests/integration/languages.test.ts:9 |
categories | 不带 lang | all.count=总播客数;15 主类齐全且 subs 非空;lang=en 各 count≤无 lang | tests/integration/categories.test.ts:16,43,56 |
shows 列表 | 默认 | 20 条;total=总数;episode_count 降序 | tests/integration/shows.test.ts:20 |
shows limit | 5/50/100 | 5/50/50(钳制);0/-1/abc→code 1000 | tests/integration/shows.test.ts:54,58,62,66 |
shows cursor | 翻 3 页 | 60 个 id 无重复;第 2 页起无 total;与单次大查询切片等价 | tests/integration/shows.test.ts:79,93 |
shows 过滤 | category=news / category+sub / lang=en | 每项命中;sub 缺 category→code 1000 | tests/integration/shows.test.ts:114,124,131,135 |
shows 搜索 | q=确定性关键词 | 命中目标节目,含 score,按相关度降序 | tests/integration/shows.test.ts:155 |
shows 排序/区间 | sort=name/recent;updated_after/before | COLLATE 稳定序;NULLS LAST;闭区间含边界 | tests/integration/shows.test.ts:186,206 |
episodes 翻页 | 100-300 集节目全量 keyset 走查 | 累计=total,无重复/遗漏,与 DB 集合一致;NULL publish_date 尾部按 episode_uid 翻页 | tests/integration/episodes.test.ts:81,111,124 |
episodes is_downloadable | 无 audio_url | false | tests/integration/episodes.test.ts:230 |
| 进度 | POST→GET 回读 / UPSERT / 隔离 | 字段一致,progress_percent 一位小数;负数/超长/非整数→code 1000;跨 token 不可见 | tests/integration/progress.test.ts:41,65,111,117,139 |
| 下载试用配额 | 第 1-3 次 / 第 4 次 | code 0 且 used 1→3;第 4 次 403 / code 1003 | tests/integration/downloads.test.ts:53,67 |
| 下载日配额 | 注入 300 当日任务后 paid POST | 403 / code 1004,带 daily_quota 载荷 | tests/integration/downloads.test.ts:200 |
| 下载归属 | 他人 task_id | 404 / code 2001(owner-scoped) | tests/integration/downloads.test.ts:163 |
| 配额快照 | /user/quota/podcasts | used+remaining=total,reset_at 为未来时刻 | tests/integration/downloads.test.ts:174 |
试用 vs 日配额顺序:API-DESIGN §2.9 规定试用检查先于日配额,故试用 token 即便注入 300 条仍得 1003;日配额 1004 路径用 paid token 验证(
tests/integration/downloads.test.ts:11-14注释 + 第 195、200 行)。
3. 数据一致性套件(核心验收)
唯一真值原则:以源 JSONL 为唯一真值,禁止以 DB 校 DB。任一映射字段不一致即 toEqual 失败并打印 diff(tests/consistency/consistency.test.ts:2-3 头注)。
3.1 用例数推导(205)
| 套件 | 描述 | 用例数 | 锚点 |
|---|---|---|---|
| §3.2 节目详情 | 60 档抽样播客,每档全映射字段比对 | 60 | consistency.test.ts:49 it.each(fixtures.sampled…) |
| §3.2 单集有序比对 | 60 档 × newest 前 30 单集,逐项有序比对 | 60 | consistency.test.ts:84 |
| §3.2 单集详情 | 40 个 episode pick 直查 /episodes/{id} | 40 | consistency.test.ts:115 |
| §3.2 stream 逐字节 | 40 个 pick 的 stream_url 与源 audioUrl 逐字节一致 | 40 | consistency.test.ts:135 |
| §3.3 聚合对账 | languages / categories / 抽样 total / DB episodes / DB podcasts | 5 | consistency.test.ts:160,174,189,205,212 |
| 合计 | 205 |
3.2 抽样规则(确定性随机,可重放)
固定种子伪随机抽样,tests/consistency/build-fixtures.mjs 生成 fixtures.json:
| 参数 | 值 | 锚点 |
|---|---|---|
| 随机种子 SEED | 20260611 | build-fixtures.mjs:23(fixture 实测 seed=20260611) |
| 每 shard 抽样数 | 10(6 shard × 10 = 60 档,seeded reservoir sampling) | build-fixtures.mjs:24,72-73,105-110 |
| 每档保留单集 | newest 前 30(publishDate DESC NULLS LAST, episodeUID DESC) | build-fixtures.mjs:25,47-57 |
| 单集直查 picks | 40(跨有单集的抽样档分布,独立 PRNG SEED*7) | build-fixtures.mjs:26,145-152 |
fixture 实测核验:
sampled=60、episodePicks=40、aggregates.totalPodcasts=293781、aggregates.totalEpisodeRows=22867469、generatedAt=2026-06-10T17:55:23Z(tests/consistency/fixtures.json)。
3.3 字段映射比对(§3.1 映射表)
| API 字段 | 源字段 | 变换 | 比对方式 |
|---|---|---|---|
show.id/title/author/cover/color/description/web/language_id | podcastUID/podcastName/author/logo/color/desc/web/lang | 无(null 透传) | 全等 toEqual |
show.categories | categories[] | 去重后无序集合 | normalizeCategories(tests/helpers/fixtures.ts:88) |
show.episode_count | episodes[].length(实存集数,≠episodeCountClaimed) | 计数 | 全等 |
episode.duration | duration | 秒→HH:MM:SS(独立实现 secondsToHms,不复用生产代码) | 全等 tests/helpers/fixtures.ts:78 |
episode.publish_date | publishDate | unix 秒→YYYY-MM-DD(UTC);≤0/越界→null | 全等 tests/helpers/fixtures.ts:66 |
stream.stream_url | audioUrl | 无 | 逐字节 toBe;无 audio→2002(consistency.test.ts:142-148) |
3.4 聚合对账结果
| 检查 | 期望值 | 锚点 |
|---|---|---|
languages all.count | = 293,781(=fixtures.aggregates.totalPodcasts),各语言 count 之和=all.count,且逐语言等于 perLang | consistency.test.ts:160-172 |
categories 各主类 count | = 源按 cat 去重的 distinct 播客数(fixture perCatDistinctPodcasts) | consistency.test.ts:174-186 |
| 抽样 60 档单集 total | = 源 episodes 数组长度(全 60 档校验) | consistency.test.ts:189-203 |
| DB episodes 行数 | = 22,867,469 | consistency.test.ts:205-208 |
| DB podcasts 行数 | = 293,781 | consistency.test.ts:210-214 |
结论:0 差异。 抽样 60 档全字段、60×30 单集有序、40 个 stream 逐字节、聚合对账(293,781 / 22,867,469 + 分类去重计数)全部一致。
分类去重计数实测(
fixtures.json:aggregates.perCatDistinctPodcasts,节选主类):news 38,112 / sports 34,238 / health--fitness 29,662 / leisure 29,309 / arts 23,641 / comedy 20,787 / business 20,715 / education 20,480 / religion--spirituality 19,288 / kids--family 16,993 / science 15,951 / tv--film 15,649 / fiction 15,141 / music 14,929 / society--culture 11,489。另有 history/government/true-crime/technology/unknown 各 4(数据集稀疏标签噪声)。 说明:当前数据集perLang仅{en: 293781}(全量为英语;fixtures.json实测),故"各语言 count 之和=总数"在单语言下退化为单项相等,断言逻辑仍成立。
4. 桌面端 500 轮稳定性压测
被测:
dist/PodcastDesktop.app(Developer ID 签名 + 硬化运行时)。夹具desktop/tests/stress-test.mjs,可重放。报告desktop/tests/STRESS-REPORT.md。
4.1 测试协议(每轮)
- 启动签名版 app(CEF 框架加载 + 5 个 Helper 进程拉起)。
- CDP 探测页面就绪后执行 3 次页面切换:主页 →
#/show/podcast_106702→ 返回主页 → 点击翻页(next)按钮(stress-test.mjs第 78-93 行)。 - 经与关闭按钮完全相同的代码路径关闭:
--auto-close-ms→QWidget::close→closeEvent→ CEF 关闭握手(stress-test.mjs:1-3;desktop/src/main.cpp:234-245)。 - 逐轮校验 5 项:退出码 / 挂起(30s 硬超时)/ 终止信号 / macOS 系统崩溃报告增量(DiagnosticReports
.ips)/ Helper 进程残留(stress-test.mjs第 12-22、108-121 行)。
4.2 结果:500/500 通过,零崩溃
| 指标 | 结果 |
|---|---|
| 完成轮次 | 500 / 500 |
| 失败轮次 | 0 |
| 退出码 | 全部 0 |
| 挂起(>30s) | 0 |
| 异常信号(SIGSEGV 等) | 0 |
| 系统崩溃报告(.ips)新增 | 0 |
| 页面切换成功 | 1500 / 1500(每轮 3/3) |
| 单轮耗时 | p50=6,964ms / p95=7,036ms / max=7,282ms(含 6s 固定驻留窗口) |
| Helper 残留 | 仅 3 轮在退出后 300ms 采样点观测到正在终止的 Helper(夹具已清理,无跨轮累积——采样时机噪声,非泄漏) |
结论:打开→页面切换→关闭的完整生命周期在 500 轮高频循环下无任何 crash/挂起/进程泄漏。此前用户报告的两类崩溃(关闭死锁、DevTools/关闭时 isHandlingSendEvent 崩溃)经修复后零复现(STRESS-REPORT.md §1.3)。
5. 启动/关闭性能 A/B
夹具
desktop/tests/perf-timing.mjs:解析perf_trace.h的 stderr[perf] <name> +<ms>打点(desktop/src/perf_trace.h:3-5,30),缺打点时回退到外部可观测计时。基线/优化各 15 轮,全部 0 失败。
5.1 A/B 结果(同机静默环境,p50)
| 指标 | 基线(旧泵) | 优化后 | 提升 |
|---|---|---|---|
| 关闭耗时(点击→进程退出) | 599ms | 47ms | ↓92%(12.7×) |
| 关闭 p90 | 618ms | 58ms | ↓91% |
| 启动→服务就绪(CDP) | 526ms | 364ms | ↓31% |
| 启动→窗口可见 | 无打点(基线晚于 CefInitialize) | 151ms | 感知启动大幅提前 |
| 启动→页面加载完成 | — | 374ms | — |
| 浏览器销毁(DoClose→destroyed) | — | 18ms | — |
签名硬化版抽验(5 轮):窗口可见 p50=144ms、启动 p50=361ms、关闭 p50=41ms,与开发版一致(
STRESS-REPORT.md§2.2)。
5.2 优化项(A/B 对照的改动来源)
| # | 层 | 改动 | 锚点 |
|---|---|---|---|
| 1 | 消息泵 | 10ms 固定轮询 → CEF 外部消息泵(external_message_pump=1 + OnScheduleMessagePumpWork 精确调度,~30Hz 自持底线防停转) | desktop/src/main.cpp:99-103,116,126-129,214;desktop/src/cef_app.cc:38-44 |
| 2 | 感知启动 | 窗口先显示、CefInitialize 后置;深色底(#0f1115)防白闪;浏览器创建解耦 showEvent | desktop/src/main.cpp:184-194,228;desktop/src/qt_window.cpp:74-86 |
| 3 | 启动裁剪 | 关闭 GCM/组件更新/同步等后台服务(disable-background-networking 等开关) | desktop/src/cef_app.cc:24-30 |
| 4 | 关闭路径 | 关闭安全网泵步进 10ms→1ms(关闭关键路径) | desktop/src/main.cpp:258-265 |
| 5 | 可观测性 | perf_trace.h 全链路毫秒级打点(stderr,夹具可解析) | desktop/src/perf_trace.h:3-5,30 |
6. 测试中发现并修复的缺陷
| # | 缺陷 | 表现 | 修复 | 锚点 |
|---|---|---|---|---|
| 1 | int4 溢出未校验 | 进度 position_seconds / duration_seconds 传超大值会击穿 PG int4 存储 | Zod 加 .max(INT4_MAX=2_147_483_647),越界返回 code 1000 | podcast-api/src/lib/validations/podcasts.ts:50-57;测试 tests/integration/progress.test.ts:117、tests/unit/validations.test.ts:82 |
| 2 | 外部泵调度停转 | CEF 不保证每次 DoWork 后回调重新调度 → 导航/IPC 冻结 | 按 cefclient 范式加 ~30Hz(33ms)自持底线,并以页面加载打点 + CDP 响应回归验证 | desktop/src/main.cpp:109-130;STRESS-REPORT.md §2.3 |
| 3 | 关闭死锁 | 关闭时 CEF 重发 OS close 被忽略 → close 取消、OnBeforeClose 不触发(关闭按钮像死键) | closeEvent 在 browser_closed_ / IsClosing() 时接受重发的 close,CloseBrowser(false) 走 DoClose→OnBeforeClose | desktop/src/qt_window.cpp:88-108 |
| 4 | isHandlingSendEvent 崩溃 | Qt 的 QNSApplication 未实现 CEF 期望的 CefAppProtocol,DevTools/关闭时 "unrecognized selector" 崩溃 | 给 NSApplication 加 category 补 isHandlingSendEvent/setHandlingSendEvent: | desktop/src/browser_embed.mm:10-30 |
| 5 | 假 loading(无谓闪烁) | 缓存/快速加载也弹 spinner,造成可见闪烁 | 延迟显示 spinner(SPINNER_DELAY_MS=150),缓存命中直接翻无 spinner | podcast-api/public/app/app.js:16,142,270-271,522-523 |
| 6 | 公证票据缺口(离线 Gatekeeper) | 未 staple 的 app 只在能联网访问 Apple 时通过;离线/被墙内网机器报警 | 公证后将票据 staple 进 app bundle,再封进 DMG(离线可过 Gatekeeper) | desktop/package.sh:116-119,149-155;desktop/notarize-on-server.sh:69-71 |
缺陷 1 同时被单元(schema 层)与集成(端到端 400/1000)两层覆盖,体现"同一缺陷多层防回归"。缺陷 3、4 的零复现由 §4 的 500 轮压测背书(
STRESS-REPORT.md§1.3)。
7. 复现方式
7.1 API 测试
# 前置:DATABASE_URL 指向已装载完毕的全量库(293,781 播客 / 22,867,469 剧集)
cd podcast-api
npm run test # vitest run,全部 381 用例
npm run test:coverage # 带覆盖率(产物输出到 coverage/)
npm run lint && npm run typecheck # 门禁(通过标准 4)
一致性 fixture 重建(确定性,可验证可重放,种子 20260611):
node podcast-api/tests/consistency/build-fixtures.mjs <dataset-dir> [out-file]锚点:
podcast-api/tests/consistency/build-fixtures.mjs:10。
7.2 桌面端压测与计时
# 500 轮稳定性压测(启动→3 次页面切换→关闭路径关闭)
node desktop/tests/stress-test.mjs <app-binary> 500 out.json
# 启动/关闭性能计时(N 轮,label 标识,可选输出 JSON)
node desktop/tests/perf-timing.mjs <app-binary> 15 baseline /tmp/perf-baseline.json
node desktop/tests/perf-timing.mjs <app-binary> 15 optimized /tmp/perf-optimized.json
锚点:
desktop/tests/stress-test.mjs:5、desktop/tests/perf-timing.mjs:6。原始数据:/tmp/stress-500.json、/tmp/perf-{baseline,optimized,signed}.json(STRESS-REPORT.md附录三)。
附:通过标准与现状
标准(podcast-api/docs/TEST-PLAN.md:50-54) | 现状 |
|---|---|
| 1. 全部用例绿 | ✅ 381/381 |
| 2. 覆盖率 ≥70% | ✅ 实测 statements 94.7% / branches 92.83% / functions 100% / lines 93.92% |
| 3. 一致性套件 0 差异 | ✅ 60 档全字段 + 60×30 单集 + 40 stream 逐字节 + 聚合对账 0 差异 |
4. lint && typecheck 通过 | ✅ |
| 桌面端 500 轮稳定性 | ✅ 500/500、0 崩溃、0 挂起、0 系统崩溃报告 |
| 桌面端性能 | ✅ 关闭 599→47ms(↓92%)、窗口可见 151ms、服务就绪 526→364ms |