# 功能验证清单（开发期累积，统一验收）

> **定位**：各 Phase 实施期间陆续累积的验证项目，本文档**不是契约**（契约在 `H5-Native-Contract.md` §10），而是面向开发者的**指导步骤**——告诉验证者"应该做什么操作、看到什么现象、用什么工具"。
>
> **使用方式**：
> - 每完成一个 Phase 子项 → 把对应验证步骤勾掉
> - **不要在 Phase 进行中频繁手动验证**，避免反复重装 app 浪费时间
> - 多个 Phase 累积后**统一跑一次**完整验证清单
> - 与 Contract §10 契约验收清单**并行**：Contract §10 是黑盒契约边界（"必须长这样"），本文档是**怎么验证它长这样**的操作手册

---

## 通用准备

### 环境

| 工具 | 用途 | 启用方式 |
|------|------|---------|
| Xcode console | 看原生 os.log 输出 | Xcode 底部输出窗口 |
| Safari Web Inspector | 看 H5 端 JS console / DOM / Network | macOS Safari → 开发菜单 → Simulator / 真机 → 当前 H5 页面 |
| 模拟器 Features 菜单 | 切换 Battery State / Network Link Conditioner / Toggle In-Call Status | Simulator → Features |
| 真机 Safari 调试 | 真机 H5 网页检查器 | iPhone → 设置 → Safari → 高级 → 网页检查器 = ON |

### 重装 app（每次跑完整验证前都做一遍）

按 CLAUDE.md「typical case 1：launch snapshot 缓存粘滞」教训：
- 模拟器/真机：**长按 app → 删除** → Xcode 重新 Run
- 或：`xcrun simctl uninstall booted com.skyapp.ylgamehall` 然后 Xcode Run
- 这样可以清掉 iOS sandbox 内的 launch snapshot 缓存，避免改了 Info.plist / LaunchScreen 后还是看到旧版本

### 沙盒访问

```bash
xcrun simctl get_app_container booted com.skyapp.ylgamehall data
# 输出形如 /Users/.../Containers/Data/Application/<UUID>/
```

### 真机 vs 模拟器跑哪些

模拟器（M Mac 当前）能跑的：Phase 1 / 2 / 3.A / 6 / 7 / 9.2 / 部分 4（QQ/抖音 URL Scheme 调起需要设备装 app 才有意义）。

**必须真机的章节**（M Mac 模拟器跑不起来或行为不可靠）：

| 章节 | 原因 |
|---|---|
| §J / §K / §O 微信 | 微信 SDK 在模拟器无法真实拉起微信 app |
| §L / §M / §N SharePanel + QQ + 抖音 | 模拟器没装 QQ / 抖音 app，URL Scheme 调起失败 |
| §P 高德定位 | AMap fat .framework 在 M Mac 模拟器链接失败（参 `Vendor/AMap/README.md`）+ 模拟器定位精度低 |
| §T phonestate | CXCallObserver 在模拟器无效，必须真机 |
| §I.5.3 七牛上传真机 | 上传 URL 公网可达验证 |

建议路径：先在模拟器跑完 Phase 1 / 2 / 6 / 7 / 9.2 大头（覆盖率最高），再单独真机跑 §J~§T 一遍。

---

## Phase 1 — 启动闭环

### A. 启动图 + LaunchScreen

- [ ] **A.1 启动图方向正确**：横屏设备启动瞬间看到完整 logo + "快乐游戏 / 健康生活" 文字，画面正向（不是横躺）
- [ ] **A.2 状态栏方向**：电池/时间在画面顶部居中横向，**不在**画面左侧竖向（竖向 = iOS 错误地按 portrait 渲染 LaunchScreen，参 CLAUDE.md「典型案例 1」）
- [ ] **A.3 黑边合理**：比 16:9 更宽的 iPhone（如 16 Pro）启动图两侧补黑边可接受；iPad 4:3 上下补黑边可接受

### B. 启动流水线时序

预期 Xcode console 顺序：

