feat(迭代29 T3): L2 集成层建承载——integration/ 首个用例 + parity 收编为条件闸件

建 game/tests/integration/(此前只有章程句无实体):首个集成用例 save_rng_roundtrip_test.gd
=存档 ⇄ RNG 快照往返,17 条断言,跨 SaveManager(落盘+版本迁移链)× AdventureRunner(断点消费)
× RngService(续跑零跳号)× 停点态确定性。与 unit/core/breakpoint_resume_test 的分工:那件是单测
只验首战开板一致,本件验跨系统闭环——含「旧版本档经迁移链后断点仍可续玩」这一真实玩家路径。
并入 run_all manifest(27 套,零新机制)。
变异验证:把断点 rng_counter 改坏 → 精确命中「续玩取数号位 = 断点号位」断言见红。

tools/run_all_gates.sh 新增 11 号「回放确定性对拍」**条件闸件**:每次跑批都对 merge-base 做一遍
触面判定(data/tables/rng_service/save_manager/meta_progression/settlement/damage_math/
damage_controller/battle_orchestrator/adventure_monte_carlo),未触面 SKIP;触面也**不自动跑**——
对拍调的是 MC 脚本,属「跑批发起前须用户批准」红线,闸只负责把触面判定做一遍并显式要求批准
(批准后 PARITY_APPROVED=1 重跑),杜绝「该跑却没人记得」。指纹逻辑把 SKIP/NEED-APPROVAL 同记 skip,
两种未跑状态互变不算闸况变化。baseline.json 登记 11 闸条目(新增闸件否则会判「无基线」而红)。

README:目录布局增 integration/;新增「测试三层与承载目录」节(L1/L2/L3 各自的判什么/承载/入闸方式
与分层判据);integration 章程句由「将来落地」改为「已建承载」。
This commit is contained in:
DKChan committed 2026-10-16 15:20:25 +08:00
1 parent 853bd12baa
commit c4fa65ef44
5 files changed
+197 -3

No files matched your search

