Files
gplan-2/game/tests/unit/presentation/pacing_queue_test.gd
T

276 lines
13 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# 轮间结算节奏化断言(逻辑先行断言面):
# ① 演出队列排空前后核心逻辑状态逐位一致(演出关停不产生分叉——排播队列零核心触碰的结构证明);
# ② 零丢行:step_row 信号总数 = 明细带实收行数(排播/排空两路都不吞行);
# ③ 段间延迟取数面双态(常态 >0 / 减动效 =0 归零不跳过——pace_delay_for 直读)。
# 载体:main.tscn 实例(同 ui_click_probe 形态——组合根全链在座),真实冒险驱动至首战结算后断言。
# 运行:godot --headless --path game --script res://tests/unit/presentation/pacing_queue_test.gd(expect 0)
extends SceneTree
const RowPacerScript := preload("res://presentation/row_pacer.gd")
var _problems: Array = []
# 记参假站桩(d12 排播半):继承真类以过类型面,只记解析入参
class FakeStage extends BattleStageView:
var calls: Array[Array] = []
var fx_rows: Array[Dictionary] = [] # on_step_row_fx 收到的行(FX 链计数载体)
func on_step_row_fx(row: Dictionary) -> void:
fx_rows.append(row.duplicate(true))
func enemy_standee_center(target_id: String, display_name: String) -> Variant:
calls.append([target_id, display_name])
return Vector2(10, 10)
func hero_standee_center() -> Vector2:
return Vector2(10, 10)
# 记参假演出层(同上——play_* 全部只记不演)
class FakeFx extends SettlementFxView:
var calls: Array[Array] = []
func play_projectile(_from: Vector2, _to: Vector2, _glyph: String, _col: Color, _crit: bool = false) -> void:
calls.append(["projectile", _to])
func play_damage_number(_pos: Vector2, _text: String, _col: Color, _crit: bool = false) -> void:
calls.append(["mark", _pos])
func play_status_pulse(_pos: Vector2, _col: Color) -> void:
calls.append(["pulse", _pos])
func check(cond: bool, msg: String) -> void:
# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组)
if not cond:
_problems.append(msg)
func _init() -> void:
# 判据出处: design/gdd/systems/表现与支撑/HUD框架.md:272 (排播节奏——排空零分叉/零丢行/延迟双态组)
var main: Node = load("res://presentation/main.tscn").instantiate()
root.add_child(main)
await process_frame
# 隔离换注(SaveManager 是 autoload——原地 attach:先删后建防陈旧档泄入,
# 读盘在 attach 内完成;meta 经 save 引用透传零重建)
for p in ["user://test_pacing_queue_save.json", "user://test_pacing_queue_save.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var sm = main.get_node("/root/SaveManager")
sm.slots.attach_path("user://test_pacing_queue_save.json")
sm.meta_state.mark_first_adventure_done()
# 行信号计数器(真实信号路径旁挂计数——零触碰既有接线)
var rows_seen: Array = []
main._start_adventure_from_menu()
await process_frame
# 入口停点选路进首战(真实停点链)
if not main.ui.modals.run.level_screen.visible:
_problems.append("入口停点未呈现(前置失守)")
_finish()
return
main.run_scope.runner.pick_next_node(int(main.run_scope.runner.next_candidate_ids()[0]))
await process_frame
# 等首战开打(board_changed 同步链——两帧余量)
await process_frame
var battle = main.run_scope.runner.current_battle()
if battle == null:
_problems.append("首战未开打(排播断言无载体)")
_finish()
return
battle.step_row.connect(_count_row.bind(rows_seen))
# 理性策略挖到触发结算(≥1 行入队即达断言前提——AP 一次消耗;挖格须邻英雄——邻接优先排序)
var ms = battle.minesweeper
# 理性策略驱动(auto_player 单一实现复用——damage_probe 同款两步消费:先 move 到 hero_cell 再 dig)
var ap = load("res://sim/auto_player.gd")
var acted := false
for guard in range(40):
var plan: Dictionary = ap.choose_dig(ms)
if plan.is_empty():
break
if plan.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if battle.action_economy.phase != "Active":
break
var outcomes: Array = ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
acted = not outcomes.is_empty() and bool(outcomes[0].accepted)
if acted:
break
# 轮末结算触发(step_row 行族只在轮末 emit——停手即结,damage_probe 同款)
if battle.action_economy.phase == "Active":
ms.settle_batch([{"action": "stop"}])
if not acted:
_problems.append("首战无受理动作(队列断言无行)")
_finish()
return
await process_frame
# ---- 断言①:排空前后核心状态逐位一致(演出关停零分叉)----
var snap_before: String = JSON.stringify(battle.diagnostic_snapshot())
var hp_before: int = int(battle.action_economy.ap) + int(battle.action_economy.rap) * 0 # 观测面双录
main.ui.screens.row_pacer.flush()
var snap_after: String = JSON.stringify(battle.diagnostic_snapshot())
check(snap_before == snap_after, "排空前后核心诊断快照逐位一致(AC⑤ 演出关停零分叉)")
# ---- 断言②:零丢行(逐行文字列表退役后改判〔退役改 V-退役卡 R2〕:结构判据=排空后队列恒空,
# 行的落地面由「原文在明细带」改为「逐行进 FX 链」——由下方 _assert_fx_chain 对全行集断言承接)----
check(rows_seen.size() > 0, "结算行已产生(断言前提)")
check(main.ui.screens.row_pacer.pending_rows().is_empty(), "排空后队列恒空(flush 完整性)")
# ---- 断言②改判承接:FX 链逐行收拍(真实行集驱动——每行一拍站桩反馈,弹道/标出按型计数逐位对上)----
_assert_fx_chain(main, rows_seen)
# ---- 断言③:段间延迟取数面双态 ----
UiKit.motion_scale = 1.0
var d_on: float = main.pace_delay_for("damage", "heal")
var d_same: float = main.pace_delay_for("damage", "damage")
check(d_on > 0.0 and d_on >= d_same, "常态段间延迟 >0 且 ≥ 段内(%f/%f)" % [d_on, d_same])
UiKit.motion_scale = 0.5
check(main.pace_delay_for("damage", "heal") == 0.0, "减动效段间延迟归零(行不跳过——同帧直落快路)")
UiKit.motion_scale = 1.0
# ---- 断言④:排播两处取用按行 target_id 解析站桩(d12 排播半——假 stage 记参)----
var rp = RowPacerScript.new()
var fstage := FakeStage.new(null, root.get_node("/root/ConfigDB").presentation.battle_layout)
var ffx := FakeFx.new()
rp.stage = fstage
rp.board_view = main.ui.battle.board_zone.board_view
rp.settlement_fx = ffx
rp._play_settlement_mark({"type": "damage", "target": "地精", "target_id": "goblin_b", "value": 3})
check(fstage.calls.size() == 1 and fstage.calls[0] == ["goblin_b", "地精"],
"结算标出按行 target_id 解析站桩(id 与名同传——d12)〔wd12〕")
rp._play_cross_zone_fx({"type": "damage", "target": "地精", "target_id": "goblin_b", "value": 3})
check(fstage.calls.size() == 2 and fstage.calls[1] == ["goblin_b", "地精"],
"跨区弹道按行 target_id 解析站桩(id 与名同传——d12)〔wd12〕")
check(ffx.calls.size() == 2, "两路解析成功后演出层各接一拍(记参面非空)〔wd12〕")
rp.free()
# ---- 断言⑤:会话更替即清队(wr4——前置夹具入队再走真实入口 _begin_session;
# 无夹具则改前改后皆绿:flush 无队列可清时两种形态同值)----
UiKit.motion_scale = 1.0 # 常态入队(减动效快路同帧直落不入队——夹具须真落队列)
main._on_step_row({"type": "damage", "target": "hero", "value": 1})
main._on_step_row({"type": "heal", "target": "hero", "value": 2})
check(main.ui.screens.row_pacer.pending_rows().size() == 2, "会话更替夹具已入队(前提:%d 行)〔wr4〕" % main.ui.screens.row_pacer.pending_rows().size())
main.begin_session(false)
check(main.ui.screens.row_pacer.pending_rows().is_empty(), "会话更替后待播队列清零(_begin_session 内机制 flush)〔wr4〕")
_finish()
func _count_row(row: Dictionary, arr: Array) -> void:
arr.append(row.duplicate(true))
# FX 排播链断言(逐行文字列表退役后的行落地面——〔退役改 V-退役卡 R2〕):
# 判据出处: design/ux/battle.md:439 动效:结算逐行兑现为信息载体不可整体跳过,可加倍(减动效只调时长)
# 判据出处: design/gdd/08-Alpha版本标准.md:154 战斗演出链(弹道 / 受击 / 血条 / 灰化)在役
# 真实行集(夹具采样的 step_row 载荷)驱动独立 RowPacer,断言:
# ① 每行恰好一拍站桩反馈(on_step_row_fx 计数=行数);② 弹道/标出调用计数与行型分布逐位对上
# (期望从行集独立推导、实际从记参假件读——不镜像实现);③ 断言前提=行集非空(非空自检)。
# 记参假棋盘 FX(highlight 分支载体——perfect sweep / seal hit 弹道只记不演)
class FakeBoardFx extends BoardFxView:
var calls: Array[String] = []
func play_perfect_sweep() -> void:
calls.append("perfect_sweep")
func play_seal_hit_projectile(_heavy: bool) -> void:
calls.append("seal_hit_projectile")
# 记参假 toast(skill_unlock 行载体)
class FakeToast extends ToastStack:
var texts: Array[String] = []
func show_toast(text: String, _tone: String = "info") -> void:
texts.append(text)
# 记参假棋盘区(_consume_row highlight 分支的 fx/toast 载体)
class FakeBoardZone extends BoardZoneView:
var board_fx := FakeBoardFx.new()
var toast_stack := FakeToast.new()
func _assert_fx_chain(main: Node, rows_seen: Array) -> void:
var rp = RowPacerScript.new()
var fstage := FakeStage.new(null, root.get_node("/root/ConfigDB").presentation.battle_layout)
var ffx := FakeFx.new()
var fzone := FakeBoardZone.new(root.get_node("/root/ConfigDB").presentation.battle_layout)
fzone.fx = fzone.board_fx
fzone.toast = fzone.toast_stack
rp.stage = fstage
rp.board_view = main.ui.battle.board_zone.board_view
rp.settlement_fx = ffx
rp.board_zone = fzone
rp.refresh_request = func() -> void: pass
rp.pump_ended = func() -> void: pass
for row in rows_seen:
rp.enqueue(row.duplicate(true))
rp.flush()
check(rows_seen.size() > 0, "FX 链断言前提:行集非空(非空自检)")
check(fstage.fx_rows.size() == rows_seen.size(),
"每行恰好一拍站桩反馈(on_step_row_fx %d/%d)〔退役改 V-退役卡 R2〕" % [fstage.fx_rows.size(), rows_seen.size()])
# 期望计数从行集独立推导(演出语义=0.4 硬契约:伤害行弹道+数字、治疗行弹道+数字、效果行色环)
var exp_projectile := 0
var exp_mark := 0
var exp_pulse := 0
for row in rows_seen:
var t := String(row.get("type", ""))
var tgt := String(row.get("target", ""))
if t == "damage":
exp_projectile += 1
exp_mark += 1
elif t == "heal" and (tgt == "hero" or tgt == ""):
exp_projectile += 1
exp_mark += 1
elif t == "heal":
exp_mark += 1
elif t == "effect":
exp_pulse += 1
var got_projectile := 0
var got_mark := 0
var got_pulse := 0
for c in ffx.calls:
match String(c[0]):
"projectile": got_projectile += 1
"mark": got_mark += 1
"pulse": got_pulse += 1
check(got_projectile == exp_projectile,
"跨区弹道计数与行型分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [got_projectile, exp_projectile])
check(got_mark == exp_mark,
"结算标出数字计数与行型分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [got_mark, exp_mark])
check(got_pulse == exp_pulse,
"状态色环计数与行型分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [got_pulse, exp_pulse])
# highlight 三路(完美封印横幅=高光行非解锁技;封印命中加重弹道=seal_hit 高亮键不限行型;
# Boss 解锁技名 toast=skill_unlock 键)——期望按行协议语义独立推导
var exp_sweep := 0
var exp_seal_hit := 0
var exp_toast := 0
for row in rows_seen:
var t := String(row.get("type", ""))
var hl := String(row.get("highlight", ""))
if t == "highlight" and hl != "skill_unlock":
exp_sweep += 1
elif hl == "seal_hit":
exp_seal_hit += 1
if hl == "skill_unlock":
exp_toast += 1
check(fzone.board_fx.calls.count("perfect_sweep") == exp_sweep,
"完美封印横幅计数与行分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [fzone.board_fx.calls.count("perfect_sweep"), exp_sweep])
check(fzone.board_fx.calls.count("seal_hit_projectile") == exp_seal_hit,
"封印命中弹道计数与行分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [fzone.board_fx.calls.count("seal_hit_projectile"), exp_seal_hit])
check(fzone.toast_stack.texts.size() == exp_toast,
"解锁技名 toast 计数与行分布逐位一致(%d/%d)〔退役改 V-退役卡 R2〕" % [fzone.toast_stack.texts.size(), exp_toast])
rp.free()
func _finish() -> void:
# 判据出处: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组)
for p in ["user://test_pacing_queue_save.json", "user://test_pacing_queue_save.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
if _problems.is_empty():
print("[pacing_queue_test] PASS — 排空零分叉/零丢行/延迟双态 全绿")
quit(0)
else:
for p in _problems:
push_error("[pacing_queue_test] FAIL — " + p)
print("[pacing_queue_test] FAIL — " + p)
quit(1)