```
[BundleConfig] 渠道注入读取结果（10 项）
[SandboxPaths] 路径计算结果
[ResourceUnzipper] ensureReady 完成
[RemoteConfigClient] 拉取成功 .parsed
[VersionResolver] 单链 4 层 fallback 结果
[LocalVersionReader] 本地 vs 远端对比
[LobbyZipUpgrader] 无需升级 / 升级完成 N → M
[lobby] write app_data.js → ...    ← Phase 1.7 落地（AppDataWriter）
[lobby] write app_gamesname.js → ...
[lobby] write app_battery.js: app_getbattery=0.XX → ...
[lobby] write app_network.js: app_getnetwork=2 → ...
```

- [ ] **B.1 启动一气呵成**：splash 显示 → "拉取配置中" → ("下载更新中 XX%") → "加载大厅..." → splash 0.3s 淡出，看到 H5 大厅
- [ ] **B.2 16:9 letterbox 黑边**：H5 大厅在屏幕中间，左右（或上下）有黑边，画面比例 16:9
- [ ] **B.3 启动总耗时 < 2s**（无升级路径），有升级时进度条实时更新

### C. `app_*.js` 文件落盘 + 全局变量命名

```bash
# 进入沙盒看 4 个文件
xcrun simctl get_app_container booted com.skyapp.ylgamehall data
# 然后 cat 4 个文件
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_data.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_battery.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_network.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_gamesname.js
```

- [ ] **C.1 4 个文件全部存在**且内容非空
- [ ] **C.2 app_data.js 内 12 个 var 命名 100% 正确**（特别注意大小写：`app_Launchtype` L 大写、`app_getwifisignalLevel` wifi 小写 + signal/Level 区分）
- [ ] **C.3 app_battery.js 变量名带 get**：`var app_getbattery=N;`（不是 `app_battery`）
- [ ] **C.4 app_network.js 变量名带 get**：`var app_getnetwork=N;`
- [ ] **C.5 app_gamesname.js 沿用 msext 字面**：`var  app_gamesname=new Array('xxx');`（var 后两个空格 + new Array(...)）
- [ ] **C.6 不存在已撤销变量**：`app_data.js` 内没有 `app_gameid` / `app_compareCode`（早期 Contract 误列，原 msext 不写）

### D. `window.app_*` H5 console 对账

Safari Web Inspector → Console：

```js
console.log("app_gameconfig =", app_gameconfig);  // 期望 ChannelConfig.plist gameconfig 实际值
console.log("app_market =", app_market);          // 期望 "2"
console.log("app_agent =", app_agent);            // 期望 ChannelConfig.plist agent 实际值
console.log("app_channel =", app_channel);
console.log("app_Launchtype =", app_Launchtype);  // 大厅期望 0
console.log("app_appversion =", app_appversion);  // 期望 '0' 字符串（审核切换标志非版本号）
console.log("app_getbattery =", app_getbattery);  // 期望 0.XX
console.log("app_getnetwork =", app_getnetwork);  // 期望 1/2/3
console.log("app_gamesname =", app_gamesname);    // 期望 Array
console.log("app_gameid =", app_gameid);          // 期望 undefined ✓
console.log("app_compareCode =", app_compareCode); // 期望 undefined ✓
```

- [ ] **D.1 所有 15 个 app_* 全局变量都 `!== undefined`**
- [ ] **D.2 关键字段都是 ChannelConfig.plist 的实际值**（不是 H5 zip 默认占位）
- [ ] **D.3 `app_appversion === '0'`**（审核标志，正常业务）；`app_gameconfig` 取 BundleConfig.gameConfig

---

## Phase 2 — 大厅 handler + 反向 callback

### E. H5 → Native handler 响应

每项的验证方式：在 H5 console 调对应 `bridge.callHandler('xxx', data, function(resp){console.log(resp);})`，看 Xcode console 有无副作用 + H5 收到 cb。