+5 -1
View File
@@ -44,6 +44,10 @@
"10 熵增闸": {
"exit": "0",
"fp": "green"
},
"11 回放确定性对拍": {
"exit": "SKIP",
"fp": "skip"
}
}
}
}
+16 -1
View File
@@ -11,13 +11,28 @@
```
tests/
unit/ # 隔离单元测试(公式/状态机/纯逻辑)——extends SceneTree + quit(code)
integration/ # L2 集成用例(跨系统闭环——与 unit/ 的隔离断言面分工,见下「分层」节)
smoke/ # critical-paths.md——人工 gate 清单(/smoke-check 读取)
evidence/ # 截图与人工签核记录
run_all.gd # 单元套件统一入口(套件 manifest 协议,D2 AC-2b)
static_scan.sh # 静态扫描域 S1–S8 可执行子集
```
> integration/ 目录已删空壳(迭代 19 卡① T4/Q8 2026-10-08——彼时仅有章程 README 无实体)。章程句保留在此:将来跨系统测试(Core-Core 集成、回放确定性〔同种子步骤流逐行相同——B1 AC-33 形态〕、存档/RNG 快照往返、A1×A2 双包含端到端)落地时随批建 `tests/integration/` 迁入;各 GDD 登记的 deferral 集成用例以对侧登记为生效条件。
## 测试三层与承载目录
| 层 | 判什么 | 承载 | 入闸方式 |
|---|---|---|---|
| **L1 单元** | 最单元的逻辑代码对不对(公式/状态机/纯逻辑) | `tests/unit/` | `run_all.gd` manifest(闸 02) |
| **L2 集成** | 跨系统的闭环对不对 | `tests/integration/` | 同上(与单元套件同一入口、同一协议) |
| **L3 系统/场景** | 跑起来之后 UI 与场景切换对不对 | `game/smoke/*.tscn` 探针 | `probe_gate.sh`(闸 03/04) |
> **integration/ 已建承载**(迭代 29 T3——此前只有章程句无实体,空壳曾于迭代 19 卡① T4 删过一次)。
> 首个集成用例 = `save_rng_roundtrip_test.gd`(存档⇄RNG 快照往返)。章程登记的四类集成用例里,
> 回放确定性已由 `tools/parity_anchor.py` 承担并收编为重闸 11 号条件闸件;
> Core-Core 与 A1×A2 端到端仍随属主批增量登记;各 GDD 登记的 deferral 集成用例以对侧登记为生效条件。
>
> **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = L1;
> 需要两个以上真实系统协同并走落盘/落库 = L2;需要完整项目引导与场景树 = L3。
## 断言计数双口径(2026-10-08 迭代 19 卡① T4 订正)
@@ -0,0 +1,132 @@
# tests/integration/save_rng_roundtrip_test.gd — L2 集成用例:存档 ⇄ RNG 快照往返
# 跨系统面 = SaveManager(落盘 + 版本迁移链)× AdventureRunner(断点消费)×
# RngService(种子/取数号位续跑)× BoardGenerator(开板确定性)。
# 与 tests/unit/core/breakpoint_resume_test.gd 的分工:那件是单测,只验「断点写读 + 首战开板一致」;
# 本件验的是**跨系统闭环**——盘上往返(含旧版本档经迁移链)之后,断点仍能被冒险层消费且 RNG 续跑
# 零跳号,是「玩家的旧存档还能不能接着打」这条真实路径。
# 运行:godot --headless --path game --script res://tests/integration/save_rng_roundtrip_test.gd
extends SceneTree
const SaveManagerScript := preload("res://core/save_manager.gd")
const AdventureRunnerScript := preload("res://core/adventure_runner.gd")
const RngServiceScript := preload("res://core/rng_service.gd")
const Kit := preload("res://tests/unit/test_kit.gd")
const SEED := 4242
const SAVE_PATH := "user://test_integration_save_rng.json"
var _db: ConfigDb
var _disk: String
func _init() -> void:
_disk = ProjectSettings.globalize_path(SAVE_PATH)
_clear_disk()
_db = load("res://core/config_db.gd").new() as ConfigDb
if not _db.load_tables("res://data/tables", false):
Kit.check("数值表加载", false)
quit(Kit.finish("save_rng_roundtrip"))
return
_test_disk_roundtrip_preserves_breakpoint()
_test_old_version_save_migrates_and_resumes()
_clear_disk()
quit(Kit.finish("save_rng_roundtrip"))
# ---- 用例一:盘上往返保真——写档再读回,断点逐键零漂移 ----
func _test_disk_roundtrip_preserves_breakpoint() -> void:
var rng := RngServiceScript.new()
var sm := SaveManagerScript.new(SAVE_PATH)
var adr := AdventureRunnerScript.new(_db, rng)
adr.set_save_manager(sm)
Kit.check_quiet(adr.start_adventure(SEED, {"hero_override": {}, "treasure_unlocked": {}}),
"往返用例:冒险启动")
Kit.check_quiet(sm.has_breakpoint(), "往返用例:入口停点断点在档")
Kit.check_quiet(sm.write_to_disk(), "往返用例:断点写盘")
var before: Dictionary = sm.breakpoint_snapshot()
var sm2 := SaveManagerScript.new(SAVE_PATH)
Kit.check_quiet(sm2.load_from_disk(), "往返用例:自盘读回受理")
Kit.check_quiet(sm2.has_breakpoint(), "往返用例:读回后断点仍在档")
var after: Dictionary = sm2.breakpoint_snapshot()
Kit.check_quiet(int(after.get("master_seed", 0)) == SEED, "往返用例:主种子往返保真")
Kit.check_quiet(int(after.get("rng_counter", -1)) == int(before.get("rng_counter", -2)),
"往返用例:RNG 取数号位往返保真(存档不得重耗/跳号)")
var bonus_before: Dictionary = before.get("bonus", {}) as Dictionary
var bonus_after: Dictionary = after.get("bonus", {}) as Dictionary
var bonus_same := bonus_after.size() == bonus_before.size()
for k in bonus_before:
if int(bonus_after.get(k, -99999)) != int(bonus_before[k]):
bonus_same = false
Kit.check_quiet(bonus_same, "往返用例:永久强化账本往返保真(逐键比整数值——JSON 往返不改值)")
Kit.check_quiet(int(after.get("hero_hp", -1)) == int(before.get("hero_hp", -2)),
"往返用例:生命账本往返保真")
# ---- 用例二:旧版本档经迁移链后仍可续玩(单测不覆盖的跨系统集成面)----
func _test_old_version_save_migrates_and_resumes() -> void:
var rng := RngServiceScript.new()
var sm := SaveManagerScript.new(SAVE_PATH)
var adr := AdventureRunnerScript.new(_db, rng)
adr.set_save_manager(sm)
if not adr.start_adventure(SEED, {"hero_override": {}, "treasure_unlocked": {}}):
Kit.check("旧档用例:冒险启动前置", false)
return
var runner = adr.runner()
var c0: int = rng.counter()
var cand: Array = runner.next_candidate_ids()
if cand.is_empty():
Kit.check("旧档用例:入口停点有候选路", false)
return
var fp_before := _board_fingerprint(runner)
var hp_before: int = runner.hero_hp
sm.write_to_disk()
# 降级为上一版格式档:抹掉当前版本新增域并回退版本号,模拟玩家手里的旧档。
var raw := FileAccess.get_file_as_string(_disk)
var parsed: Variant = JSON.parse_string(raw)
if not (parsed is Dictionary):
Kit.check("旧档用例:盘上档可解析", false)
return
var old: Dictionary = (parsed as Dictionary).duplicate(true)
old["version"] = 6
var meta: Dictionary = old.get("meta", {}) as Dictionary
meta.erase("forge_routes")
old["meta"] = meta
var f := FileAccess.open(_disk, FileAccess.WRITE)
if f == null:
Kit.check("旧档用例:旧档可落盘", false)
return
f.store_string(JSON.stringify(old))
f.close()
var sm_old := SaveManagerScript.new(SAVE_PATH)
Kit.check_quiet(sm_old.load_from_disk(), "旧档用例:旧档经迁移链可载")
Kit.check_quiet(sm_old.has_breakpoint(), "旧档用例:迁移后断点未被迁移链吃掉")
var forge: Dictionary = sm_old.forge_routes_snapshot()
Kit.check_quiet(str(forge.get("weapon", "")) == "sword",
"旧档用例:迁移补齐当前版本新增域(缺 forge_routes 的旧档按初始持有态可玩)")
# 旧档断点仍能被冒险层消费,且 RNG 续跑零跳号、开板与中断前逐位一致
var bp: Dictionary = sm_old.breakpoint_snapshot()
var rng2 := RngServiceScript.new()
var adr2 := AdventureRunnerScript.new(_db, rng2)
Kit.check_quiet(adr2.resume_adventure(bp, {"hero_override": {}, "treasure_unlocked": {}}),
"旧档用例:旧档断点续玩受理")
Kit.check_quiet(int(adr2.master_seed) == SEED, "旧档用例:续玩主种子回填")
Kit.check_quiet(rng2.counter() == c0, "旧档用例:续玩取数号位 = 断点号位(零重耗零跳号)")
var runner2 = adr2.runner()
Kit.check_quiet(runner2.hero_hp == hp_before, "旧档用例:续玩生命账本回填")
Kit.check_quiet(_board_fingerprint(runner2) == fp_before,
"旧档用例:续玩停点态与中断前逐位一致")
func _board_fingerprint(runner) -> String:
# 停点态指纹:候选路 + 生命 + 取数号位(无战斗对象时的可比面)
var cand: Array = runner.next_candidate_ids()
return "%s|%d" % [str(cand), runner.hero_hp]
func _clear_disk() -> void:
if FileAccess.file_exists(_disk):
DirAccess.remove_absolute(_disk)
+2
View File
@@ -68,6 +68,8 @@ func _suites() -> Array:
{"path": "res://tests/unit/core/feat_vouchers_test.gd", "expect": 0}, # 迭代 20 T4——凭证五件+留任二件(V9 路由逐件/F-1 存量账/V10 留任路由+AP 属性轴)
{"path": "res://tests/unit/core/settle_segments_test.gd", "expect": 0}, # 迭代 27 T2——结算五段 Segment(V4:直接 new/编排器接线/真实一轮走段序)
{"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 迭代 27 T4——模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗)
# ---- L2 集成层(迭代 29 T3 建承载——跨系统用例,与 unit/ 的隔离断言面分工)----
{"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性)
]
+42 -1
View File
@@ -119,6 +119,45 @@ gate "07 doc_link" "doc_link.log" "$PY" "$REPO/tools/doc_link_audit.py" --strict
gate "08 doc_term" "doc_term.log" "$PY" "$REPO/tools/doc_term_audit.py" --strict
# ── 6b. 状态文件体量闸(active.md 只记现在:条目数/快照行数/编号/死指针四条硬上限)──
gate "08b active.md 体量" "active_state.log" "$PY" "$REPO/tools/audit_active_state.py"
# ── 6c. 回放确定性对拍(条件闸件——迭代 29 T3 收编)──
# 触面判定:本批 diff 相对 master 分叉点,命中下列路径面之一即「触面」。
# 触面也**不自动跑**——对拍调的是 MC 脚本,属「MC/统计类跑批发起前须用户批准」红线管辖;
# 闸只负责每次跑批都把触面判定做一遍并显式要求批准,杜绝「该跑却没人记得」。
# 用户批准后设 PARITY_APPROVED=1 重跑本脚本即实跑三变体对拍。
PARITY_TOUCH_FACES="
game/data/tables/
game/core/rng_service.gd
game/core/save_manager.gd
game/core/meta_progression.gd
game/core/settlement/
game/core/damage_math.gd
game/core/damage_controller.gd
game/core/battle_orchestrator.gd
game/smoke/adventure_monte_carlo.gd
"
_base="$(git -C "$REPO" merge-base master HEAD 2>/dev/null || true)"
_changed_files="$(git -C "$REPO" diff --name-only "${_base:-HEAD}" HEAD 2>/dev/null || true)"
_touch_hit=0
for _f in $_changed_files; do
for _pat in $PARITY_TOUCH_FACES; do
case "$_f" in
"$_pat"*) _touch_hit=1 ;;
esac
done
done
_parity_name="11 回放确定性对拍"
if [ "$_touch_hit" -eq 0 ]; then
NAMES+=("$_parity_name"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] $_parity_name → SKIP(本批 diff 未触数值/存档/RNG/结算面)"
elif [ "${PARITY_APPROVED:-0}" = "1" ]; then
gate "$_parity_name" "parity.log" "$PY" "$REPO/tools/parity_anchor.py" check
else
NAMES+=("$_parity_name"); EXITS+=("NEED-APPROVAL"); LOGS+=("")
echo ""
echo "[gates] $_parity_name → 本批 diff 触及数值/存档/RNG/结算面,对拍需用户批准发起"
echo " (批准后设 PARITY_APPROVED=1 重跑本脚本即实跑;不得借机重锚)"
fi
# ── 7. 静态扫描域 + 熵增闸 ──
if [ $QUICK -eq 0 ]; then
gate "09 静态扫描" "static_scan.log" bash "$GAME/tests/static_scan.sh"
@@ -154,7 +193,9 @@ def head():
def fingerprint(exit_code, log):
"""红项指纹:只取 exit!=0 时的 FAIL 关键行集合,忽略时序/耗时噪声。
绿闸一律记 'green'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。"""
if exit_code in ("0", "SKIP"):
# SKIP / NEED-APPROVAL 都属「本闸未实跑」状态,指纹一律记 skip——
# 两者互变不是闸况变化(同是没跑),只有「未跑 → 跑出结果」或「绿 → 红」才算变化。
if exit_code in ("0", "SKIP", "NEED-APPROVAL"):
return "green" if exit_code == "0" else "skip"
if not log:
return "red-nolog"