Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions tests/manual/W3_multi_session_log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# W3 多场次回归日志(refs #25)

> **范围**: W3-01 (#25 多场次场景下场次选择失败) 已合入 master 后的真机回归。
> **目标**: 在不少于 1 台真机上验证 5 类多场次场景,所有结果回填本表后归档。
> **保密要求**: 演出名脱敏(首字母 + 类型),不写真实观演人姓名、手机号、身份证号。
> **关联**: refs #25;fix-plan `reference/05-fix-plans/p1-25-multi-session-rush.md`;上游模板 `tests/manual/W2_nlp_realmachine_log.md`。

---

## 测试环境

| 项 | 值(待 qa 填写) |
| --- | --- |
| 真机型号 / Android 版本 | _ |
| Damai App 版本 | _ |
| 仓库 commit (`git rev-parse --short HEAD`) | _ |
| 操作者(脱敏 ID,如 qa-01) | _ |
| 执行日期(YYYY-MM-DD) | _ |

---

## Task A:多场次 5 场景回归矩阵

> **填表说明**
> - `命中场次`:从 `mobile/scripts/start_ticket_grabbing.sh --probe --yes` 的日志中读取 `select_session: ... 命中 idx=K/N` 字段,记 `idx (K/N) - <场次描述>`,如 `0 (0/3) - 04.05 上海`。
> - `通过` 列写 ✅/❌;❌ 行须在 Task D 开 issue 并回链。
> - `# 4` 与 `# 5` 不需要真机活动,可在任意已 reach `sku_page` 的演出上验证;重点验证配置项的兼容/语义。

| # | 演出名(脱敏) | 类型 | date 配置 | city 配置 | 命中场次 | 通过 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 巡演 A 上海站 | 多日同城(同一城市多个日期) | `04.05` | `上海` | _ | _ | 期望唯一命中 04.05 |
| 2 | 巡演 B 全国 | 多日多城(多个城市各一日期) | `04.10` | `北京` | _ | _ | 期望命中 北京 04.10;只填 date 应 ambiguous → 必须配合 city |
| 3 | 音乐节 C 单日 | 多艺人单日(单日多场次卡片) | `05.01` | `上海` | _ | _ | 单日内若 SESSION_PICKER 不弹出(只有 1 场次),日志应说明 "skipped session selection";fallback_index 失效 |
| 4 | rush_mode alias 兼容 | _ | _ | _ | _ | _ | `config` 中 `rush_mode: true` 启动期应自动展开为三个子开关默认值,且日志显示 `rush_skip_session=false / rush_skip_price_dump=true / rush_aggressive_retry=true` |
| 5 | rush_skip_session=true(单场次场景) | _ | _ | _ | _ | _ | 单场次演出 + `rush_skip_session: true`:跳过场次选择直接 sku_page;多场次演出 + `rush_skip_session: true`:日志应有 warning 或 fail-fast |

---

## Task B:执行步骤(人工 qa 真机现场操作)

```bash
cd /Users/andrew/Documents/GitHub/HaTickets
git checkout master && git pull # W3-01 已合入

# 准备 config.local.jsonc,填入对应行的 keyword / date / city
# 然后逐场景执行:
bash mobile/scripts/start_ticket_grabbing.sh --probe --yes 2>&1 | tee tmp/w3_session_case_$N.log
# 把 select_session 命中行复制到 Task A 表格对应行

# 场景 4 验证:直接构造 alias 配置
# config.local.jsonc:
# {"rush_mode": true, ...}
# 启动后日志应有:rush_mode alias → rush_skip_session=false, rush_skip_price_dump=true, rush_aggressive_retry=true

# 场景 5:手动构造 rush_skip_session=true 单场次/多场次 各一次
```

---

## Task C:发现问题汇总

> 任一行 ❌ 都要在此区列出 issue 链接 + 一句话症状。
> 若已知症状但暂无 issue,先标记 "TBD" 并在 W3_regression_summary 中提请创建。

- _

---

## 归档

- 完成填写后将本文件链接同步到 `reference/04-issues-matrix.md` 的 #25 条目「real-device validation」字段。
- 若 5 场景全部 ✅,在 #25 issue 下评论「W3-03 真机回归全部通过 — log: tests/manual/W3_multi_session_log.md@<commit>」并 close(或推迟到 PM 决定)。
179 changes: 179 additions & 0 deletions tests/manual/W3_p2_log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# W3 P2 三连真机回归日志(refs #28 #23 #24)

> **范围**: W3-02(P2 修复栈)已合入 master 后的真机回归。覆盖:
> - #28 wait_for_home_ready 首页探测超时 + UI dump
> - #23 select_search_result 0/1/N 分流 + UI dump
> - #24 PageProbe unknown_threshold 阈值告警 + force_state
>
> **目标**: 在不少于 1 台真机上,覆盖三个 issue 的关键分支并回填本表。
> **保密要求**: 演出名脱敏,不写真实观演人姓名 / 手机号 / 身份证号。
> **关联**: refs #28 #23 #24;PR #42;上游模板 `tests/manual/W2_nlp_realmachine_log.md`。

---

## 测试环境

| 项 | 值(待 qa 填写) |
| --- | --- |
| 真机型号 / Android 版本 | _ |
| Damai App 版本 | _ |
| 仓库 commit (`git rev-parse --short HEAD`) | _ |
| 操作者(脱敏 ID,如 qa-01) | _ |
| 执行日期(YYYY-MM-DD) | _ |

---

## #28 首页探测超时

> 实现位于 `mobile/page_helpers.py:wait_for_home_ready`。
> 期望:超时抛 `HomeNotReadyError`,异常 message 含 `dump=tmp/home_probe_<ts>.xml`,dump 文件已落地,日志输出当前可见 texts + resource_ids。

| # | 测试 | 步骤 | 预期 | 实际 | 通过 |
| --- | --- | --- | --- | --- | --- |
| 28-1 | 已在大麦首页 fast path | 启动脚本时已停留在大麦 MainActivity | 立即返回,state=homepage,<1.5s 完成 | _ | _ |
| 28-2 | 关闭大麦 App 后启动 | 强停大麦后启动脚本 | 8s 后抛 `HomeNotReadyError`,异常含 `首页未就绪` + dump 路径,文件存在 | _ | _ |
| 28-3 | 启动大麦但停在详情页 | 进入详情页后启动脚本 | 8s 后抛 `HomeNotReadyError`,dump 显示详情页 hierarchy(包含 `purchase_status_bar` 等关键 id) | _ | _ |
| 28-4 | dump 路径与命名 | 任一超时 case 后查看 `ls tmp/home_probe_*.xml` | 命名形如 `home_probe_YYYYMMDD_HHMMSS.xml`,体积 >0 | _ | _ |

---

## #23 搜索结果

> 实现位于 `mobile/page_helpers.py:select_search_result`。
> 0 结果 → `SearchEmptyError`(异常 message 含 keyword + dump);1 结果 → 自动选中点击;N 结果 + target_title 命中 → 模糊匹配选中;N 结果 + target_title 未命中(默认 `strict=False`)→ 回退首条并 warning;`strict=True` 未命中则 `SearchAmbiguousError`。

| # | 测试 | 关键词 | target_title | strict | 预期 | 实际 | 通过 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 23-1 | 0 结果 | `xQyZpQ123abcdNOTEXIST` | `None` | `False` | `SearchEmptyError`,message 含 keyword + `dump=tmp/search_probe_*.xml` | _ | _ |
| 23-2 | 1 结果 | `<极冷门演出全名脱敏>` | `None` | `False` | 自动选中并跳转 `ProjectDetailActivity` | _ | _ |
| 23-3 | N 结果 + target 匹配 | `周杰伦` | `周杰伦` | `False` | 选中含 `周杰伦` 的卡片,跳转 ProjectDetailActivity | _ | _ |
| 23-4 | N 结果 + 无 target(fallback first) | `周杰伦` | `None` | `False` | 选中第一个,warning `回退到 idx=0 title=...` | _ | _ |
| 23-5 | N 结果 + target 未命中 + strict | `周杰伦` | `不存在的歌手` | `True` | `SearchAmbiguousError`,message 含 `共 N 条` + dump | _ | _ |
| 23-6 | dump 路径与命名 | 23-1 / 23-5 后查看 `ls tmp/search_probe_*.xml` | _ | _ | 命名形如 `search_probe_YYYYMMDD_HHMMSS.xml` | _ | _ |

---

## #24 page_probe unknown 阈值

> 实现位于 `mobile/page_probe.py:PageProbe._track_unknown` 与 `force_state`。
> 连续 N=`unknown_threshold` 次返回 `state="unknown"` 时:logger.warning(`UNKNOWN_THRESHOLD reached after N classifications`) + 自动写 `tmp/page_probe_unknown_<ts>.xml` + 设 `probe.dumped_xml_path`。
> `force_state(state)` 短路返回;`force_state(None)` 清除并恢复真实探测。

| # | 测试 | 步骤 | 预期 | 实际 | 通过 |
| --- | --- | --- | --- | --- | --- |
| 24-1 | 在弹窗未关时连续 probe | 启动脚本时手动让首页弹窗保持显示,连续 3 次 `PageProbe.classify()` | 第 3 次返回 unknown 后 logger.warning `UNKNOWN_THRESHOLD reached after 3 classifications`;`probe.dumped_xml_path` 非 None;文件存在且为弹窗 hierarchy | _ | _ |
| 24-2 | 任一已知状态后计数重置 | 关闭弹窗 → probe 一次(应为 homepage)→ 再次让弹窗出现 → 仅 1 次 unknown 不应触发告警 | 不出现告警;`dumped_xml_path` 维持 24-1 的旧值 | _ | _ |
| 24-3 | `force_state(PageState.HOMEPAGE)` 短路 | 在任意页面调用 `probe.force_state(PageState.HOMEPAGE)`;后续 `classify()` | state=homepage,且未触发 dump_hierarchy / app_current 调用(adb 抓包/日志确认) | _ | _ |
| 24-4 | `force_state(None)` 恢复 | 24-3 后调用 `probe.force_state(None)` 然后 classify | 返回真实 state(与设备实际页面一致) | _ | _ |
| 24-5 | dump 路径与命名 | 24-1 后查看 `ls tmp/page_probe_unknown_*.xml` | 命名形如 `page_probe_unknown_YYYYMMDD_HHMMSS.xml` | _ | _ |

---

## 执行步骤(人工 qa 真机现场操作)

```bash
cd /Users/andrew/Documents/GitHub/HaTickets
git checkout master && git pull # W3-02 PR #42 已合入

# === #28 ===
# 28-1
adb shell am start -n cn.damai/.homepage.MainActivity # 确保在首页
poetry run python -c "
import time, uiautomator2 as u2
from mobile.page_probe import PageProbe
from mobile.event_navigator import wait_for_home_ready, HomeNotReadyError
d = u2.connect()
probe = PageProbe(d, cache_ttl_s=0.0)
t0=time.time(); r=wait_for_home_ready(d, probe, timeout=8.0); print('elapsed_ms=', (time.time()-t0)*1000, 'state=', r['state'])
"

# 28-2
adb shell am force-stop cn.damai
poetry run python -c "
import time, uiautomator2 as u2
from mobile.page_probe import PageProbe
from mobile.event_navigator import wait_for_home_ready, HomeNotReadyError
d = u2.connect()
probe = PageProbe(d, cache_ttl_s=0.0)
try:
wait_for_home_ready(d, probe, timeout=8.0)
except HomeNotReadyError as e:
print('OK:', e)
import glob; print('dumps:', glob.glob('tmp/home_probe_*.xml'))
"

# === #23 ===
# 进搜索页(首页 → 搜索按钮)
poetry run python -c "
import time, uiautomator2 as u2
d = u2.connect()
d(resourceId='cn.damai:id/pioneer_homepage_header_search_btn').click(); time.sleep(1.2)
# 关位置权限弹窗(如有)
for _ in range(3):
btn = d(resourceId='cn.damai:id/damai_theme_dialog_cancel_btn')
if not btn.exists: break
btn.click(); time.sleep(0.6)
print('on search:', d.app_current().get('activity'))
"

# 23-1 / 23-3 / 23-4 / 23-5:在搜索页执行
poetry run python -c "
import time, uiautomator2 as u2
from mobile.event_navigator import select_search_result, SearchEmptyError, SearchAmbiguousError
d = u2.connect()
inp = d(resourceId='cn.damai:id/header_search_v2_input')
inp.click(); time.sleep(0.3)
btn = d(resourceId='cn.damai:id/header_search_v2_input_delete')
if btn.exists: btn.click(); time.sleep(0.2)
keyword = '<填入测试关键词>'
inp.set_text(keyword); time.sleep(0.2)
d.press('enter'); time.sleep(1.0)
if d(text='演出').exists: d(text='演出').click(); time.sleep(0.5)
try:
chosen = select_search_result(d, keyword=keyword, target_title='<或 None>', strict=False, timeout=5.0)
print('picked:', chosen)
except (SearchEmptyError, SearchAmbiguousError) as e:
print(type(e).__name__, e)
"

# === #24 ===
# 24-1:让首页弹窗保持显示(不点关闭),连续 3 次 probe
adb shell am start -n cn.damai/.homepage.MainActivity
sleep 2
poetry run python -c "
from mobile.page_probe import PageProbe, PageState
import uiautomator2 as u2
d = u2.connect()
# 关弹窗以外的窗,但保留首页弹窗
probe = PageProbe(d, cache_ttl_s=0.0, unknown_threshold=3, dump_dir='tmp')
# 强制让 probe 看到 unknown:用一个非大麦 Activity(如设置)
import os
os.system('adb shell am start -a android.settings.SETTINGS')
import time; time.sleep(1.5)
for i in range(3):
r = probe.classify()
print(i, r['state'])
print('dumped:', probe.dumped_xml_path)

# 24-3 / 24-4
probe.force_state(PageState.HOMEPAGE)
print('forced:', probe.classify()['state'])
probe.force_state(None)
print('cleared:', probe.classify()['state'])
"
```

---

## 发现问题汇总

> 任一行 ❌ 都要在此区列出 issue 链接 + 一句话症状。

- _

---

## 归档

- 完成填写后将本文件链接同步到 `reference/04-issues-matrix.md` 中 #28 / #23 / #24 条目「real-device validation」字段。
- 三个 issue 的关键分支全部 ✅ 后,在各自 issue 下评论「W3-03 真机回归通过 — log: tests/manual/W3_p2_log.md@<commit>」。
65 changes: 65 additions & 0 deletions tests/manual/W3_regression_summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# W3 回归总结(YYYY-MM-DD ~ YYYY-MM-DD)

> **范围**: W3 sprint(W3-01 多场次 #25 + W3-02 P2 三连 #28 #23 #24)真机 + 单测综合回归。
> **填写人**: qa(脱敏 ID)
> **关联**: refs #25 #28 #23 #24;PR #40(W3-01)+ PR #42(W3-02)+ 本 PR(W3-03 回归矩阵 + 单测增补)。

---

## 通过率汇总

> `通过 / 计划` 形式填写。`通过` = ✅ 行数;`计划` = 各 manual log 中表格行总数。

- W3-01 #25:__/_5 场景通过(详见 [`W3_multi_session_log.md`](W3_multi_session_log.md))
- W3-02 #28:__/_4 场景通过(详见 [`W3_p2_log.md`](W3_p2_log.md) #28 区块)
- W3-02 #23:__/_6 场景通过(详见 [`W3_p2_log.md`](W3_p2_log.md) #23 区块;含 strict/dump 子用例)
- W3-02 #24:__/_5 场景通过(详见 [`W3_p2_log.md`](W3_p2_log.md) #24 区块)

整体通过率:__/_20

---

## 单测与覆盖率

| 项 | 结果 |
| --- | --- |
| `poetry run pytest --cov=mobile --cov-fail-under=80` | _ passed / coverage _% |
| 新增单测条数(C1+C2) | 3 |
| CI 矩阵(Py 3.8/3.9/3.10/3.12/3.13) | _ |

---

## 发现的新问题

> 一行一个 issue:编号 + 一句话症状 + 严重度(P0/P1/P2)+ 责任人。
> 若无新问题,写 "本轮回归未发现新问题"。

| issue # | 一句话症状 | 严重度 | owner | 发现于场景 |
| --- | --- | --- | --- | --- |
| TBD-1 | `PageProbe(unknown_threshold=0)` 当前 clamp 到 1(第一次 unknown 即告警),与"0 = 禁用告警"的直觉语义相反。需要 PM 决定:澄清文档(保留 clamp)/ 改源码(0=disable)。`tests/unit/test_page_probe.py::TestUnknownThresholdZeroBoundary::test_unknown_threshold_zero_disables_alert` 已用 `xfail(strict=True)` 锁定预期,待源码修复后 xfail 自动失效。 | P2 | dev / pm | C2 边界单测 |

---

## 已知风险 / 跳过项

- _

---

## 真机环境

| 项 | 值 |
| --- | --- |
| 真机型号 / Android 版本 | _ |
| Damai App 版本 | _ |
| 仓库 commit | _ |
| 操作者 | _ |
| 执行起止时间 | _ |

---

## 下一步建议

- 若 #25 / #28 / #23 / #24 全部 ✅:在各 issue 下评论「W3-03 通过」并提请 PM 决定是否 close。
- 若发现新问题:在 `reference/04-issues-matrix.md` 增加新行;在 W4 sprint 计划中纳入。
- 若覆盖率 < 80%:在本 PR 中追加单测,**不**降低覆盖率门槛。
51 changes: 51 additions & 0 deletions tests/unit/test_event_navigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,3 +516,54 @@ def test_search_result_target_title_fuzzy_matches_second(self, tmp_path):
)
assert chosen["title"] == "周杰伦2026演唱会"
driver.click.assert_called_once_with(540, 300)


# ---------------------------------------------------------------------------
# select_session — qa W3-03 边界增补 (Task C1)
# ---------------------------------------------------------------------------


class TestSelectSessionBoundaryCases:
"""W3-03 qa-added 边界用例:补 fix-plan p1-25 未显式覆盖的路径。"""

def _make_driver(self, xml):
driver = MagicMock()
driver.dump_hierarchy.return_value = xml
return driver

def test_select_session_returns_first_match_when_city_omitted(self):
"""提供 date 且 city 显式为 None:date 唯一命中应返回该卡片 idx,
即使该卡片不是面板第 0 项。

与 ``test_unique_date_match_clicks_card`` 区别:这里 city=None 显式传入,
且匹配命中第 1 项(非 0 项),保证 ``date-only`` 单匹配路径独立可达。
"""
xml = _hierarchy_xml(
("04.01", "北京", "[0,0][540,200]"),
("04.13", "上海", "[540,0][1080,200]"),
("04.27", "广州", "[0,200][540,400]"),
)
driver = self._make_driver(xml)
idx = select_session(driver, date="04.13", city=None)
assert idx == 1
# Center of [540,0][1080,200] = (810, 100)
driver.click.assert_called_once_with(810, 100)

def test_select_session_falls_back_to_index_when_no_match(self):
"""提供 date + city 都未命中任何卡片,但同时提供了 fallback_index:
应进入 fallback_index 路径选中对应卡片,而不是抛 SessionNotFoundError。

与 ``test_fallback_index_when_no_date`` 区别:这里 date 是有提供的,
但 cards 中根本没有该 date — 验证 date+city → date-only → fallback_index
三段优先级链中第三段真的兜底。
"""
xml = _hierarchy_xml(
("04.05", "北京", "[0,0][540,200]"),
("04.13", "上海", "[540,0][1080,200]"),
)
driver = self._make_driver(xml)
# date "12.31" 未在卡片中;city "深圳" 也未匹配;fallback_index=1 兜底
idx = select_session(driver, date="12.31", city="深圳", fallback_index=1)
assert idx == 1
# Center of [540,0][1080,200] = (810, 100)
driver.click.assert_called_once_with(810, 100)
Loading
Loading