- [ ] **E.1 vibrator** → 真机震动 + H5 cb "vibrator"
- [ ] **E.2 repeatvibrator** → 真机震动 + cb "repeatvibrator"
- [ ] **E.3 canclevibrator** → cb "canclevibrator"（无可感知副作用，正常）
- [ ] **E.4 gameCopytext('test123')** → 系统剪贴板含 "test123"；其他 app 粘贴可验证；iOS 14+ 会出"已粘贴自 ylgamehall" banner（平台行为非 bug）
- [ ] **E.5 gamepastetext** → cb 是当前剪贴板内容（不是字面 "gamepastetext"）
- [ ] **E.6 SwitchShake(1) + SwitchShake(0)** → 内部 canVoice 状态切换（无可视化副作用，cb "SwitchShake"）
- [ ] **E.7 startshake → 摇一摇手机 → H5 收到 `shakeEnd` 反向 callback**（`bridge.registerHandler('shakeEnd', fn)` 先注册）
- [ ] **E.8 stopshake → 摇一摇 → H5 不再收到 shakeEnd**
- [ ] **E.9 voicePlaying(1) / voicePlaying(0)** → 内部 actor VoiceCenter 开关位（cb "Response from voicePlaying"）；mediaTypeAudio 实现后效果可验证
- [ ] **E.10 getphoneInfo** → cb "getphoneInfo"（**大写 I**）+ 反向 callback `getphoneinfo`（**小写 i**）含 6 字段（PhoneAdresseMAC / PhoneDeviceBrand / PhoneIMEI / PhoneModel / PhoneProvidersName / PhoneVersion）
- [ ] **E.11 browser('https://www.apple.com')** → Safari 打开苹果首页
- [ ] **E.12 opensaoma** → cb "opensaoma"（空实现，无可感知副作用，符合契约）
- [ ] **E.13 startlocation** → cb "startlocation"（Phase 2 stub，无定位真实业务；Phase 5 升级后再验真实定位）

### F. Native → H5 反向 callback

H5 需先用 `bridge.registerHandler('callbackName', fn)` 注册：

- [ ] **F.1 模拟器 Features → Battery State 切换** → H5 收到 `getBattery` "0.XX" + `window.app_getbattery` 实时跟随
- [ ] **F.2 模拟器 Features → Network Link Conditioner 切换 100% Loss** → H5 收到 `getnetwork` "1" + `window.app_getnetwork === 1`；切回 WiFi → 收 "2"；切 3G → 收 "3"
- [ ] **F.3 App 切后台**（Home 键 / 上滑）→ H5 收到 `appservice` "2"；回前台 → 收 "1" ⚠️ 值错位沿用 msext WKWebView 路径历史，参 Contract.md §3.2
- [ ] **F.4 摇一摇** → shakeEnd（见 E.7）
- [ ] **F.5 getphoneInfo 触发 getphoneinfo**（见 E.10）

### G. ExternalSubscriptions 生命周期

> Phase 2.C 已实现 setup/teardown 配对，但 Phase 6 子游戏容器才能完整验证"防双发"。当前只验证基本生命周期。

- [ ] **G.1 viewWillAppear setup**：app 启动后 onChange 闭包都已挂钩（F.1-F.4 都生效）
- [ ] **G.2 viewWillDisappear teardown**：手动触发场景待 Phase 6 子游戏 push 上来时验证

---

## Phase 3 — 音频体系（部分实现：3.A 完成；3.B/C/D 待外部资源）

### H. 本地音频（srcIsloop，3.A 完成）

- [ ] **E.14 srcIsloop({src:"xxx.wav", isloop:0})** → 听到单次按钮音；cb "Response from srcIsloop"
- [ ] **E.15 srcIsloop({src:"bg.wav", isloop:1})** → 听到背景循环音 + 同时调 srcIsloop({src:"另一首.wav", isloop:1}) → 替换为新背景循环
- [ ] **E.16 srcIsloop({src:"bg.wav", isloop:-1})** → 停止 bg.wav 背景；如果当前背景不是 bg.wav 则无效（与 msext 同名 type 校验）

> **依赖**：测试需要 H5 zip 内 `assets/wav/` 目录有可用音频文件。
> H5 团队 / 项目方提供后做。

### I. prepareaudio / mediaTypeAudio stub（3.B/3.C/3.D 待 opencore-amr 接入）

- [ ] **E.17 prepareaudio** → cb "Response from prepareaudio"（stub 仅 cb，无录音 / 无 getaudiourl 反向）
- [ ] **E.18 mediaTypeAudio** → cb "Response from mediaTypeAudio"（stub 仅 cb，无下载 / 无播放 / 无 gameui_play_voice 反向）

