245 lines
13 KiB
GDScript
245 lines
13 KiB
GDScript
# tests/unit/data_config/config_db_test.gd — 数据配置单元测试:表加载/进度坐标 ceil 解析/变异池门控/消费面访问器
|
||
# 运行:godot --headless --path game --script res://tests/unit/data_config/config_db_test.gd
|
||
# 协议(tests/README):extends SceneTree + quit(code);--script 模式无 autoload——显式实例化。
|
||
# 红线:期望值由表读值推导,不写死玩法数值(tests/README 协议要点)。
|
||
extends SceneTree
|
||
|
||
const ConfigDBScript := preload("res://core/config_db.gd")
|
||
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
|
||
|
||
|
||
func _init() -> void:
|
||
var db = ConfigDBScript.new()
|
||
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||
Kit.check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0)
|
||
Kit.check("is_ready 通道随加载置位", db.is_ready)
|
||
if not load_ok:
|
||
for d in db.diagnostics():
|
||
if d.severity == "error":
|
||
push_error("[config_db_test] 加载 error: [%s] %s" % [d.category, d.message])
|
||
else:
|
||
_test_progress_resolution(db)
|
||
_test_variant_pool_gate(db)
|
||
_test_shop_accessors(db)
|
||
_test_meta_accessors(db)
|
||
_test_manifest_dir_sync(db)
|
||
_test_rowset_discipline()
|
||
quit(Kit.finish("config_db_test"))
|
||
|
||
|
||
# ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)----
|
||
|
||
func _test_progress_resolution(db) -> void:
|
||
var bg_rows: Array = db.board_gen.rows
|
||
# 行键全集自表推导(禁写死关卡/深度数值)
|
||
var level_keys: Array = []
|
||
var depth_keys_by_level: Dictionary = {}
|
||
for row in bg_rows:
|
||
var lv := int(row.level_index)
|
||
if not level_keys.has(lv):
|
||
level_keys.append(lv)
|
||
if not depth_keys_by_level.has(lv):
|
||
depth_keys_by_level[lv] = []
|
||
var dv := int(row.node_depth)
|
||
if not depth_keys_by_level[lv].has(dv):
|
||
depth_keys_by_level[lv].append(dv)
|
||
level_keys.sort()
|
||
var min_l: int = level_keys[0]
|
||
var max_l: int = level_keys[-1]
|
||
var min_depths: Array = depth_keys_by_level[min_l]
|
||
min_depths.sort()
|
||
|
||
# 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等)
|
||
var probe_row: Dictionary = bg_rows[0]
|
||
var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth))
|
||
Kit.check("进度解析精确命中返回原行(board_gen 首行)",
|
||
int(got.get("level_index", -99)) == int(probe_row.level_index)
|
||
and int(got.get("node_depth", -99)) == int(probe_row.node_depth)
|
||
and String(got.get("board_type", "")) == String(probe_row.get("board_type", "")))
|
||
|
||
# enemy 行集同通道:同 (L,D) 行集数组非空(关卡节点地图 前置——battle 节点双表精确行集契约;
|
||
# 行集面契约:返回形态是行集数组,精确性断言随行集面)
|
||
var en_got: Array = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth))
|
||
Kit.check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty())
|
||
|
||
# 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重)
|
||
db.reset_diagnostics()
|
||
var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
|
||
Kit.check("深度越最大行 clamp 取该关最大深度行",
|
||
int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1]))
|
||
Kit.check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1)
|
||
var found_clamp := false
|
||
for d in db.diagnostics():
|
||
if d.category == "resolve_depth_clamp":
|
||
found_clamp = true
|
||
Kit.check("警告类别 = resolve_depth_clamp", found_clamp)
|
||
var warn_before: int = db.diagnostics_count("warning")
|
||
db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
|
||
Kit.check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before)
|
||
|
||
# 关卡 clamp:请求关卡超最大行 → 取最大关卡行
|
||
db.reset_diagnostics()
|
||
var high: Dictionary = db.resolve_board_gen(max_l + 1, 0)
|
||
Kit.check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l)
|
||
|
||
|
||
# ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配 + 节点身份第三维)----
|
||
|
||
func _test_variant_pool_gate(db) -> void:
|
||
var rows: Array = db.variation.rows
|
||
var gated_row := {}
|
||
var wildcard_row := {}
|
||
var global_row := {}
|
||
for row in rows:
|
||
if String(row.get("scope", "region")) != "region":
|
||
if global_row.is_empty():
|
||
global_row = row # global 行第三维门控另测(全局作用域变异)
|
||
continue
|
||
var tags: Array = row.get("board_types", [])
|
||
if gated_row.is_empty() and not tags.is_empty():
|
||
gated_row = row
|
||
if wildcard_row.is_empty() and tags.is_empty():
|
||
wildcard_row = row
|
||
if not gated_row.is_empty():
|
||
var u: int = int(gated_row.unlock_level)
|
||
var tag: String = String(gated_row.board_types[0])
|
||
var before: Array = db.variant_pool(u - 1, tag)
|
||
var after: Array = db.variant_pool(u, tag)
|
||
Kit.check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row))
|
||
Kit.check("变异池 unlock 当级含该行", _rows_contain(after, gated_row))
|
||
if not wildcard_row.is_empty():
|
||
var u2: int = int(wildcard_row.unlock_level)
|
||
var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag")
|
||
Kit.check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row))
|
||
if not global_row.is_empty():
|
||
var u3: int = int(global_row.unlock_level)
|
||
Kit.check("global 行 boss 节点入池(board_types ∅ 通配)", _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "boss"), global_row))
|
||
Kit.check("global 行缺省双参调用跳过(既有调用口径零扰动)", not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag"), global_row))
|
||
Kit.check("global 行 battle 节点静默跳过(门控兜底零报错)", not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "battle"), global_row))
|
||
|
||
|
||
func _rows_contain(pool: Array, row: Dictionary) -> bool:
|
||
for r in pool:
|
||
if r == row:
|
||
return true
|
||
return false
|
||
|
||
|
||
# ---- ShopTable 消费面(行集解析单点——runner/编排禁自扫表的契约消费侧验证)----
|
||
|
||
func _test_shop_accessors(db) -> void:
|
||
var products: Array = db.shop_products()
|
||
if products.is_empty():
|
||
Kit.check("ShopTable products 行族非空(表已落籍前提)", false)
|
||
return
|
||
Kit.check("ShopTable products 行族非空", true)
|
||
var first: Dictionary = products[0]
|
||
var got: Dictionary = db.shop_product_row(String(first.product_id))
|
||
Kit.check("shop_product_row 按 product_id 精确命中首行", got == first)
|
||
Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty())
|
||
var tier_rows: Array = db._shop_rows("gp_drops")
|
||
if not tier_rows.is_empty():
|
||
var tier0: String = String(tier_rows[0].tier)
|
||
Kit.check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty())
|
||
var bonus_rows: Array = db._shop_rows("level_gp_bonus")
|
||
if not bonus_rows.is_empty():
|
||
var blv: int = int(bonus_rows[0].level_index)
|
||
Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus))
|
||
Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0)
|
||
var price_rows: Array = db._shop_rows("price_level_bonus")
|
||
if not price_rows.is_empty():
|
||
var plv: int = int(price_rows[0].level_index)
|
||
Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus))
|
||
|
||
|
||
# ---- MetaTable 消费面 ----
|
||
|
||
func _test_meta_accessors(db) -> void:
|
||
# 无 forge_products 行族断言(行族/访问器不在表,V20 清单)
|
||
var key_rows: Array = db._meta_rows("boss_keystone")
|
||
if not key_rows.is_empty():
|
||
var klv: int = int(key_rows[0].level_index)
|
||
Kit.check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0])
|
||
var conv_rows: Array = db._meta_rows("conversion")
|
||
if not conv_rows.is_empty():
|
||
var cattr: String = String(conv_rows[0].attr)
|
||
Kit.check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0])
|
||
|
||
|
||
# ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)----
|
||
|
||
func _test_manifest_dir_sync(db) -> void:
|
||
var manifest: Dictionary = db.TABLE_MANIFEST
|
||
var dir := DirAccess.open("res://data/tables")
|
||
var disk_files: Array = []
|
||
if dir != null:
|
||
dir.list_dir_begin()
|
||
var f := dir.get_next()
|
||
while f != "":
|
||
if not dir.current_is_dir() and f.ends_with(".tres"):
|
||
disk_files.append(f)
|
||
f = dir.get_next()
|
||
dir.list_dir_end()
|
||
var synced := disk_files.size() == manifest.size()
|
||
if synced:
|
||
for key in manifest.keys():
|
||
if not disk_files.has(String(manifest[key])):
|
||
synced = false
|
||
Kit.check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced)
|
||
|
||
|
||
# ---- 行集构成纪律(EnemyTable.validate 正反验证;资源直构不碰生产表)----
|
||
# 纪律三条(敌人系统 v0.15):①行集内 enemy_id 唯一(dup_key 既有通道)②同坐标多行文件内相邻
|
||
# (rowset_rule——行序即契约,R6 加载期兜底)③行集实例总数 ≤4(骰后值运行面,spawn 守卫——冒烟侧验证)。
|
||
|
||
func _test_rowset_discipline() -> void:
|
||
# 行集用 rows.assign([...]) 而非直接赋值:rows 是 Array[Dictionary] 定型属性,直接赋 untyped
|
||
# 数组字面量会触发「Invalid assignment」脚本错误——该错误不改退出码,却会**中断本函数剩余断言**
|
||
#(本函数五条纪律断言曾因此全部静默不跑,套件仍报 all green,是零覆盖假象)。
|
||
const EnemyTableScript := preload("res://data/schemas/enemy_table.gd")
|
||
var mk_row := func(l: int, d: int, eid: String) -> Dictionary:
|
||
return {"level_index": l, "node_depth": d, "enemy_id": eid, "display_name": eid + "名",
|
||
"unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}
|
||
# 正向:同坐标两行不同敌种(相邻)= 混编合法,validate 零错
|
||
var t_mix = EnemyTableScript.new()
|
||
t_mix.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "bugbear"),
|
||
mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(validate 的 anchor 规则:每 level 必须有)
|
||
var errs: Array = t_mix.validate()
|
||
Kit.check("混编行集加载通过(同坐标两行不同敌种相邻——行集恰一敌种校验退役的正向面)", errs.is_empty())
|
||
# 正向:Boss 坐标两行同构合法(P4 乙——Boss 行集校验同样放开,加载层无 Boss 单行硬闸)
|
||
var t_boss = EnemyTableScript.new()
|
||
t_boss.rows.assign([mk_row.call(2, 9, "drider"), mk_row.call(2, 9, "bugbear"),
|
||
mk_row.call(2, 0, "drider")]) # 末行=depth-0 锚行
|
||
errs = t_boss.validate()
|
||
Kit.check("Boss 坐标混编行集加载通过(P4 乙——Boss 多行合法,无硬闸)", errs.is_empty())
|
||
# 负向:同坐标行不相邻 = rowset_rule 且报错定位到坐标(R6 行序纪律 + R3 错误类别可定位)
|
||
var t_gap = EnemyTableScript.new()
|
||
t_gap.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 9, "hobgoblin"), mk_row.call(1, 5, "bugbear"),
|
||
mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(隔断关系不受影响)
|
||
errs = t_gap.validate()
|
||
var gap_hit := false
|
||
for e in errs:
|
||
if String(e.get("category", "")) == "rowset_rule" and String(e.get("message", "")).contains("(1,5)"):
|
||
gap_hit = true
|
||
Kit.check("行序相邻校验:同坐标行被隔断报 rowset_rule 且定位坐标(R6——行序即契约加载期兜底)", gap_hit)
|
||
# 负向:同敌种第二行仍被 dup_key 拦(键含 enemy_id——纪律①既有通道不回归)
|
||
var t_dup = EnemyTableScript.new()
|
||
t_dup.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "goblin"),
|
||
mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行
|
||
errs = t_dup.validate()
|
||
var dup_hit := false
|
||
for e in errs:
|
||
if String(e.get("category", "")) == "dup_key":
|
||
dup_hit = true
|
||
Kit.check("行集内同敌种第二行仍被 dup_key 拦(纪律①——enemy_id 唯一)", dup_hit)
|
||
# 负向:域校验不回归(单行 unit_count 越域仍拦——UNIT_COUNT_MAX 单种数语义下的同款域闸)
|
||
var t_dom = EnemyTableScript.new()
|
||
t_dom.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行
|
||
t_dom.rows[0]["unit_count"] = 4
|
||
errs = t_dom.validate()
|
||
var dom_hit := false
|
||
for e in errs:
|
||
if String(e.get("category", "")) == "domain" and String(e.get("message", "")).contains("unit_count"):
|
||
dom_hit = true
|
||
Kit.check("单行数量域 [1,3] 域闸不回归(UNIT_COUNT_MAX 语义 = 单种数)", dom_hit)
|