Files
shaguabijia-app-server/tests/test_trace_stuck.py
T
guke 2e86d2ce27 test(compare-alert): 补 review 缺口——continue 分支回归 + <1s 边界
- 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>
2026-08-07 18:05:39 +08:00

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"