> 3.B AMR 转码 + 3.C 录音 + 3.D 远端播放完整实现后升级（参 SDK-Integration-Guide.md §C，需用户手动配置 opencore-amr ObjC 源码）。

### I.5 七牛 token 自签 + 上传单元验证（3.6 已落地，依赖 SDK 接入）

- [ ] **I.5.1 token 字符串结构正确**：在 Xcode console 或 Swift Playground 调
  ```swift
  let token = QiniuTokenSigner.uploadToken(key: "test/foo.amr")
  print(token)
  ```
  应输出 `AccessKey:encodedSign:encodedPolicy` 三段（用 `:` 分隔，无空格）；中间段是 base64url（含 `-_`、无 `+/=`）
- [ ] **I.5.2 putPolicy 反序列化合法**：取出第三段 base64url decode → JSON `{"scope": "iosaudio:test/foo.amr", "deadline": <future-unix-ts>}`，deadline 应为约 1 小时后
- [ ] **I.5.3 真机上传**：临时写一段 await `QiniuUploader.shared.upload(<本地小文件>, timeSec: 3)` → 拿到 `UploadedFile.fileUrl` → 浏览器粘贴 URL 能直接下载内容

---

## Phase 4 — 分享 + 登录（已落地）

> **真机要求**：微信 / QQ / 抖音 SDK + URL Scheme 调起在模拟器都不可靠，必须真机。

### J. 微信授权登录（4.B 已落地）

H5 console 先注册 callback：
```js
bridge.registerHandler('sharelogin', function(d){ console.log('sharelogin', d); });
```
然后调：
```js
bridge.callHandler('accreditlogin', null, function(r){ console.log('cb', r); });
```

- [ ] **E.19 accreditlogin** → 拉起微信授权 → 同意 → 大厅 H5 收到 `sharelogin({ openid, headimgurl, nickname, sex, city, Province, unionid })`
- [ ] **E.19.1 关键契约**：payload 内 `Province` **大写 P**（不是 `province` 小写——与 Phase 5 startlocation 的 province 小写形成对照）
- [ ] **E.19.2 容错**：用户取消授权 → 不发 sharelogin（msext 同款乐观策略）
- [ ] **E.19.3 未装微信**：H5 alert 提示"请安装微信"（具体文案由 H5 决定，原生只保证不崩）
- [ ] **E.19.4 SceneDelegate 回调命中**：app 切到微信 → 授权 → 切回 ylgamehall → 立刻收 sharelogin（不是过 N 秒）

### K. 微信链接分享 type=1（4.E 已落地）

H5 注册 + 调用：
```js
bridge.registerHandler('sharesuccess', function(d){ console.log('sharesuccess', d); });
bridge.callHandler('friendsSharetypeUrlToptitleDescript', {
  sharefriend: "2",        // "2" 直接走微信朋友圈，"1" 弹 SharePanel
  sharetype:   "1",
  type:        "1",        // 链接
  webpageUrl:  "https://www.apple.com",
  title:       "测试标题",
  description: "测试描述"
}, null);
```

- [ ] **E.20 sharefriend=2 type=1** → 直接拉起微信朋友圈分享面板，标题/描述/链接正确
- [ ] **E.20.1 sharesuccess 反向**：分享成功或取消都收 `sharesuccess({success:"2", type:"2"})`（type 是原 sharefriend）；notInstalled / failed 不发（乐观策略）

### L. SharePanel 三选一面板（4.B 已落地）

调用：
```js
bridge.callHandler('friendsSharetypeUrlToptitleDescript', { sharefriend:"1", ... }, null);
```

- [ ] **L.1** 底部弹出半透明面板，3 个按钮：微信 / QQ / 抖音
- [ ] **L.2** 0.25s 滑入/滑出动画
- [ ] **L.3** 半透明背景点击 → 关闭面板（不分享）
- [ ] **L.4** 选微信 → 走 §K 路径；选 QQ → 走 §M；选抖音 → 走 §N
- [ ] **L.5** 平台未安装 → 按钮置灰可点但触发后无副作用（与 msext 一致）

### M. QQ URL Scheme 分享 type=1（4.C 已落地，无 SDK）

