Skip to content

Commit 279986c

Browse files
committed
test(timing): 遮挡 hold + 清零规则回归测试
TestOcclusionNone: tick 层遮挡检测(灰色/非灰度遮挡物 → None) TestOcclusionHoldTimeSource: time_source 层遮挡 hold(pw=0/偏小值 → hold,正常 wrap 不 hold) TestBattleStateIntegration: 清零规则(费用条可见性 + BattleBegin + reentry reset)
1 parent 13cc4c4 commit 279986c

1 file changed

Lines changed: 380 additions & 1 deletion

File tree

tests/test_timing.py

Lines changed: 380 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ class TestNegativeCostReprojection:
194194

195195
@staticmethod
196196
def _make_negative_frame(fill_width: int) -> np.ndarray:
197-
"""1280x720 BGR 帧:费用条填充 + 减号标记(触发 detect_negative_cost)。"""
197+
"""1280x720 BGR 帧:费用条填充 + 减号标记(触发 detect_negative_cost)+ InBattle glyph。"""
198+
from aao.core.timing.battle_state import _battle_button_scale, _pause_glyph_rect
199+
198200
roi = tick.find_cost_bar_roi(1280, 720)
199201
x1, x2, y = roi
200202
frame = np.full((720, 1280, 3), 150, dtype=np.uint8)
@@ -203,6 +205,16 @@ def _make_negative_frame(fill_width: int) -> np.ndarray:
203205
# 减号 ROI 全白 → detect_negative_cost 返回 True
204206
sx, sy, sw, sh = config.COST_SIGN_ROI
205207
frame[sy : sy + sh, sx : sx + sw] = 255
208+
# 暂停 glyph ROI 填充亮像素(InBattle 信号)
209+
scale = _battle_button_scale(1280, 720)
210+
gl, gr, gt, gb = _pause_glyph_rect(1280, 720, scale)
211+
filled = 0
212+
for gy in range(gt, gb):
213+
for gx in range(gl, gr):
214+
if filled >= 635:
215+
break
216+
frame[gy, gx] = 255
217+
filled += 1
206218
return frame
207219

