2e86d2ce27
- read_stuck_points 最忙平台末帧损坏 → last fallthrough 到干净平台 (重构最险分支的回归测试,原只在一次性验证脚本里) - _fmt_last round 边界:500→<1s、999→停留1s - continue 注释点明与旧版 _platform_stuck→None 等价 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
9.9 KiB
Python
243 lines
9.9 KiB
Python
"""trace_stuck 单测:用 tmp 造 step_*.json(只含头部字段)验证卡死判据。"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from app.services.trace_stuck import (
|
|
StuckPoint,
|
|
dir_name_from_trace_url,
|
|
last_step,
|
|
read_stuck_points,
|
|
)
|
|
|
|
|
|
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
|
|
"""造一帧 step json:头部放 pipeline_step/detected_page,尾部塞大 windows 模拟真实。"""
|
|
pdir.mkdir(parents=True, exist_ok=True)
|
|
body = {
|
|
"trace_id": "t", "step": idx, "platform": pdir.name,
|
|
"pipeline_step": step, "detected_page": page,
|
|
"windows": [{"nodes": ["x" * 200]}],
|
|
}
|
|
(pdir / f"step_{idx:03d}.json").write_text(
|
|
json.dumps(body, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
|
|
|
|
def test_stuck_when_tail_repeats_same_step(tmp_path):
|
|
pdir = tmp_path / "meituan"
|
|
for i in range(20):
|
|
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.readable is True
|
|
assert res.points == [StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup")]
|
|
|
|
|
|
def test_not_stuck_when_progressing(tmp_path):
|
|
pdir = tmp_path / "eleme"
|
|
_frame(pdir, 0, "set_address", "home")
|
|
for i in range(1, 8):
|
|
_frame(pdir, i, "enter_store", "store")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.readable is True
|
|
assert res.points == []
|
|
|
|
|
|
def test_adding_many_dishes_not_stuck_when_page_changes(tmp_path):
|
|
# add_one_dish 重复但 detected_page 在跳(换菜)=推进,不判卡死
|
|
pdir = tmp_path / "meituan"
|
|
for i in range(20):
|
|
_frame(pdir, i, "add_one_dish", "menu" if i % 2 == 0 else "dish_popup")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.points == []
|
|
|
|
|
|
def test_below_threshold_not_stuck(tmp_path):
|
|
pdir = tmp_path / "meituan"
|
|
for i in range(10): # < 15
|
|
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.points == []
|
|
|
|
|
|
def test_missing_dir_not_readable(tmp_path):
|
|
res = read_stuck_points(tmp_path / "nope", threshold=15, max_tail=40)
|
|
assert res.readable is False
|
|
assert res.points == []
|
|
|
|
|
|
def test_empty_dir_no_platform_frames_not_readable(tmp_path):
|
|
(tmp_path / "emptysub").mkdir()
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.readable is False
|
|
|
|
|
|
def test_per_platform_one_stuck_one_normal(tmp_path):
|
|
m = tmp_path / "meituan"
|
|
for i in range(18):
|
|
_frame(m, i, "add_one_dish", "meal_detail_popup")
|
|
e = tmp_path / "eleme"
|
|
_frame(e, 0, "set_address", "home")
|
|
for i in range(1, 6):
|
|
_frame(e, i, "enter_store", "store")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.readable is True
|
|
assert res.points == [StuckPoint("meituan", "add_one_dish", 18, detected_page="meal_detail_popup")]
|
|
|
|
|
|
def test_last_step_returns_busiest_platform_last_env(tmp_path):
|
|
m = tmp_path / "meituan"
|
|
for i in range(20):
|
|
_frame(m, i, "add_one_dish", "meal_detail_popup")
|
|
e = tmp_path / "eleme"
|
|
for i in range(3):
|
|
_frame(e, i, "enter_store", "store")
|
|
sp = last_step(tmp_path, max_tail=40)
|
|
assert sp == StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup")
|
|
|
|
|
|
def test_stuck_point_label_includes_page():
|
|
# label 带页面(平台/环节中文 + 页面原始英文);无页面时只到环节
|
|
assert StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup").label() \
|
|
== "美团·加菜·meal_detail_popup"
|
|
assert StuckPoint("meituan", "add_one_dish", 20).label() == "美团·加菜"
|
|
|
|
|
|
def test_read_stuck_points_returns_last_frame(tmp_path):
|
|
# res.last = 帧数最多平台的末帧(带 detected_page),供"退出前在哪屏"用
|
|
m = tmp_path / "meituan"
|
|
for i in range(6):
|
|
_frame(m, i, "enter_store", "store")
|
|
_frame(m, 6, "add_one_dish", "menu") # 末帧换到 menu
|
|
e = tmp_path / "eleme"
|
|
_frame(e, 0, "set_address", "home")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.last == StuckPoint("meituan", "add_one_dish", 7, detected_page="menu")
|
|
assert res.points == [] # 没卡死,但末帧照样有
|
|
|
|
|
|
def test_dir_name_from_trace_url():
|
|
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc/") == "20260804_1_abc"
|
|
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc") == "20260804_1_abc"
|
|
assert dir_name_from_trace_url("") is None
|
|
assert dir_name_from_trace_url(None) is None
|
|
|
|
|
|
def test_corrupt_frame_does_not_crash(tmp_path):
|
|
# 截断的 UTF-8(pricebot 被 SIGTERM 打断的末帧)不应抛 UnicodeDecodeError
|
|
pdir = tmp_path / "meituan"
|
|
pdir.mkdir()
|
|
(pdir / "step_000.json").write_bytes(b'{"pipeline_step": "add\xff')
|
|
res = read_stuck_points(tmp_path, threshold=1, max_tail=40)
|
|
assert res.points == [] # 抠不出字段 → 不判卡死;关键是没崩溃
|
|
|
|
|
|
def _frame_ts(pdir: Path, idx: int, step: str, page: str, ts: str) -> None:
|
|
pdir.mkdir(parents=True, exist_ok=True)
|
|
body = {"pipeline_step": step, "detected_page": page, "timestamp": ts,
|
|
"windows": [{"n": ["x" * 200]}]}
|
|
(pdir / f"step_{idx:03d}.json").write_text(
|
|
json.dumps(body, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
|
|
|
|
def test_stuck_ms_computed_from_timestamps(tmp_path):
|
|
# 末段 16 帧都卡在 add_one_dish,timestamp 从 :00 到 :30(每帧+2s) → 卡住 30s
|
|
pdir = tmp_path / "meituan"
|
|
for i in range(16):
|
|
_frame_ts(pdir, i, "add_one_dish", "meal_detail_popup",
|
|
f"2026-08-04T12:00:{i * 2:02d}.000000")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert len(res.points) == 1
|
|
sp = res.points[0]
|
|
assert sp.frames == 16
|
|
assert sp.stuck_ms == 30000 # (第15帧:30 - 第0帧:00) = 30s
|
|
|
|
|
|
def test_stuck_ms_none_when_timestamp_missing(tmp_path):
|
|
# 帧无 timestamp 字段 → stuck_ms 为 None(不报错)
|
|
pdir = tmp_path / "meituan"
|
|
for i in range(16):
|
|
_frame(pdir, i, "add_one_dish", "meal_detail_popup") # 现有 _frame,无 timestamp
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert len(res.points) == 1
|
|
assert res.points[0].stuck_ms is None
|
|
|
|
|
|
def test_stuck_ms_none_when_clock_goes_backwards(tmp_path):
|
|
# 帧 timestamp 非单调(时钟回退):step_000=:30 ... step_015=:00 → 末帧早于段首 → 负时长 → 降级 None
|
|
pdir = tmp_path / "meituan"
|
|
for i in range(16):
|
|
_frame_ts(pdir, i, "add_one_dish", "meal_detail_popup",
|
|
f"2026-08-04T12:00:{(30 - i * 2):02d}.000000")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert len(res.points) == 1
|
|
assert res.points[0].stuck_ms is None # 负时长降级为 None
|
|
|
|
|
|
def test_last_step_computes_dwell_ms(tmp_path):
|
|
# 帧数最多平台末段 5 帧都在 checkout,ts :00→:08(每帧+2s) → 停留 8s
|
|
m = tmp_path / "meituan"
|
|
_frame_ts(m, 0, "enter_store", "store", "2026-08-07T12:00:00.000000")
|
|
_frame_ts(m, 1, "enter_store", "store", "2026-08-07T12:00:02.000000")
|
|
_frame_ts(m, 2, "enter_store", "store", "2026-08-07T12:00:04.000000")
|
|
for i in range(3, 8): # step3..7 checkout,末段 5 帧
|
|
_frame_ts(m, i, "checkout", "checkout_page",
|
|
f"2026-08-07T12:00:{(i - 3) * 2:02d}.000000")
|
|
sp = last_step(tmp_path, max_tail=40)
|
|
assert sp.platform == "meituan"
|
|
assert sp.pipeline_step == "checkout"
|
|
assert sp.frames == 8 # 总帧数(非末段)
|
|
assert sp.dwell_ms == 8000 # 末段 checkout :00→:08 = 8s
|
|
|
|
|
|
def test_last_step_dwell_none_without_ts(tmp_path):
|
|
m = tmp_path / "meituan"
|
|
for i in range(5):
|
|
_frame(m, i, "checkout", "checkout_page") # 无 timestamp
|
|
sp = last_step(tmp_path, max_tail=40)
|
|
assert sp.dwell_ms is None
|
|
|
|
|
|
def test_last_step_dwell_zero_single_frame_segment(tmp_path):
|
|
# 末帧与前一帧不同屏 → 末段只有末帧 1 帧 → 停留 0(一到就是末屏)
|
|
m = tmp_path / "meituan"
|
|
for i in range(4):
|
|
_frame_ts(m, i, "enter_store", "store", f"2026-08-07T12:00:{i:02d}.000000")
|
|
_frame_ts(m, 4, "checkout", "checkout_page", "2026-08-07T12:00:10.000000")
|
|
sp = last_step(tmp_path, max_tail=40)
|
|
assert sp.pipeline_step == "checkout"
|
|
assert sp.dwell_ms == 0
|
|
|
|
|
|
def test_read_stuck_points_last_has_dwell(tmp_path):
|
|
# 没卡死(末段<threshold),但末帧末段带 ts → last.dwell_ms 有值
|
|
m = tmp_path / "meituan"
|
|
_frame_ts(m, 0, "enter_store", "store", "2026-08-07T12:00:00.000000")
|
|
_frame_ts(m, 1, "enter_store", "store", "2026-08-07T12:00:02.000000")
|
|
for i in range(2, 5): # 末段 checkout 3 帧 :00→:04
|
|
_frame_ts(m, i, "checkout", "checkout_page",
|
|
f"2026-08-07T12:00:{(i - 2) * 2:02d}.000000")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.points == [] # 末段 3<15,没卡死
|
|
assert res.last.pipeline_step == "checkout"
|
|
assert res.last.frames == 5 # 总帧数
|
|
assert res.last.dwell_ms == 4000 # 末段 :00→:04 = 4s
|
|
|
|
|
|
def test_read_stuck_points_last_falls_through_when_busiest_last_frame_corrupt(tmp_path):
|
|
# 最忙平台末帧损坏(抠不出环节)→ _last_segment=None → 整段跳过(重构 continue 分支)
|
|
# → last 落到次忙的干净平台。锁定 read_stuck_points 重构的最险等价分支。
|
|
m = tmp_path / "meituan"
|
|
m.mkdir()
|
|
for i in range(7):
|
|
_frame(m, i, "add_one_dish", "menu")
|
|
(m / "step_007.json").write_bytes(b'{"pipeline_step": "add\xff') # 末帧截断 UTF-8
|
|
e = tmp_path / "eleme"
|
|
for i in range(3):
|
|
_frame(e, i, "enter_store", "store")
|
|
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
|
|
assert res.points == [] # 谁都没卡死
|
|
assert res.last is not None
|
|
assert res.last.platform == "eleme" # 最忙的 meituan(8帧)末帧损坏被跳过,last 落到 eleme
|
|
assert res.last.pipeline_step == "enter_store"
|