- [ ] **M.1 QQ 已安装** → `mqqapi://share/to_fri?...` 调起 QQ app 选好友页
- [ ] **M.2 朋友圈**：sharefriend=2（朋友圈）→ 走 `mqqapi://share/to_qzone?...`
- [ ] **M.3 调起即 success**：URL Scheme 单向无返回，不发 sharesuccess（msext 乐观策略）
- [ ] **M.4 encode 正确**：含中文 / 空格的 title / desc 经 RFC 3986 percent encoding（与 msext line 1262-1268 等价）

### N. 抖音 URL Scheme + Photos 分享（4.D 已落地，无 SDK）

- [ ] **N.1 截图分享（type=1/2 退化为截屏）**：抖音已装 → 系统首次弹出相册写入权限弹窗 → 同意 → 截图存进相册 + 调起抖音 → 用户在抖音里选图发布
- [ ] **N.2 远端图（type≠1,2）**：下载 URL → 存相册 + 调起抖音
- [ ] **N.3 PhotoLibrary 拒绝权限**：H5 alert 提示（具体文案由 H5）
- [ ] **N.4 抖音未安装**：尝试 4 个 URL Scheme（camera / publish / share/image / 基础）都失败 → 不报错（乐观）

### O. 微信截图 / 远端图分享（4.F 已落地，commit 8e4c36d）

H5 调用（截图 type=2）：
```js
bridge.callHandler('friendsSharetypeUrlToptitleDescript', {
  sharefriend:"2", sharetype:"1", type:"2",
  webpageUrl:"", title:"", description:""
}, null);
```

- [ ] **O.1 type=2 截图**：当前 H5 整屏被截 → JPEG 0.6 大图 + 0.4x 缩略图 → 拉起微信朋友圈分享，看到截图缩略图 + 大图
- [ ] **O.2 type=3 远端图**：传 `type:"3", webpageUrl:"http://xxx/qr.png"` → 下载远端图 → 微信分享带远端图（msext 公众号二维码分享场景）
- [ ] **O.3 thumb ≤ 32KB 自动压缩**：分享调起时不报"缩略图过大"错（微信 SDK 内部 setThumbImage 自动压缩，外层保证 0.4x 尺寸够小）
- [ ] **O.4 errCode 处理**：用户在微信侧取消 → 收 cancelled（errCode -2）；其它 errCode → failed
- [ ] **O.5 关键回归**：type=1 链接分享仍正常（O 不该影响 §K）

---

## Phase 5 — 高德定位（已落地）

> **真机要求**：fat .framework 在 M Mac 模拟器链接失败（参 `Vendor/AMap/README.md` 「已知限制」），所有定位验证只能跑真机。

### P. startlocation 真实路径

H5 注册 + 调用：
```js
bridge.registerHandler('getlocationinfo', function(d){ console.log('loc', d); });
bridge.callHandler('startlocation', null, function(r){ console.log('cb', r); });
```

- [ ] **P.1 首次启动权限弹窗**：iOS 系统弹"是否允许访问位置"，文案是中文 `进贤聚友棋牌需要访问您的位置以提供本地化服务`
- [ ] **P.2 同意权限** → H5 收到 `getlocationinfo` 包含 **9 字段**：
  - `latitude` （string 类型，`String(format:"%f")` 产物）
  - `longitude` （string，同上）
  - `altitude`
  - `country` / `province` / `city` / `district` / `street` / `streetNumber`（**province 小写 p**，与 §J sharelogin 的 Province 大写 P 对照）
- [ ] **P.3 拒绝权限** → H5 收到 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
- [ ] **P.4 飞行模式 / 无网络** → 高德 SDK 返回错误 → H5 收 errorCode 12（或其它非 0）

---

## Phase 6 — 子游戏（已落地）

### Q. SwitchOverGameData → 子游戏 push + backgameData → pop

H5 调用：
```js
bridge.callHandler('SwitchOverGameData', { gameid:"<sub_id>", ... }, null);
```

