diff --git a/tests/manual/W3_multi_session_log.md b/tests/manual/W3_multi_session_log.md new file mode 100644 index 0000000..cc8bd95 --- /dev/null +++ b/tests/manual/W3_multi_session_log.md @@ -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@」并 close(或推迟到 PM 决定)。 diff --git a/tests/manual/W3_p2_log.md b/tests/manual/W3_p2_log.md new file mode 100644 index 0000000..5d02d9d --- /dev/null +++ b/tests/manual/W3_p2_log.md @@ -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_.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_.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@」。 diff --git a/tests/manual/W3_regression_summary.md b/tests/manual/W3_regression_summary.md new file mode 100644 index 0000000..18eb0ef --- /dev/null +++ b/tests/manual/W3_regression_summary.md @@ -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 中追加单测,**不**降低覆盖率门槛。 diff --git a/tests/unit/test_event_navigator.py b/tests/unit/test_event_navigator.py index bd9f2cd..20f0b08 100644 --- a/tests/unit/test_event_navigator.py +++ b/tests/unit/test_event_navigator.py @@ -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) diff --git a/tests/unit/test_page_probe.py b/tests/unit/test_page_probe.py index 52da7b1..73211dd 100644 --- a/tests/unit/test_page_probe.py +++ b/tests/unit/test_page_probe.py @@ -590,3 +590,46 @@ def test_force_state_resets_unknown_counter(self, tmp_path): # One more unknown — only 1 consecutive after reset, no dump. probe.probe_current_page() assert probe.dumped_xml_path is None + + +# --------------------------------------------------------------------------- +# unknown_threshold=0 — qa W3-03 边界 (Task C2) +# --------------------------------------------------------------------------- + + +import pytest # noqa: E402 + + +class TestUnknownThresholdZeroBoundary: + """W3-03 qa-added 边界用例:把 unknown_threshold=0 的语义 lock-in。""" + + @pytest.mark.xfail( + strict=True, + reason=( + "qa intent: unknown_threshold=0 应禁用告警 (never dump)。" + "当前实现在 mobile/page_probe.py PageProbe.__init__ 内做 max(1, ...) " + "夹紧,第一次 unknown 即触发 dump — 行为与名字相反。" + "本测试 xfail 等待源码语义澄清/修复后转为 pass;" + "见 tests/manual/W3_regression_summary.md 'New issues' 区。" + ), + ) + def test_unknown_threshold_zero_disables_alert(self, tmp_path): + """传 unknown_threshold=0 时,无论多少次 unknown 都不应触发告警/dump。 + + 当前实现:clamp 到 1 → 第一次 unknown 即 dump,本测试 xfail。 + """ + device = _make_device(activity="") + device.dump_hierarchy.return_value = "" + probe = PageProbe( + device, + cache_ttl_s=0, + unknown_threshold=0, + dump_dir=str(tmp_path), + ) + # 即便连续 5 次 unknown,也不应触发任何 dump(按"0=disabled"语义)。 + for _ in range(5): + assert probe.probe_current_page()["state"] == "unknown" + assert probe.dumped_xml_path is None, ( + "expected no dump when unknown_threshold=0 (disabled), " + "but probe.dumped_xml_path is set" + )