docs(test): 测试框架架构 + 开发规范五支柱文档

- docs/test-architecture.html:分层测试金字塔(L0契约→L4 E2E) + 契约接缝
  数据流 + 四类诉求映射 + CI 流水线 + E2E 拓扑,5 张内联 SVG/表格图
- docs/dev-conventions.html:可测试性五支柱(接缝即接口/契约单源/纯逻辑分离/
  错误是值/可观测) + 支柱↔测试层咬合矩阵 + 反例对照表 + 执行机制四道闸
- docs/index.html:登记两份文档到知识库/调研,双向互链

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 23:25:14 +08:00
parent 2a1208a41c
commit 29ddd5b453
3 changed files with 739 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin 开发规范 · 可测试性五支柱</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 28px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16.5px;margin:24px 0 8px;color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
a{color:var(--accent2);text-decoration:none}
a:hover{text-decoration:underline}
ul{margin:8px 0;padding-left:22px}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
.note{color:var(--fg2);font-size:13px;border-left:3px solid var(--border);padding-left:12px;margin:10px 0}
.fig{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;margin:18px 0;overflow-x:auto}
.fig .cap{color:var(--fg2);font-size:12.5px;margin:0 0 10px;font-weight:600;letter-spacing:.02em}
.fig svg{display:block;max-width:100%;height:auto}
.pillar{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin:14px 0}
.pillar .badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;margin-bottom:8px;background:rgba(224,136,79,.16);color:var(--accent)}
.pillar .rule{font-size:15px;margin:6px 0 12px;padding:10px 12px;background:var(--panel2);border-radius:8px;border-left:3px solid var(--accent)}
.kv{margin:6px 0}
.kv .k{display:inline-block;min-width:84px;color:var(--fg2);font-size:12.5px;font-weight:700;vertical-align:top}
.kv .v{display:inline-block;width:calc(100% - 92px);font-size:13.5px;vertical-align:top}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--panel2);color:var(--fg);font-weight:600}
td{color:var(--fg2)}
td b,td strong{color:var(--fg)}
.tag{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;white-space:nowrap}
.t-ok{background:rgba(94,194,122,.16);color:var(--ok)}
.t-warn{background:rgba(224,184,79,.16);color:var(--warn)}
.t-bad{background:rgba(224,106,106,.16);color:var(--bad)}
.ex-ok{color:var(--ok);font-weight:700}
.ex-bad{color:var(--bad);font-weight:700}
.kbd{font-family:var(--mono);font-size:.86em;color:#f0d9c4}
.xref{display:inline-block;margin-top:6px;font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<h1>Pangolin 开发规范 · 可测试性五支柱</h1>
<p class="sub">「怎么写才好测」——开发规范是可测试性的前置条件 · 与测试框架咬合</p>
<div class="lead">
<strong>核心立场:</strong>测试做不做得起来,根子在代码<b>有没有遵守可测试的结构约定</b>。哪里没遵守,哪里就测不了。这份规范把决定「能不能自动化测」的约定收成 <b>5 条支柱</b>,每条回答三件事:<b>怎么写 → 所以测得了 → 由哪层守门</b>。它与 <a href="test-architecture.html">测试框架架构说明</a> 一一咬合:规范定义接缝,测试在接缝下刀。
<div class="xref">↔ 配套文档:<a href="test-architecture.html">统一测试框架 · 架构说明(L0–L4 分层)</a></div>
</div>
<h2>五支柱速览</h2>
<p>写法采用<b>通用原则 + Pangolin 实例</b>双层——原则可复制到任何多端项目,实例替换即可。</p>
<div class="pillar">
<span class="badge">支柱 1 · 接缝即接口</span>
<div class="rule">所有跨进程 / 外部依赖(原生隧道、HTTP、子进程、存储、<b>时钟、随机</b>)藏在<b>接口 / 可注入构造</b>之后;业务逻辑里禁止直接 <code>new</code> 或调静态单例。分层<b>单向依赖</b>(token→实现→组件/页面),生成物 <code>*.gen.dart</code> 勿手改。</div>
<div class="kv"><span class="k">测试红利</span><span class="v">L2 集成/交互能换假替身,无需真依赖即可驱动真实路径。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">每个外部依赖都存在 Fake/Mock + 一条 flow 测试;依赖方向 lintdart import / go internal 包边界)。</span></div>
<div class="kv"><span class="k">正 / 反例</span><span class="v"><span class="ex-ok"></span> <code>VpnBridge</code> 接口 → 可换 <code>VpnBridgeMock</code><code>AuthApi(baseUrl, http.Client)</code> 可注入。<span class="ex-bad"></span> <code>NETunnelProviderManager</code> 直接散在 UI / 业务里 new。</span></div>
</div>
<div class="pillar">
<span class="badge">支柱 2 · 契约单源</span>
<div class="rule">每段跨边界数据(<code>pangolin/vpn/stats</code> EventChannel、HTTP JSON、gRPC <code>UsageEntry</code> proto、设计 token)有<b>唯一定义源</b>,两端从它<b>生成或校验</b>,禁止两端各写各的。</div>
<div class="kv"><span class="k">测试红利</span><span class="v">L0 契约快照能守住「字段形状」,接缝两端永不漂移。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">契约快照测试(字段 diff=0)+ codegen 漂移检查(<code>colors_and_type.css</code><code>*.gen.dart</code>、proto→桩,生成物与源一致)。</span></div>
<div class="kv"><span class="k">针对</span><span class="v">这条正是「mac 实时统计恒为 0」的根因解药——两端各写 stats、无单源、无守门。</span></div>
</div>
<div class="pillar">
<span class="badge">支柱 3 · 纯逻辑与 IO 分离</span>
<div class="rule">计算 / 换算 / 状态机是<b>纯函数</b>,不混时间、网络、IO、随机;这些都当「可注入依赖」从外面传,IO 收进薄壳(functional core / imperative shell)。<b>纯核心天然确定 → 不 flaky。</b></div>
<div class="kv"><span class="k">测试红利</span><span class="v">L1 单元无依赖、可重复、毫秒级;确定性是 E2E 不 flaky 的前提。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">覆盖率阈值;纯函数测试<b>零 mock</b>;时钟/随机可注入。</span></div>
<div class="kv"><span class="k">正例</span><span class="v"><code>bytes→速率/时长</code> 格式化、配额计算、<b>时间 Go 端算好传 <code>?</code></b>(不用 MySQL <code>NOW()</code>)、golden <b>字体锁定</b></span></div>
</div>
<div class="pillar">
<span class="badge">支柱 4 · 错误是有类型的值</span>
<div class="rule">错误带<b>类型 / 码</b>、可断言;<b>禁止吞异常、禁止只打日志不返回</b>。失败路径和成功路径一样要可观测、可测。</div>
<div class="kv"><span class="k">测试红利</span><span class="v">测试能断言失败分支(401、超额、连接失败),而不是只测 happy path。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">错误路径用例;lint 禁空 <code>catch {}</code> / 裸吞;错误类型可枚举。</span></div>
<div class="kv"><span class="k">正例</span><span class="v"><code>AuthApiException(statusCode, messageZh, messageEn)</code>——带码、带双语、可断言。</span></div>
</div>
<div class="pillar">
<span class="badge">支柱 5 · 可观测即可断言</span>
<div class="rule">关键状态 / 事件经<b>稳定通道</b>暴露(status / stats stream、结构化日志、<code>/healthz</code><code>/v1/usage</code>),让测试<b>读数值断言,而非截屏猜</b></div>
<div class="kv"><span class="k">测试红利</span><span class="v">L4 端到端能断言:bytes 增长、限流触发、延迟 &gt;0、状态流转。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">每个关键状态有可程序读取的出口;E2E 冒烟据此断言。</span></div>
<div class="kv"><span class="k">正例</span><span class="v"><code>VpnStatusStreamHandler</code> / <code>stats</code> EventChannel / <code>/v1/usage</code> 数值出口。</span></div>
</div>
<h2>支柱 ↔ 测试层 咬合矩阵</h2>
<p>每条支柱直接<b>解锁</b>测试架构里的某些层。这张图就是规范与测试的咬合证明:</p>
<div class="fig">
<p class="cap">图 1 · 五支柱解锁五测试层(● 主要解锁 ○ 间接帮助)</p>
<svg viewBox="0 0 760 380" role="img" aria-label="支柱与测试层咬合矩阵">
<!-- column headers -->
<g text-anchor="middle" font-size="12" font-weight="700" fill="var(--fg)">
<text x="345" y="34">L0</text><text x="345" y="50" fill="var(--accent2)" font-size="11">契约</text>
<text x="440" y="34">L1</text><text x="440" y="50" fill="var(--ok)" font-size="11">单元</text>
<text x="535" y="34">L2</text><text x="535" y="50" fill="var(--accent2)" font-size="11">集成·交互</text>
<text x="630" y="34">L3</text><text x="630" y="50" fill="var(--accent)" font-size="11">视觉</text>
<text x="710" y="34">L4</text><text x="710" y="50" fill="var(--bad)" font-size="11">E2E</text>
</g>
<!-- grid -->
<g stroke="var(--border)" stroke-width="1">
<line x1="300" y1="62" x2="755" y2="62"/>
<line x1="300" y1="122" x2="755" y2="122"/>
<line x1="300" y1="177" x2="755" y2="177"/>
<line x1="300" y1="232" x2="755" y2="232"/>
<line x1="300" y1="287" x2="755" y2="287"/>
<line x1="300" y1="342" x2="755" y2="342"/>
<line x1="300" y1="62" x2="300" y2="342"/>
<line x1="392" y1="62" x2="392" y2="342" stroke-dasharray="2 3"/>
<line x1="487" y1="62" x2="487" y2="342" stroke-dasharray="2 3"/>
<line x1="582" y1="62" x2="582" y2="342" stroke-dasharray="2 3"/>
<line x1="675" y1="62" x2="675" y2="342" stroke-dasharray="2 3"/>
<line x1="755" y1="62" x2="755" y2="342"/>
</g>
<!-- row labels -->
<g font-size="12.5" fill="var(--fg)" font-weight="600">
<text x="14" y="96">支柱 1 · 接缝即接口</text>
<text x="14" y="151">支柱 2 · 契约单源</text>
<text x="14" y="206">支柱 3 · 纯逻辑/IO 分离</text>
<text x="14" y="261">支柱 4 · 错误是值</text>
<text x="14" y="316">支柱 5 · 可观测</text>
</g>
<!-- dots: ● solid (strong), ○ open (weak) -->
<g>
<!-- 支柱1 (y92): L1○ L2● L4○ -->
<circle cx="440" cy="92" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<circle cx="535" cy="92" r="8" fill="var(--accent2)"/>
<circle cx="710" cy="92" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱2 (y147): L0● L3○ -->
<circle cx="345" cy="147" r="8" fill="var(--accent2)"/>
<circle cx="630" cy="147" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱3 (y202): L1● L2○ L3○ -->
<circle cx="440" cy="202" r="8" fill="var(--accent2)"/>
<circle cx="535" cy="202" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<circle cx="630" cy="202" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱4 (y257): L1● L2● L4○ -->
<circle cx="440" cy="257" r="8" fill="var(--accent2)"/>
<circle cx="535" cy="257" r="8" fill="var(--accent2)"/>
<circle cx="710" cy="257" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱5 (y312): L2○ L4● -->
<circle cx="535" cy="312" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<circle cx="710" cy="312" r="8" fill="var(--accent2)"/>
</g>
<!-- legend -->
<g font-size="11.5" fill="var(--fg2)">
<circle cx="312" cy="366" r="7" fill="var(--accent2)"/><text x="326" y="370">主要解锁</text>
<circle cx="430" cy="366" r="6" fill="none" stroke="var(--accent2)" stroke-width="2"/><text x="444" y="370">间接帮助</text>
</g>
</svg>
</div>
<p class="note">读法:补齐<b>支柱 2</b>(契约单源)就点亮 <b>L0</b>;做好<b>支柱 1</b> 才有 <b>L2</b> 的假桥;<b>支柱 5</b><b>L4</b> 端到端能断言的唯一前提。规范不是道德要求,是测试能力的开关。</p>
<h2>反例 → 真实 bug → 本可由哪条支柱防住</h2>
<p>规范的说服力来自真实的坑。下表把项目里遇到/潜在的问题,回溯到被违反的支柱:</p>
<table>
<thead><tr><th style="width:26%">现象 / 真实 bug</th><th style="width:38%">根因(违反了什么)</th><th style="width:36%">本可由哪条支柱防住</th></tr></thead>
<tbody>
<tr>
<td><b>mac 实时统计恒为 0</b></td>
<td>原生侧产出、Dart 侧消费 <code>stats</code>,两端各写、无单源;触发条件一变即静默失效,无任何测试守门。</td>
<td><span class="tag t-bad">支柱 2 契约单源</span> + <span class="tag t-bad">支柱 5 可观测</span></td>
</tr>
<tr>
<td><b>延迟显示 stale / 拿不到</b><br><span class="small">Android·Windows urltest</span></td>
<td>延迟值没有稳定出口、没有契约校验,靠各端自行解析、刷新时机不一致。</td>
<td><span class="tag t-bad">支柱 5 可观测</span> + <span class="tag t-bad">支柱 2 契约单源</span></td>
</tr>
<tr>
<td><b>记账若用 MySQL 专属时间函数</b></td>
<td>时间/方言混进 SQL,无法在 SQLite 真库测、不可移植。</td>
<td><span class="tag t-bad">支柱 3 纯逻辑/IO 分离</span><br><span class="small">时间 Go 端算、方言走 dialect</span></td>
</tr>
<tr>
<td><b>连接交互测不了</b><br><span class="small">(通用风险)</span></td>
<td>连接逻辑直接依赖 <code>NETunnelProviderManager</code> / 真子进程,不连真隧道就无法驱动。</td>
<td><span class="tag t-bad">支柱 1 接缝即接口</span><br><span class="small">藏到 VpnBridge 后面</span></td>
</tr>
<tr>
<td><b>失败路径不可测 / UI 卡死</b><br><span class="small">(通用风险)</span></td>
<td>连接失败只打日志不返回、空 <code>catch</code> 吞异常,调用方拿不到可断言的错误。</td>
<td><span class="tag t-bad">支柱 4 错误是有类型的值</span></td>
</tr>
</tbody>
</table>
<h2>执行机制 · 四道闸</h2>
<p>规范只靠文档一定漂。执行 = 尽量把每条往<b>机器自动守门</b>推,推不动的用<b>脚手架降低犯错成本</b> + <b>评审清单兜底</b>。这不是一个动作,是从「写之前」卡到「push」的<b>四道闸</b>——越早拦,成本越低。</p>
<div class="fig">
<p class="cap">图 2 · 四道闸:从「写之前」预防到「push」硬闸,外加评审兜底</p>
<svg viewBox="0 0 760 250" role="img" aria-label="规范执行四道闸时间线">
<text x="20" y="18" font-size="11" fill="var(--fg2)">← 越早拦,成本越低</text>
<g text-anchor="middle" font-size="11.5" fill="var(--fg2)" font-weight="700">
<text x="95" y="42">写之前</text>
<text x="267" y="42">写的时候</text>
<text x="439" y="42">提交时</text>
<text x="638" y="42">push · PR</text>
</g>
<!-- gates -->
<rect x="20" y="58" width="150" height="112" rx="10" fill="rgba(94,194,122,.10)" stroke="var(--ok)" stroke-width="1.5"/>
<rect x="192" y="58" width="150" height="112" rx="10" fill="rgba(95,176,201,.10)" stroke="var(--accent2)" stroke-width="1.5"/>
<rect x="364" y="58" width="150" height="112" rx="10" fill="rgba(224,184,79,.10)" stroke="var(--warn)" stroke-width="1.5"/>
<rect x="536" y="58" width="204" height="112" rx="10" fill="rgba(224,106,106,.10)" stroke="var(--bad)" stroke-width="1.5"/>
<!-- gate texts -->
<g text-anchor="middle">
<text x="95" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 1 · 脚手架</text>
<text x="95" y="108" font-size="10.5" fill="var(--fg2)">接口 + Fake 骨架</text>
<text x="95" y="125" font-size="10.5" fill="var(--fg2)">codegen 默认</text>
<text x="95" y="155" font-size="11" font-weight="700" fill="var(--ok)">预防</text>
<text x="267" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 2 · lint/analyze</text>
<text x="267" y="108" font-size="10.5" fill="var(--fg2)">空 catch · import 方向</text>
<text x="267" y="125" font-size="10.5" fill="var(--fg2)">IDE 即时报</text>
<text x="267" y="155" font-size="11" font-weight="700" fill="var(--accent2)">即时</text>
<text x="439" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 3 · pre-commit</text>
<text x="439" y="108" font-size="10.5" fill="var(--fg2)">format · 快检</text>
<text x="439" y="125" font-size="10.5" fill="var(--fg2)">codegen 漂移检查</text>
<text x="439" y="155" font-size="11" font-weight="700" fill="var(--warn)">本地拦</text>
<text x="638" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 4 · CI 硬闸</text>
<text x="638" y="108" font-size="10.5" fill="var(--fg2)">全量测试 · 契约快照</text>
<text x="638" y="125" font-size="10.5" fill="var(--fg2)">覆盖率 · golden · E2E</text>
<text x="638" y="155" font-size="11" font-weight="700" fill="var(--bad)">挡 merge</text>
</g>
<!-- arrows -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="var(--fg2)">
<line x1="170" y1="114" x2="190" y2="114"/><polygon points="192,114 182,110 182,118"/>
<line x1="342" y1="114" x2="362" y2="114"/><polygon points="364,114 354,110 354,118"/>
<line x1="514" y1="114" x2="534" y2="114"/><polygon points="536,114 526,110 526,118"/>
</g>
<!-- review bar -->
<rect x="20" y="196" width="720" height="38" rx="8" fill="var(--panel2)" stroke="var(--border)" stroke-width="1"/>
<text x="380" y="219" text-anchor="middle" font-size="11.5" fill="var(--fg2)"><tspan fill="var(--accent)" font-weight="700">评审清单(人治兜底)</tspan> · 机器判不了的:接缝抽象是否合理 / 关键状态有没有留可观测出口</text>
</svg>
</div>
<ul>
<li><b>闸 1 · 脚手架(预防)</b>:让对的写法成默认路径——新模块生成器自带「接口 + Fake 骨架」,token/proto 一律走 codegen。不主动绕,自然合规。</li>
<li><b>闸 2 · lint/analyze(即时)</b><code>flutter analyze</code> + <code>go vet</code> + 自定义规则(空 <code>catch</code>、import 方向、业务层禁直接 new 外部依赖),编辑器当场红线。</li>
<li><b>闸 3 · pre-commit(本地把关)</b>:format + 快检 + 红线词扫描 + codegen 漂移检查(生成物与源不一致即拦,逼你别手改 <code>*.gen.dart</code>)。</li>
<li><b>闸 4 · CI 硬闸(守门,失败挡 merge)</b>:全量测试 + 覆盖率阈值 + 契约快照 diff=0 + 可移植 SQL 扫描 + golden 像素闸 +(按需)E2E。不可绕过的最终防线。</li>
</ul>
<p>诚实区分「能不能机器强制」——能强制的硬卡 CI,强制不了的靠脚手架 + 评审兜底:</p>
<table>
<thead><tr><th style="width:20%">支柱</th><th style="width:40%">主执行手段</th><th style="width:16%">强制度</th><th style="width:24%">现状</th></tr></thead>
<tbody>
<tr><td>1 接缝即接口</td><td>import 方向 lint + 脚手架 + 评审清单</td><td><span class="tag t-warn">半自动</span></td><td><span class="tag t-bad">待补</span></td></tr>
<tr><td>2 契约单源</td><td>契约快照 diff=0 + codegen 漂移检查</td><td><span class="tag t-ok">强制</span></td><td><span class="tag t-bad">待补</span><span class="small">OpenAPI 已有)</span></td></tr>
<tr><td>3 纯逻辑/IO 分离</td><td>覆盖率阈值 + 可移植 SQL 扫描</td><td><span class="tag t-ok">强制</span></td><td><span class="tag t-warn">部分已有</span></td></tr>
<tr><td>4 错误是值</td><td>lint 禁空 catch + 失败路径用例</td><td><span class="tag t-warn">半自动</span></td><td><span class="tag t-bad">待补</span></td></tr>
<tr><td>5 可观测</td><td>关键状态出口评审 + L4 冒烟据出口断言</td><td><span class="tag t-warn">半自动</span></td><td><span class="tag t-bad">随 L4</span></td></tr>
</tbody>
</table>
<p class="note">这个仓库的落点:<code>.gitea/workflows/ci.yml</code>shellcheck、OpenAPI 校验、红线词扫描、<code>flutter analyze + test</code>= 闸 4 骨架已在。<b></b>:契约快照、golden 进 CI、可移植 SQL 扫描、pre-commit hook、自定义 lint、PR checklist。</p>
<h2>可复用:通用原则 vs 本项目实例</h2>
<p>换到你别的多端项目时,<b>五条原则不变</b>,只替换右列实例:</p>
<table>
<thead><tr><th style="width:30%">通用原则</th><th style="width:70%">Pangolin 实例(换项目时替换)</th></tr></thead>
<tbody>
<tr><td>接缝即接口</td><td><code>VpnBridge</code> / <code>KernelProcess</code> / <code>TokenStore</code> / <code>AuthApi(http.Client)</code></td></tr>
<tr><td>契约单源</td><td><code>stats</code> EventChannel · <code>/v1/*</code> JSON · <code>UsageEntry</code> proto · <code>colors_and_type.css</code></td></tr>
<tr><td>纯逻辑/IO 分离</td><td>速率/时长格式化 · 配额计算 · 时间 Go 端算 · dialect 方言层</td></tr>
<tr><td>错误是值</td><td><code>AuthApiException</code> · 控制面错误码</td></tr>
<tr><td>可观测</td><td>status/stats stream · 结构化日志 · <code>/v1/usage</code> · <code>/healthz</code></td></tr>
</tbody>
</table>
<p class="note">本规范为「怎么写」的真相源;「怎么验证」见 <a href="test-architecture.html">测试框架架构说明</a>。两份配套维护,新增接缝时同步更新两边。</p>
</div>
</body>
</html>
+15
View File
@@ -76,8 +76,23 @@
<div class="d">Windows 客户端的分步实现计划。</div>
<div class="path">docs/superpowers/plans/2026-06-21-windows-client.md</div>
</a>
<a class="doc" href="stats-overhaul-plan.html">
<div class="t">统计体系整改 实现计划(#5<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-24-stats-overhaul.md</code>(含 checkbox)。三期:四端实时统计修复 → 每设备归因+GB综合配额 → 统计页重设计(上聚合·下分设备)。</div>
<div class="path">docs/stats-overhaul-plan.html</div>
</a>
<h2>知识库 / 调研</h2>
<a class="doc" href="dev-conventions.html">
<div class="t">开发规范 · 可测试性五支柱 <span class="tag html">HTML</span></div>
<div class="d">「怎么写才好测」——开发规范作为可测试性前置条件。五支柱(接缝即接口/契约单源/纯逻辑分离/错误是值/可观测)+ 支柱↔测试层咬合矩阵图 + 反例→真实bug→对应支柱对照表。与测试框架文档咬合。</div>
<div class="path">docs/dev-conventions.html</div>
</a>
<a class="doc" href="test-architecture.html">
<div class="t">统一测试框架 · 架构说明 <span class="tag html">HTML</span></div>
<div class="d">多端项目可复用的分层测试方法论(L0 契约→L4 E2E)+ 四类诉求(界面一致/交互/数值/后端记账)→层→工具→量化指标映射 + 五张架构图(金字塔/契约接缝/CI 流水线/E2E 拓扑)。</div>
<div class="path">docs/test-architecture.html</div>
</a>
<a class="doc" href="killswitch-design.html">
<div class="t">KillSwitch 设计与跨平台能力矩阵 <span class="tag html">HTML</span></div>
<div class="d">断网保护 L0–L3 分级模型 + 各平台能力天花板 / 当前实现矩阵。KillSwitch 决策依据。</div>
+431
View File
@@ -0,0 +1,431 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin 统一测试框架 · 架构说明</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16.5px;margin:24px 0 8px;color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
a{color:var(--accent2);text-decoration:none}
a:hover{text-decoration:underline}
ul{margin:8px 0;padding-left:22px}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
.note{color:var(--fg2);font-size:13px;border-left:3px solid var(--border);padding-left:12px;margin:10px 0}
.fig{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;margin:18px 0;overflow-x:auto}
.fig .cap{color:var(--fg2);font-size:12.5px;margin:0 0 10px;font-weight:600;letter-spacing:.02em}
.fig svg{display:block;max-width:100%;height:auto}
.phase{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin:14px 0}
.phase .badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;margin-bottom:8px}
.bL0{background:rgba(95,176,201,.16);color:var(--accent2)}
.bL1{background:rgba(94,194,122,.16);color:var(--ok)}
.bL2{background:rgba(95,176,201,.16);color:var(--accent2)}
.bL3{background:rgba(224,136,79,.16);color:var(--accent)}
.bL4{background:rgba(224,106,106,.16);color:var(--bad)}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--panel2);color:var(--fg);font-weight:600}
td{color:var(--fg2)}
td b,td strong{color:var(--fg)}
.tag{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;white-space:nowrap}
.t-ok{background:rgba(94,194,122,.16);color:var(--ok)}
.t-warn{background:rgba(224,184,79,.16);color:var(--warn)}
.t-bad{background:rgba(224,106,106,.16);color:var(--bad)}
.kbd{font-family:var(--mono);font-size:.86em;color:#f0d9c4}
</style>
</head>
<body>
<div class="wrap">
<h1>Pangolin 统一测试框架 · 架构说明</h1>
<p class="sub">分层方法论 + 脚手架模板 + 单一编排入口 · 可自动化 / 可量化 / 可自定义</p>
<div class="lead">
<strong>定性(方案 A):</strong>不存在一个能同时覆盖「界面渲染 / 交互驱动 / 后端记账 / 真链路端到端」的单一工具——这四者运行时根本不同。所以「一套测试框架」= 一套<b>分层方法论</b> + 一组<b>可复用脚手架模板</b>(假桥、Provider 覆盖、golden+字体锁、Go 真库套、E2E compose 骨架)+ 一个<b>统一编排入口</b>。跨多端项目的复用方式是<b>模板复制后按接缝适配</b>,而非全项目 import 同一个包。
<div class="small" style="margin-top:8px">↔ 配套文档:<a href="dev-conventions.html">开发规范 · 可测试性五支柱</a>(「怎么写才好测」——测试能力的前置条件)</div>
</div>
<h2>设计原则</h2>
<ul>
<li><b>测试金字塔</b>:底层用例多、快、稳、便宜;越往上越接近真实、越慢、越脆。绝大多数断言压在底部,顶部只留薄薄一层冒烟。</li>
<li><b>契约接缝(contract seam)优先</b>:系统由若干跨进程/跨语言的接缝拼成(原生→Dart、Dart→控制面、agent→控制面)。每段接缝先有<b>契约快照</b>守住「字段形状」,再分层在接缝两侧各自下刀。</li>
<li><b>量化优先</b>:每层都产出可比对的数字(覆盖率、像素不一致比例、数值容差、时延 ms、通过率),而非「人眼看着对」。</li>
<li><b>单一编排</b>:一个入口(<code class="kbd">make test</code> / <code class="kbd">test.sh</code>)按 profile 跑对应层,输出一份合并报告。</li>
</ul>
<p class="note">前不久的「mac 实时统计恒为 0」就是典型反例:原生侧产出统计、Dart 侧消费统计,两端的 <code>pangolin/vpn/stats</code> 契约没有任何自动化测试守门,触发条件一变(隧道已连接才启动 app)就静默失效。<b>L0 契约层防的正是这类 bug。</b></p>
<h2>① 分层测试金字塔</h2>
<div class="fig">
<p class="cap">图 1 · 五层金字塔:L0 契约为地基,L1→L4 自底向上</p>
<svg viewBox="0 0 860 400" role="img" aria-label="测试金字塔分层图">
<!-- pyramid bands -->
<polygon points="430,46 360,112 500,112" fill="rgba(224,106,106,.20)" stroke="var(--bad)" stroke-width="1.5"/>
<polygon points="360,112 500,112 575,178 285,178" fill="rgba(224,136,79,.18)" stroke="var(--accent)" stroke-width="1.5"/>
<polygon points="285,178 575,178 650,244 210,244" fill="rgba(95,176,201,.16)" stroke="var(--accent2)" stroke-width="1.5"/>
<polygon points="210,244 650,244 725,310 135,310" fill="rgba(94,194,122,.16)" stroke="var(--ok)" stroke-width="1.5"/>
<!-- foundation L0 -->
<rect x="135" y="324" width="590" height="48" rx="8" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<!-- labels -->
<text x="430" y="96" text-anchor="middle" fill="var(--fg)" font-size="13" font-weight="700">L4 E2E</text>
<text x="430" y="152" text-anchor="middle" fill="var(--fg)" font-size="14" font-weight="700">L3 视觉一致性</text>
<text x="430" y="166" text-anchor="middle" fill="var(--fg2)" font-size="11">golden · pixelmatch</text>
<text x="430" y="212" text-anchor="middle" fill="var(--fg)" font-size="14" font-weight="700">L2 集成 · 交互</text>
<text x="430" y="226" text-anchor="middle" fill="var(--fg2)" font-size="11">flutter flow · httptest · bufconn · 真库</text>
<text x="430" y="280" text-anchor="middle" fill="var(--fg)" font-size="14" font-weight="700">L1 单元</text>
<text x="430" y="294" text-anchor="middle" fill="var(--fg2)" font-size="11">flutter_test · go test+testify(纯逻辑)</text>
<text x="430" y="346" text-anchor="middle" fill="var(--accent2)" font-size="13" font-weight="700">L0 契约 / 静态闸 · 地基</text>
<text x="430" y="363" text-anchor="middle" fill="var(--fg2)" font-size="11">契约快照 · OpenAPI 校验 · analyze/vet · 红线词扫描</text>
<!-- L4 callout (apex too small for text) -->
<line x1="500" y1="79" x2="700" y2="79" stroke="var(--border)" stroke-width="1"/>
<text x="705" y="76" fill="var(--bad)" font-size="11.5" font-weight="700">L4 E2E</text>
<text x="705" y="91" fill="var(--fg2)" font-size="10.5">docker-compose 真链路冒烟</text>
<!-- right axis arrows -->
<line x1="800" y1="60" x2="800" y2="310" stroke="var(--border)" stroke-width="1.5"/>
<polygon points="800,310 796,300 804,300" fill="var(--fg2)"/>
<polygon points="800,60 796,70 804,70" fill="var(--fg2)"/>
<text x="818" y="120" fill="var(--fg2)" font-size="10.5" transform="rotate(90 818,120)">越往上:越真实·越慢·越脆·越少</text>
<text x="836" y="250" fill="var(--fg2)" font-size="10.5" transform="rotate(90 836,250)">越往下:越多·越快·越稳·越省</text>
</svg>
</div>
<h2>② 契约接缝数据流</h2>
<p>统计/记账数据沿一条跨进程链路流动。每段接缝标注其<b>契约</b>,以及<b>哪一层测试在此下刀</b>——这张图就是「该测什么、在哪测」的地图。</p>
<div class="fig">
<p class="cap">图 2 · 契约接缝:从节点数据面一路回到客户端 UI</p>
<svg viewBox="0 0 860 250" role="img" aria-label="契约接缝数据流图">
<!-- boxes -->
<g font-size="12.5" font-weight="700">
<rect x="8" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="83" y="99" text-anchor="middle" fill="var(--fg)">原生隧道 / libbox</text>
<text x="83" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">sing-box(客户端)</text>
<rect x="186" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="261" y="99" text-anchor="middle" fill="var(--fg)">Flutter Dart 层</text>
<text x="261" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">UI / Riverpod</text>
<rect x="364" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="439" y="99" text-anchor="middle" fill="var(--fg)">Go 控制面</text>
<text x="439" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">:8080 HTTP + DB</text>
<rect x="542" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="617" y="99" text-anchor="middle" fill="var(--fg)">agent</text>
<text x="617" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">:9443 gRPC mTLS</text>
<rect x="720" y="74" width="132" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="786" y="99" text-anchor="middle" fill="var(--fg)">节点数据面</text>
<text x="786" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">sing-box 出网</text>
</g>
<!-- arrows between -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="var(--fg2)">
<line x1="186" y1="103" x2="160" y2="103"/><polygon points="158,103 168,99 168,107"/>
<line x1="364" y1="103" x2="338" y2="103"/><polygon points="336,103 346,99 346,107"/>
<line x1="542" y1="103" x2="516" y2="103"/><polygon points="514,103 524,99 524,107"/>
<line x1="720" y1="103" x2="694" y2="103"/><polygon points="692,103 702,99 702,107"/>
</g>
<!-- contract labels (above) -->
<g font-size="10.5" fill="var(--accent2)" text-anchor="middle" font-weight="700">
<text x="172" y="58">① stats EventChannel</text>
<text x="350" y="58">② /v1/* HTTP JSON</text>
<text x="528" y="58">③ UsageEntry gRPC</text>
<text x="707" y="58">④ V2Ray stats</text>
</g>
<!-- which test cuts here (below) -->
<g font-size="10" text-anchor="middle">
<rect x="112" y="150" width="120" height="40" rx="6" fill="rgba(94,194,122,.12)" stroke="var(--ok)" stroke-width="1"/>
<text x="172" y="166" fill="var(--ok)" font-weight="700">L0 契约 + L2 flow</text>
<text x="172" y="180" fill="var(--fg2)">VpnBridgeMock</text>
<rect x="290" y="150" width="120" height="40" rx="6" fill="rgba(94,194,122,.12)" stroke="var(--ok)" stroke-width="1"/>
<text x="350" y="166" fill="var(--ok)" font-weight="700">L2 httptest</text>
<text x="350" y="180" fill="var(--fg2)">ApiClient 注入</text>
<rect x="468" y="150" width="120" height="40" rx="6" fill="rgba(94,194,122,.12)" stroke="var(--ok)" stroke-width="1"/>
<text x="528" y="166" fill="var(--ok)" font-weight="700">L2 bufconn ✓强</text>
<text x="528" y="180" fill="var(--fg2)">自签 CA/CRL</text>
<rect x="646" y="150" width="122" height="40" rx="6" fill="rgba(224,106,106,.12)" stroke="var(--bad)" stroke-width="1"/>
<text x="707" y="166" fill="var(--bad)" font-weight="700">L4 真流量 ✗缺</text>
<text x="707" y="180" fill="var(--fg2)">E2E 待建</text>
</g>
<!-- connector ticks -->
<g stroke="var(--border)" stroke-width="1" stroke-dasharray="3 3">
<line x1="172" y1="132" x2="172" y2="150"/>
<line x1="350" y1="132" x2="350" y2="150"/>
<line x1="528" y1="132" x2="528" y2="150"/>
<line x1="707" y1="132" x2="707" y2="150"/>
</g>
</svg>
</div>
<p class="note">实时上下行走 ①(libbox→EventChannel→Dart);累计流量/周柱走 ④→③→DB→②(节点统计→agent→控制面入库→客户端拉取)。两条流共用同一张接缝图,测试各自归位。</p>
<h2>③ 四类诉求 → 层 → 工具 → 量化指标</h2>
<p>把你提出的四类测试诉求逐条落到层、工具、量化指标与现状:</p>
<table>
<thead><tr><th style="width:20%">诉求</th><th style="width:8%"></th><th style="width:30%">工具</th><th style="width:26%">量化指标</th><th style="width:16%">现状</th></tr></thead>
<tbody>
<tr>
<td><b>① 界面设计一致</b><br><span class="small">每端一致</span></td>
<td>L3</td>
<td>Flutter <b>golden</b>(字体锁定,host 无关→天然每端一致);design-distill <b>pixelmatch</b> 对照 HTML 原型真相源</td>
<td>像素不一致比例 vs 阈值 <code>0.05</code>golden 通过/失败</td>
<td><span class="tag t-warn">素材齐 · CI 没跑</span></td>
</tr>
<tr>
<td><b>② 前端交互</b><br><span class="small">连接 / 登陆 / 登出</span></td>
<td>L2</td>
<td><b>flutter_test 进程内驱动整树</b> + VpnBridgeMock + http MockClient + ProviderScope 覆盖</td>
<td>流程通过/失败;覆盖率 lcov</td>
<td><span class="tag t-warn">素材齐 · 待组装</span></td>
</tr>
<tr>
<td><b>③ 数值型</b><br><span class="small">统计 / 实时速度 / 时延</span></td>
<td>L1 + L4</td>
<td>L1:格式化/换算纯逻辑单测;L4:连真节点拉 stats 断言 bytes 增长、延迟 &gt;0</td>
<td>实测 vs 期望<b>容差</b>;时延 ms;速率 B/s &gt;0</td>
<td><span class="tag t-warn">L1 可补</span> <span class="tag t-bad">L4 缺</span></td>
</tr>
<tr>
<td><b>④ 后端网络</b><br><span class="small">按租户流量 / 限流</span></td>
<td>L2 + L4</td>
<td>L2SQLite 真库 + testcontainers MySQL(已覆盖并发/重放/跨日/配额);L4:全链路真流量计数</td>
<td>记账数值断言;限流是否触发;覆盖率 go cover</td>
<td><span class="tag t-ok">L2 强</span> <span class="tag t-bad">L4 缺</span></td>
</tr>
</tbody>
</table>
<h2>④ 逐层详解</h2>
<div class="phase">
<span class="badge bL0">L0 · 契约 / 静态闸</span>
<h3 style="margin-top:0">守住接缝的「字段形状」</h3>
<ul>
<li><b>测什么</b>:跨进程契约的结构——<code>pangolin/vpn/stats</code> EventChannel 字段、gRPC <code>UsageEntry</code> proto、<code>/v1/me</code>·<code>/v1/usage</code> JSON 形态;静态:analyze/vet、OpenAPI 结构、红线词。</li>
<li><b>工具</b>:契约快照测试(snapshot+ 已有 OpenAPI 校验 + <code>ci/scan-redline.sh</code></li>
<li><b>量化</b>:字段 diff = 0;扫描命中 = 0。</li>
<li><b>现状/缺口</b><span class="tag t-ok">OpenAPI/红线已有</span> <span class="tag t-warn">契约快照需补</span><b>这是最被低估、性价比最高的一层。</b></li>
<li><b>可自定义</b>:新增一段接缝 = 加一份快照基线。</li>
</ul>
</div>
<div class="phase">
<span class="badge bL1">L1 · 单元</span>
<h3 style="margin-top:0">纯逻辑,最快最稳</h3>
<ul>
<li><b>测什么</b>:数值换算(bytes→速率/时长格式化)、状态机(ConnectionController)、记账纯函数(GB 卡控、dp_uuid 解析)。</li>
<li><b>工具</b><code>flutter_test</code><code>go test</code> + testify。</li>
<li><b>量化</b>:覆盖率(lcov / <code>go test -cover</code>)。</li>
<li><b>现状</b><span class="tag t-ok">已有</span>(前端 controller、后端 usage 包多场景)。</li>
</ul>
</div>
<div class="phase">
<span class="badge bL2">L2 · 集成 / 交互</span>
<h3 style="margin-top:0">带假替身的真实路径</h3>
<ul>
<li><b>前端交互</b><code>pumpWidget(真 App 树)</code> 驱动 登陆→重启续登→点连接→状态流转→登出,把原生隧道换 <b>VpnBridgeMock</b>、服务端换 <b>MockClient</b><code>ProviderScope</code> 覆盖注入。</li>
<li><b>后端 DB</b>SQLite 内存真库 + testcontainers MySQL/Redis(已覆盖并发去重/重放/跨日分桶/配额)。</li>
<li><b>后端 HTTP/gRPC</b><code>httptest.Server</code>(鉴权路径需补);gRPC <code>bufconn</code> + 自签 CA/CRL(已强)。</li>
<li><b>量化</b>:通过/失败 + 覆盖率 + 数值断言。</li>
<li><b>现状</b><span class="tag t-ok">后端强</span> <span class="tag t-warn">前端 flow 待组装 · HTTP 鉴权偏薄</span></li>
</ul>
</div>
<div class="phase">
<span class="badge bL3">L3 · 视觉一致性</span>
<h3 style="margin-top:0">每端渲染一致 + 对照设计真相源</h3>
<ul>
<li><b>测什么</b>mobile/tablet/desktop × light/dark × zh/en 的渲染一致;以及实现 vs HTML 原型的像素差。</li>
<li><b>工具</b>Flutter golden<code>flutter_test_config.dart</code> 字体锁定→host 无关,所以「每端效果一致」天然成立);design-distill <code>shoot-prototype.mjs</code> + <code>diff.mjs</code>pixelmatch)。</li>
<li><b>量化</b>:像素不一致比例 vs 阈值 <code>0.05</code></li>
<li><b>现状</b><span class="tag t-ok">20+ golden 基准已有</span> <span class="tag t-warn">CI 未接 · pixel 闸未自动化</span></li>
<li><b>可自定义</b>:新增屏 = 加一张基线;阈值按屏可调。</li>
</ul>
</div>
<div class="phase">
<span class="badge bL4">L4 · 端到端冒烟</span>
<h3 style="margin-top:0">真链路:统计真不真 / 流量真不真</h3>
<ul>
<li><b>测什么</b>:起真控制面 + agent + sing-box + DB,发真流量,断言按租户/按设备 bytes 入库、限流触发、实时数值 &gt;0。</li>
<li><b>工具</b>docker-compose + 探针驱动脚本(见图 5)。</li>
<li><b>量化</b>:实测 vs 期望容差、限流是否触发、连上耗时、时延 ms。</li>
<li><b>现状</b><span class="tag t-bad">完全缺——最大缺口</span><code>local_test.sh</code> 只构建启动、<code>deploy.sh</code> 只部署,均无连通性自检。</li>
<li><b>边界(建议)</b>:先做 <b>(a) 后端全链路</b>(可全自动、进一台 Linux runner);<b>(b) desktop 真连</b>(需常驻 Mac)与 <b>(c) iOS/Android 真机</b> 作本地按需后续。</li>
</ul>
</div>
<h2>⑤ 编排与 CI 流水线</h2>
<p>一个入口按 <b>profile</b> 分流:CI 每 push 只跑快而稳的 <code>fast</code>;视觉与 E2E 按需触发。</p>
<div class="fig">
<p class="cap">图 4 · 单一入口 → 三 profile 泳道</p>
<svg viewBox="0 0 860 270" role="img" aria-label="编排与 CI 流水线图">
<!-- entry -->
<rect x="10" y="105" width="150" height="58" rx="10" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="85" y="130" text-anchor="middle" fill="var(--fg)" font-size="13" font-weight="700">单一入口</text>
<text x="85" y="147" text-anchor="middle" fill="var(--fg2)" font-size="11">make test / test.sh</text>
<!-- branch lines -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="none">
<path d="M160,120 C195,120 195,48 230,48"/>
<path d="M160,134 L230,134"/>
<path d="M160,148 C195,148 195,220 230,220"/>
</g>
<!-- lane fast -->
<rect x="230" y="22" width="300" height="52" rx="9" fill="rgba(94,194,122,.10)" stroke="var(--ok)" stroke-width="1.5"/>
<text x="246" y="42" fill="var(--ok)" font-size="12.5" font-weight="700">fast</text>
<text x="246" y="60" fill="var(--fg2)" font-size="11">L0 + L1 + L2(假桥/真库)</text>
<rect x="556" y="22" width="294" height="52" rx="9" fill="var(--panel)" stroke="var(--border)" stroke-width="1"/>
<text x="570" y="42" fill="var(--fg)" font-size="11.5" font-weight="700">触发:每次 pushnas CI</text>
<text x="570" y="60" fill="var(--fg2)" font-size="11">产物:通过/失败 + lcov 覆盖率</text>
<!-- lane visual -->
<rect x="230" y="108" width="300" height="52" rx="9" fill="rgba(224,136,79,.10)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="246" y="128" fill="var(--accent)" font-size="12.5" font-weight="700">visual</text>
<text x="246" y="146" fill="var(--fg2)" font-size="11">L3 golden + pixelmatch</text>
<rect x="556" y="108" width="294" height="52" rx="9" fill="var(--panel)" stroke="var(--border)" stroke-width="1"/>
<text x="570" y="128" fill="var(--fg)" font-size="11.5" font-weight="700">触发:push 或本地</text>
<text x="570" y="146" fill="var(--fg2)" font-size="11">产物:pixel diff 报告(HTML</text>
<!-- lane e2e -->
<rect x="230" y="194" width="300" height="52" rx="9" fill="rgba(224,106,106,.10)" stroke="var(--bad)" stroke-width="1.5"/>
<text x="246" y="214" fill="var(--bad)" font-size="12.5" font-weight="700">e2e</text>
<text x="246" y="232" fill="var(--fg2)" font-size="11">L4 真链路冒烟</text>
<rect x="556" y="194" width="294" height="52" rx="9" fill="var(--panel)" stroke="var(--border)" stroke-width="1"/>
<text x="570" y="214" fill="var(--fg)" font-size="11.5" font-weight="700">触发:本地 / 按需(docker±Mac</text>
<text x="570" y="232" fill="var(--fg2)" font-size="11">产物:数值容差报告</text>
<!-- arrows lane->product -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="var(--fg2)">
<line x1="530" y1="48" x2="552" y2="48"/><polygon points="556,48 546,44 546,52"/>
<line x1="530" y1="134" x2="552" y2="134"/><polygon points="556,134 546,130 546,138"/>
<line x1="530" y1="220" x2="552" y2="220"/><polygon points="556,220 546,216 546,224"/>
</g>
</svg>
</div>
<h2>⑥ L4 端到端冒烟拓扑</h2>
<div class="fig">
<p class="cap">图 5 · docker-compose 真链路 + 测试驱动器(推荐边界 a)</p>
<svg viewBox="0 0 860 280" role="img" aria-label="端到端冒烟拓扑图">
<!-- driver -->
<rect x="10" y="60" width="170" height="160" rx="10" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="95" y="84" text-anchor="middle" fill="var(--fg)" font-size="13" font-weight="700">测试驱动器</text>
<g font-size="11" fill="var(--fg2)">
<text x="26" y="110">① 登陆 /v1/auth/login</text>
<text x="26" y="134">② 发起连接(真凭证)</text>
<text x="26" y="158">③ 发真流量(curl/iperf</text>
<text x="26" y="182">④ 拉 /v1/usage</text>
<text x="26" y="206" fill="var(--accent2)" font-weight="700">⑤ 断言 bytes↑ / 限流</text>
</g>
<!-- compose container -->
<rect x="250" y="28" width="600" height="224" rx="12" fill="none" stroke="var(--border)" stroke-width="1.5" stroke-dasharray="6 4"/>
<text x="266" y="48" fill="var(--fg2)" font-size="11.5" font-weight="700">docker-compose · 一台 Linux runner</text>
<g font-size="12" font-weight="700">
<rect x="276" y="70" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="356" y="93" text-anchor="middle" fill="var(--fg)">控制面 :8080</text>
<text x="356" y="110" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">HTTP + gRPC :9443</text>
<rect x="276" y="160" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--ok)" stroke-width="1.5"/>
<text x="356" y="183" text-anchor="middle" fill="var(--fg)">SQLite + Redis</text>
<text x="356" y="200" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">记账存储</text>
<rect x="500" y="70" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="580" y="93" text-anchor="middle" fill="var(--fg)">agent</text>
<text x="580" y="110" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">mTLS enroll · 上报</text>
<rect x="500" y="160" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="580" y="183" text-anchor="middle" fill="var(--fg)">sing-box</text>
<text x="580" y="200" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">数据面出网</text>
<rect x="700" y="115" width="130" height="56" rx="9" fill="var(--panel)" stroke="var(--warn)" stroke-width="1.5"/>
<text x="765" y="138" text-anchor="middle" fill="var(--fg)" font-size="11.5">回环出口</text>
<text x="765" y="155" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">httpbin/echo</text>
</g>
<!-- internal links -->
<g stroke="var(--fg2)" stroke-width="1.4" fill="var(--fg2)">
<line x1="356" y1="126" x2="356" y2="160"/><polygon points="356,160 352,150 360,150"/>
<line x1="500" y1="98" x2="436" y2="98"/><polygon points="436,98 446,94 446,102"/>
<line x1="580" y1="126" x2="580" y2="160"/><polygon points="580,160 576,150 584,150"/>
<line x1="700" y1="143" x2="664" y2="180" stroke-dasharray="4 3"/>
</g>
<!-- driver -> control plane -->
<g stroke="var(--accent)" stroke-width="1.6" fill="var(--accent)">
<line x1="180" y1="110" x2="274" y2="96"/><polygon points="276,96 266,92 266,101"/>
</g>
<text x="200" y="92" fill="var(--accent)" font-size="10">① ② ④</text>
<!-- driver traffic to data plane -->
<g stroke="var(--accent2)" stroke-width="1.4" fill="var(--accent2)" stroke-dasharray="5 3">
<line x1="180" y1="175" x2="498" y2="188"/>
</g>
<text x="300" y="245" fill="var(--accent2)" font-size="10">③ 真流量经数据面出网 → 计数</text>
</svg>
</div>
<h2>⑦ 量化指标总表</h2>
<table>
<thead><tr><th style="width:24%">维度</th><th style="width:30%">指标</th><th style="width:46%">工具 / 口径</th></tr></thead>
<tbody>
<tr><td>覆盖率</td><td>行/分支 %</td><td><code>flutter test --coverage</code> + <code>go test -coverprofile</code> → 合并 lcov</td></tr>
<tr><td>视觉</td><td>不一致像素比例 vs 阈值 0.05</td><td>pixelmatchdiff.mjs);golden 通过/失败</td></tr>
<tr><td>数值精度</td><td>实测 vs 期望容差</td><td>L4 断言 bytes/速率/累计落在容差带内</td></tr>
<tr><td>时延</td><td>毫秒 ms</td><td>urltest 延迟 &gt;0 且在合理区间</td></tr>
<tr><td>稳定性</td><td>通过率 / flaky 率</td><td>多次重跑统计;flaky 用例隔离</td></tr>
</tbody>
</table>
<h2>⑧ 可复用与可自定义</h2>
<p><b>跨项目模板(复制即用,按接缝微调)</b></p>
<ul>
<li>假桥模式(<code>VpnBridge</code> 接口 + Mock)→ 任何「原生/外部依赖」都可照此切假。</li>
<li>ProviderScope/DI 覆盖注入 → 任何状态层都可注入假数据。</li>
<li>golden + 字体锁定(<code>flutter_test_config.dart</code>)→ 视觉回归通用骨架。</li>
<li>Go 内存真库套(<code>run_sqlite_test.sh</code>+ testcontainers 集成套 → 任何 DB 项目通用。</li>
<li>E2E docker-compose 骨架 + 探针驱动器 → 换服务清单即复用。</li>
<li>单一编排器 + profile 分流 + 合并报告 → 框架的「壳」,跨项目不变。</li>
</ul>
<p><b>每项目按接缝定制</b>:契约字段、API 路径、compose 服务清单、golden 屏清单、数值容差阈值。</p>
<h2>⑨ 落地路线(建议)</h2>
<table>
<thead><tr><th style="width:8%"></th><th style="width:42%">动作</th><th style="width:24%">性价比</th><th style="width:26%">起点</th></tr></thead>
<tbody>
<tr><td>1</td><td><b>L0 契约快照</b> + 把现有 <b>golden 接进 CI</b></td><td><span class="tag t-ok">最高(防回归)</span></td><td>素材已有,组装即可</td></tr>
<tr><td>2</td><td>组装 <b>L2 前端 flow 测试</b>(登陆/续登/连接/登出)</td><td><span class="tag t-ok"></span></td><td>Mock/覆盖素材全齐</td></tr>
<tr><td>3</td><td><b>L4 后端全链路冒烟 (a)</b></td><td><span class="tag t-warn">中(覆盖"真不真"</span></td><td>需新建 compose + 驱动器</td></tr>
<tr><td>4</td><td>补 L1 数值单测 + L2 HTTP 鉴权测试</td><td><span class="tag t-warn"></span></td><td>增量补齐</td></tr>
<tr><td>5</td><td>(b) desktop 真连 / (c) 真机——本地按需</td><td><span class="tag t-bad">低(贵·脆)</span></td><td>需常驻 Mac / 真机</td></tr>
</tbody>
</table>
<p class="note">本文档仅为架构说明(设计真相源)。具体落地拆解(契约快照实现、前端 flow 用例、L4 冒烟脚本)走各自实现计划(writing-plans)。</p>
</div>
</body>
</html>