- [ ] **Q.1 push 子游戏**：大厅 → push 出新 VC 加载子游戏 H5；过程中 splash + "进入子游戏..." 进度
- [ ] **Q.2 2s 节流**：连续点 SwitchOverGameData 两次（间隔 < 2s）→ 第二次被忽略（AppCoordinator lastSwitchAt 守门）
- [ ] **Q.3 栈深约束**：连续 push 3 个子游戏 → 第 3 次被拒（viewControllers.count < 2 限制）
- [ ] **Q.4 backgameData 回大厅**：子游戏 H5 调 `bridge.callHandler('backgameData', "transfer-payload", null)` → pop 回大厅 + 大厅 H5 收到 `getWebdata("transfer-payload")`
- [ ] **Q.5 backgameData 对象入参**：传 `{x:1, y:"a"}` 而非字符串 → 大厅 getWebdata 收到 JSON 序列化字符串
- [ ] **Q.6 ExternalSubscriptions teardown 防双发**：子游戏 push 上来时大厅 viewWillDisappear 解绑 Monitor 闭包 → 模拟器切电池/网络 → H5 console 看大厅与子游戏各自只收一次（不是双发）
- [ ] **Q.7 SubGameDownloader 缓存命中**：第一次进子游戏看到下载进度；第二次进同一子游戏跳过下载（gameVersion 一致）
- [ ] **Q.8 SubGameDownloader 升级**：远端 gameVersion 提升 → 第二次进重新下载并原子 rename 替换

---

## Phase 7 — 弹层 Overlay（已落地）

### R. OpenurlTitleData 弹层 + 三个 settings

H5 调用：
```js
bridge.callHandler('OpenurlTitleData', { url:"https://www.apple.com", "title ":"标题（注意末尾空格）" }, null);
```

- [ ] **R.1 弹层 push**：大厅栈深 + 1，全屏加载外链页（黑底 + 标题栏）
- [ ] **R.2 入参 "title " 末尾空格**：H5 console 调时 key 必须带空格（契约 §3.1 ［27］）；少空格则原生收不到 title fall back 默认值
- [ ] **R.3 3s 节流**：连续调 OpenurlTitleData 两次（< 3s）→ 第二次拒（AppCoordinator 守门）
- [ ] **R.4 window.settings.finishweb()**：在弹层 H5 console 调 → pop 回大厅，不带数据
- [ ] **R.5 window.settings.backgameData("payload")**：pop 回大厅 + 大厅 H5 收 `getWebdata("payload")`（与 Q.4 同链路）
- [ ] **R.6 window.settings.browser("https://apple.com")**：Safari 打开 URL（不退出 ylgamehall）
- [ ] **R.7 Cookie 隔离**：弹层登录某第三方网站 → 弹层 pop 后 → 主大厅 H5 不带这个 cookie（WKWebsiteDataStore.nonPersistent 隔离）
- [ ] **R.8 子游戏 → 弹层 → backgameData → 子游戏**：从子游戏 push overlay → overlay 调 backgameData → pop 回子游戏 + 子游戏 H5 收到 getWebdata（覆盖 .subGameDidReturn 通知链）

---

## Phase 8 — 视频房间 stub + phonestate（已落地）

### S. 视频房间 3 件套 stub

- [ ] **S.1 createRoom** → cb "createRoom"（无真实 Agora 加入）
- [ ] **S.2 getVideoinfo（H5→Native）** → cb "getVideoinfo"（无真实排版）
- [ ] **S.3 exitRoom** → cb "exitRoom"（无真实 leaveChannel）
- [ ] **S.4 大厅误调 createRoom**：契约规定大厅不注册 → bridge handler not found，H5 console 看到错误信号（不该崩）

### T. phonestate（PhoneStateMonitor，仅子游戏订阅）

H5 在子游戏 console：
```js
bridge.registerHandler('phonestate', function(d){ console.log('phonestate', d); });
```

- [ ] **T.1 子游戏内来电响铃**：模拟器 Features → Toggle In-Call Status 或真机来电 → H5 收 `phonestate("2")`
- [ ] **T.2 接通通话** → H5 收 `phonestate("2")`（hasConnected=true 分支）
- [ ] **T.3 挂断** → H5 收 `phonestate("0")`
- [ ] **T.4 大厅页不订阅**：在大厅期间来电 → 大厅 H5 **不**收 phonestate（仅 SubGameViewController 注册）
- [ ] **T.5 子游戏 pop 后停止订阅**：子游戏返回大厅 → 期间来电 → 大厅 H5 不收（PhoneStateMonitor.stop() 已 detach）