208220
@staticmethod
@@ -331,3 +343,370 @@ def test_from_dict_v2_no_migration(self) -> None:
331343
assert pm["0"] == 0
332344
assert pm["5"] == 1
333345
assert pm["10"] == 3
346+
347+
348+
class TestOcclusionNone:
349+
"""遮挡检测(tick 层):灰色遮挡物截断白色填充 → None → hold。
350+
351+
_contiguous_fill_width 检测到 edge 后仍有白色像素 → 费用条被遮挡 → None。
352+
正常费用条末端(edge 后全灰,无白色)→ 返回 edge。
353+
"""
354+
355+
@staticmethod
356+
def _make_calibration(total: int = 30) -> FullCalibrationData:
357+
pixel_map = {str(w): w - 1 for w in range(1, total)} | {"0": 0}
358+
return FullCalibrationData(
359+
detection_mode="single",
360+
profiles=[CalibrationProfile(total_frames=total, pixel_map=pixel_map)],
361+
screen_width=1280,
362+
screen_height=720,
363+
)
364+
365+
@staticmethod
366+
def _make_cost_bar_frame(fill_width: int) -> np.ndarray:
367+
"""构造有费用条填充的帧 + InBattle 暂停 glyph。"""
368+
from aao.core.timing import tick as tick_mod
369+
from aao.core.timing.battle_state import _battle_button_scale, _pause_glyph_rect
370+
371+
roi = tick_mod.find_cost_bar_roi(1280, 720)
372+
x1, x2, y = roi
373+
frame = np.full((720, 1280, 3), 150, dtype=np.uint8)
374+
if fill_width > 0:
375+
frame[y, x1 : x1 + fill_width] = 255
376+
scale = _battle_button_scale(1280, 720)
377+
gl, gr, gt, gb = _pause_glyph_rect(1280, 720, scale)
378+
filled = 0
379+
for gy in range(gt, gb):
380+
for gx in range(gl, gr):
381+
if filled >= 635:
382+
break
383+
frame[gy, gx] = 255
384+
filled += 1
385+
return frame
386+
387+
def test_gray_occluder_returns_none(self) -> None:
388+
"""灰色遮挡物夹在白色填充中间 → None(费用条被遮挡)。"""
389+
from aao.core.timing import tick as tick_mod
390+
from aao.core.timing.tick import get_filled_pixel_width
391+
392+
roi = tick_mod.find_cost_bar_roi(1280, 720)
393+
x1, x2, y = roi
394+
bar_w = x2 - x1
395+
frame = self._make_cost_bar_frame(bar_w) # 填满整条
396+
# 在费用条 1/3 处插入灰色遮挡物,遮挡物之后仍有白色
397+
mid = x1 + bar_w // 3
398+
frame[y, mid : mid + 5] = 150 # 灰色遮挡物
399+
pw = get_filled_pixel_width(frame, roi)
400+
assert pw is None
401+
402+
def test_non_gray_occluder_returns_none(self) -> None:
403+
"""非灰度遮挡物夹在白色填充中间 → None。"""
404+
from aao.core.timing import tick as tick_mod
405+
from aao.core.timing.tick import get_filled_pixel_width
406+
407+
roi = tick_mod.find_cost_bar_roi(1280, 720)
408+
x1, x2, y = roi
409+
bar_w = x2 - x1
410+
frame = self._make_cost_bar_frame(bar_w)
411+
mid = x1 + bar_w // 3
412+
frame[y, mid : mid + 5] = [0, 0, 255] # 彩色遮挡物
413+
pw = get_filled_pixel_width(frame, roi)
414+
assert pw is None
415+
416+
def test_normal_fill_returns_width(self) -> None:
417+
"""正常费用条(白色填充 + 灰色末端)→ 返回正确宽度。"""
418+
from aao.core.timing import tick as tick_mod
419+
from aao.core.timing.tick import get_filled_pixel_width
420+
421+
roi = tick_mod.find_cost_bar_roi(1280, 720)
422+
frame = self._make_cost_bar_frame(200)
423+
pw = get_filled_pixel_width(frame, roi)
424+
assert pw is not None
425+
assert pw > 0
426+
427+
def test_occlusion_hold_in_time_source(self) -> None:
428+
"""遮挡 → lf=None → TimeSource hold previous_frame,不跳帧。"""
429+
cal = self._make_calibration()
430+
ts = TimeSource(cal)
431+
ts.update(self._make_cost_bar_frame(15))
432+
held = ts.previous_frame
433+
from aao.core.timing import tick as tick_mod
434+
435+
roi = tick_mod.find_cost_bar_roi(1280, 720)
436+
x1, x2, y = roi
437+
bar_w = x2 - x1
438+
frame = self._make_cost_bar_frame(bar_w)
439+
mid = x1 + bar_w // 3
440+
frame[y, mid : mid + 5] = 150 # 灰色遮挡物
441+
result = ts.update(frame)
442+
assert result == held # hold,不跳
443+
assert ts.previous_frame == held
444+
445+
def test_wrap_still_works(self) -> None:
446+
"""正常 wrap(lf 29→1)→ cycle_counter++,遮挡检测不干扰。"""
447+
cal = self._make_calibration()
448+
ts = TimeSource(cal)
449+
ts.update(self._make_cost_bar_frame(29))
450+
assert ts.cycle_counter == 0
451+
ts.update(self._make_cost_bar_frame(1))
452+
assert ts.cycle_counter == 1
453+
454+
455+
class TestOcclusionHoldTimeSource:
456+
"""遮挡 hold(time_source 层):pw=0 伪装成空费用条 → lf=0。
457+
458+
实际遮挡场景(来自游戏日志):
459+
- 费用条左端被灰色遮挡物盖住 → pw=0 → lf=0
460+
- 但 _prev_measured 在中间值(非满条)→ 不是正常 wrap → hold
461+
462+
遮挡 hold 规则:
463+
- lf 大幅回退(> _WRAP_MARGIN)
464+
- 且 lf 在中间值(>= _WRAP_MARGIN)→ hold(遮挡导致读到偏小值)
465+
- 或 lf 接近 0(< _WRAP_MARGIN)但 prev 不在满条附近 → hold(遮挡导致 pw=0)
466+
- 正常 wrap(prev 在满条附近 → lf=0)→ 不 hold
467+
- 负费切换(neg_changed)→ 不 hold
468+
"""
469+
470+
@staticmethod
471+
def _make_calibration(total: int = 30) -> FullCalibrationData:
472+
pixel_map = {str(w): w - 1 for w in range(1, total)} | {"0": 0}
473+
return FullCalibrationData(
474+
detection_mode="single",
475+
profiles=[CalibrationProfile(total_frames=total, pixel_map=pixel_map)],
476+
screen_width=1280,
477+
screen_height=720,
478+
)
479+
480+
@staticmethod
481+
def _make_frame(fill_width: int) -> np.ndarray:
482+
"""InBattle 帧 + 费用条填充。fill_width=0 → 空费用条(全灰)。"""
483+
from aao.core.timing import tick as tick_mod
484+
from aao.core.timing.battle_state import _battle_button_scale, _pause_glyph_rect
485+
486+
roi = tick_mod.find_cost_bar_roi(1280, 720)
487+
x1, x2, y = roi
488+
frame = np.full((720, 1280, 3), 150, dtype=np.uint8)
489+
if fill_width > 0:
490+
frame[y, x1 : x1 + fill_width] = 255
491+
scale = _battle_button_scale(1280, 720)
492+
gl, gr, gt, gb = _pause_glyph_rect(1280, 720, scale)
493+
filled = 0
494+
for gy in range(gt, gb):
495+
for gx in range(gl, gr):
496+
if filled >= 635:
497+
break
498+
frame[gy, gx] = 255
499+
filled += 1
500+
return frame
501+
502+
@staticmethod
503+
def _width_for_frame(target_lf: int, total: int = 30) -> int:
504+
"""display frame → 像素宽度(查测试 calibration 的 pixel_map 反转)。
505+
506+
测试用 pixel_map: {0:0, 1:0, 2:1, ..., 29:28}。
507+
display = internal+1(width=0 → display=0)。
508+
key=w, value=w-1, display=w-1+1=w → display=target_lf → key=target_lf。
509+
"""
510+
if target_lf == 0:
511+
return 0
512+
return target_lf # pixel_map key=target_lf → display=target_lf
513+
514+
def test_occlusion_pw0_mid_value_holds(self) -> None:
515+
"""遮挡 pw=0:lf 16→0(prev 不满条)→ hold,不 wrap。"""
516+
cal = self._make_calibration()
517+
ts = TimeSource(cal)
518+
# 喂到 lf=16(中间值)
519+
w16 = self._width_for_frame(16)
520+
ts.update(self._make_frame(w16))
521+
assert ts.previous_frame >= 0
522+
held = ts.previous_frame
523+
# 遮挡 → 空费用条 → lf=0
524+
ts.update(self._make_frame(0))
525+
assert ts.cycle_counter == 0 # 不 wrap
526+
assert ts.previous_frame == held # hold
527+
528+
def test_occlusion_pw0_high_value_holds(self) -> None:
529+
"""遮挡 pw=0:lf 23→0(prev 不满条)→ hold。"""
530+
cal = self._make_calibration()
531+
ts = TimeSource(cal)
532+
w23 = self._width_for_frame(23)
533+
ts.update(self._make_frame(w23))
534+
held = ts.previous_frame
535+
ts.update(self._make_frame(0))
536+
assert ts.cycle_counter == 0
537+
assert ts.previous_frame == held
538+
539+
def test_normal_wrap_from_full_not_held(self) -> None:
540+
"""正常 wrap:lf 29→0(prev 在满条附近)→ wrap,不 hold。"""
541+
cal = self._make_calibration()
542+
ts = TimeSource(cal)
543+
w29 = self._width_for_frame(29)
544+
ts.update(self._make_frame(w29))
545+
assert ts.cycle_counter == 0
546+
ts.update(self._make_frame(0))
547+
assert ts.cycle_counter == 1 # wrap
548+
549+
def test_normal_wrap_from_near_full_not_held(self) -> None:
550+
"""正常 wrap:lf 27→0(prev=27 >= 27)→ wrap。"""
551+
cal = self._make_calibration()
552+
ts = TimeSource(cal)
553+
w27 = self._width_for_frame(27)
554+
ts.update(self._make_frame(w27))
555+
ts.update(self._make_frame(0))
556+
assert ts.cycle_counter == 1
557+
558+
def test_occlusion_mid_value_to_mid_value_holds(self) -> None:
559+
"""遮挡 pw=偏小值:lf 22→15(中间值回退)→ hold。"""
560+
cal = self._make_calibration()
561+
ts = TimeSource(cal)
562+
w22 = self._width_for_frame(22)
563+
w15 = self._width_for_frame(15)
564+
ts.update(self._make_frame(w22))
565+
held = ts.previous_frame
566+
ts.update(self._make_frame(w15))
567+
assert ts.cycle_counter == 0
568+
assert ts.previous_frame == held
569+
570+
def test_occlusion_recovery(self) -> None:
571+
"""遮挡恢复:lf 16→0(hold)→17(恢复) → 正常更新。"""
572+
cal = self._make_calibration()
573+
ts = TimeSource(cal)
574+
w16 = self._width_for_frame(16)
575+
ts.update(self._make_frame(w16))
576+
# 遮挡
577+
ts.update(self._make_frame(0))
578+
assert ts.cycle_counter == 0
579+
# 恢复
580+
w17 = self._width_for_frame(17)
581+
ts.update(self._make_frame(w17))
582+
assert ts.cycle_counter == 0 # 仍未 wrap
583+
assert ts.previous_frame >= 0 # 正常更新
584+
585+
586+
class TestBattleStateIntegration:
587+
"""清零规则:费用条可见性 + BattleBegin 双路径。
588+
589+
- InBattle → arm + 读费用条
590+
- NotInBattle + 费用条可见(暂停)→ 不清零
591+
- NotInBattle + 费用条不可见(lf=None)连续 >= 30 帧 → 标记重入清零
592+
- 重新 InBattle + 标记 → reset(进关卡清计时器)
593+
- BattleBegin + armed → reset + disarm
594+
"""
595+
596+
@staticmethod
597+
def _make_calibration(total: int = 30) -> FullCalibrationData:
598+
pixel_map = {str(w): w - 1 for w in range(1, total)} | {"0": 0}
599+
return FullCalibrationData(
600+
detection_mode="single",
601+
profiles=[CalibrationProfile(total_frames=total, pixel_map=pixel_map)],
602+
screen_width=1280,
603+
screen_height=720,
604+
)
605+
606+
@staticmethod
607+
def _make_in_battle_frame(fill_width: int) -> np.ndarray:
608+
"""InBattle 帧:暂停 glyph ROI 亮 + 费用条填充。
609+
610+
1280x720: 暂停 glyph ROI (1188, 1230, 38, 69)。
611+
需要亮像素归一化面积 ~635(播放区间 [560,710])→ 填充 635 个亮像素。
612+
"""
613+
from aao.core.timing import tick as tick_mod
614+
from aao.core.timing.battle_state import _battle_button_scale, _pause_glyph_rect
615+
616+
roi = tick_mod.find_cost_bar_roi(1280, 720)
617+
x1, x2, y = roi
618+
frame = np.full((720, 1280, 3), 150, dtype=np.uint8)
619+
if fill_width > 0:
620+
frame[y, x1 : x1 + fill_width] = 255
621+
# 暂停 glyph ROI 填充亮像素(播放状态)
622+
scale = _battle_button_scale(1280, 720)
623+
gl, gr, gt, gb = _pause_glyph_rect(1280, 720, scale)
624+
# 填充前 635 个像素(归一化面积 635,在 [560,710] 内)
625+
filled = 0
626+
target = 635
627+
for gy in range(gt, gb):
628+
for gx in range(gl, gr):
629+
if filled >= target:
630+
break
631+
frame[gy, gx] = 255
632+
filled += 1
633+
if filled >= target:
634+
break
635+
return frame
636+
637+
@staticmethod
638+
def _make_not_in_battle_frame() -> np.ndarray:
639+
"""NotInBattle 帧:两个 glyph ROI 全暗 + 费用条 ROI 末端非灰度 → None。
640+
641+
全黑帧会让 get_filled_pixel_width 返回 0 → lf=0 → 误 wrap。
642+
末端放彩色像素 → None → hold,模拟真实遮挡场景。
643+
"""
644+
from aao.core.timing import tick as tick_mod
645+
646+
frame = np.full((720, 1280, 3), 0, dtype=np.uint8)
647+
roi = tick_mod.find_cost_bar_roi(1280, 720)
648+
x1, x2, y = roi
649+
frame[y, x2 - 1] = [200, 0, 0] # 末端非灰度 → None
650+
return frame
651+
652+
def test_in_battle_reads_cost_bar_and_arms(self) -> None:
653+
"""InBattle 帧 + 费用条填充 → 正常读帧 + arm。"""
654+
cal = self._make_calibration()
655+
ts = TimeSource(cal)
656+
result = ts.update(self._make_in_battle_frame(15))
657+
assert result is not None
658+
assert ts.previous_frame >= 0
659+
assert ts._battle_begin_reset_armed is True
660+
661+
def test_not_in_battle_with_cost_bar_no_reset(self) -> None:
662+
"""NotInBattle + 费用条仍可见(暂停)→ 不标记清零。"""
663+
cal = self._make_calibration()
664+
ts = TimeSource(cal)
665+
ts.update(self._make_in_battle_frame(15))
666+
# NotInBattle 但费用条有填充(暂停时费用条可见)
667+
from aao.core.timing import tick as tick_mod
668+
669+
roi = tick_mod.find_cost_bar_roi(1280, 720)
670+
x1, x2, y = roi
671+
for _ in range(100):
672+
# 全灰背景 + 费用条填充(末端灰度 → get_filled_pixel_width 非 None)
673+
frame = np.full((720, 1280, 3), 150, dtype=np.uint8)
674+
frame[y, x1 : x1 + 15] = 255 # 费用条填充 → lf != None
675+
ts.update(frame)
676+
assert ts._needs_reentry_reset is False # 未标记清零
677+
678+
def test_not_in_battle_no_cost_bar_marks_reset(self) -> None:
679+
"""NotInBattle + 费用条不可见(lf=None)连续 >= 180 帧 → 标记清零。"""
680+
cal = self._make_calibration()
681+
ts = TimeSource(cal)
682+
ts.update(self._make_in_battle_frame(15))
683+
assert ts._needs_reentry_reset is False
684+
# 180 帧 NotInBattle + 费用条不可见(None)
685+
for _ in range(180):
686+
ts.update(self._make_not_in_battle_frame())
687+
assert ts._needs_reentry_reset is True # 标记清零
688+
689+
def test_reentry_in_battle_resets(self) -> None:
690+
"""标记清零后 → InBattle → reset(进关卡清计时器)。"""
691+
cal = self._make_calibration()
692+
ts = TimeSource(cal)
693+
ts.update(self._make_in_battle_frame(15))
694+
assert ts._total_frames > 0
695+
# 180 帧 NotInBattle + 费用条不可见 → 标记
696+
for _ in range(180):
697+
ts.update(self._make_not_in_battle_frame())
698+
assert ts._needs_reentry_reset is True
699+
# 重新 InBattle → reset
700+
ts.update(self._make_in_battle_frame(5))
701+
assert ts._needs_reentry_reset is False
702+
assert ts.cycle_base_frames == 0
703+
assert ts.timer_offset == 0
704+
705+
def test_short_no_cost_bar_no_reset(self) -> None:
706+
"""NotInBattle + 费用条不可见 < 180 帧 → 不标记清零。"""
707+
cal = self._make_calibration()
708+
ts = TimeSource(cal)
709+
ts.update(self._make_in_battle_frame(15))
710+
for _ in range(179):
711+
ts.update(self._make_not_in_battle_frame())
712+
assert ts._needs_reentry_reset is False # 未到阈值

0 commit comments

Comments
 (0)