---

## Phase 9 — H5ErrorRelay（9.2 已落地）

### U. H5 错误中继到原生 print

- [ ] **U.1 H5 console.error**：Safari Web Inspector → 大厅 console 执行 `console.error("test", 123, {a:1})` → Xcode console 看到 `[H5 console.error] test 123 [object Object]`
- [ ] **U.2 H5 onerror**：H5 console 执行 `undefinedVar.foo` → Xcode console 看到 `[H5 onerror] ReferenceError: ... at xxx.js:N:N` + stack
- [ ] **U.3 H5 unhandledrejection**：H5 console 执行 `Promise.reject("boom")` → Xcode console 看到 `[H5 unhandledrejection] boom`
- [ ] **U.4 仅大厅+子游戏拦截**：弹层 OverlayViewController 内执行 console.error → Xcode console **不**看到（Overlay 不挂 H5ErrorRelay）
- [ ] **U.5 原始函数链未断**：H5 业务的 console.error 仍能在 Safari Web Inspector 里看到（hook 保留 origErr.apply）

---

## 附：契约 §10 26 项验收清单映射

本文档每个章节对应 Contract §10 的 A/B/C/D/E 五节：

| 本文档章节 | Contract §10 节 | 状态 |
|---|---|---|
| A. 启动图 + LaunchScreen | §A 启动（启动相关） | Phase 1 ✅ |
| B. 启动流水线时序 | §A 启动 | Phase 1 ✅ |
| C. app_*.js 落盘 + 命名 | §A 启动 | Phase 1 ✅ |
| D. window.app_* H5 console | §A 启动 + §B 桥接 | Phase 1 ✅ |
| E. H5→Native handler | §B 桥接 | Phase 2 ✅ |
| F. 反向 callback | §C 系统事件 + §B 桥接 | Phase 2 ✅ |
| G. 生命周期（防双发） | （Design §2.4.2） | Phase 2 ✅ / Phase 6 联动 |
| H. 本地音频 srcIsloop | §B 桥接 | Phase 3.A ✅ |
| I. 录音 / 远端语音 stub | §B 桥接 | Phase 3.B/C/D ⏳（待 opencore-amr）|
| I.5. 七牛 token + 上传 | §B 桥接 | Phase 3.6 ✅（链路完整，需 SDK 接入后真机验） |
| J. 微信授权 sharelogin | §B + §E 回归 | Phase 4.B ✅ |
| K. 微信链接分享 type=1 | §B + §E | Phase 4.E ✅ |
| L. SharePanel 三选一 | §B | Phase 4.B ✅ |
| M. QQ URL Scheme 分享 | §B + §E | Phase 4.C ✅ |
| N. 抖音 URL Scheme + Photos | §B + §E | Phase 4.D ✅ |
| O. 微信截图 / 远端图 type=2/3 | §B + §E | Phase 4.F ✅ |
| P. 高德定位 9 字段 | §B 桥接 | Phase 5 ✅（**仅真机**）|
| Q. 子游戏 push / backgameData | §B + §D | Phase 6 ✅ |
| R. 弹层 OpenurlTitleData / settings 3 件 | §B + §D | Phase 7 ✅ |
| S. 视频房间 stub | §B + §D 视频 | Phase 8.1-8.3 ✅（stub 满足契约边界）|
| T. phonestate（来电/挂断）| §C 系统事件 | Phase 8.4 ✅（**仅真机** CXCallObserver）|
| U. H5ErrorRelay | （Design §11.4，原则 B 内部加强）| Phase 9.2 ✅ |

---

## 维护规则

- 每完成一个 Phase 子项 → 在对应章节补 / 勾验证步骤
- **不要把验证步骤复制到 Contract §10**：Contract §10 是契约边界（"必须长这样"），本文档是验证操作手册（"怎么验证它长这样"），两份保持独立
- 验证发现的 bug → 修复后 commit 时在 message 引用本文档对应 checkbox 编号（如 "修 E.10 getphoneInfo 大小写错"）
