Files
gplan-2/game/tests/unit/data_config/config_db_test.gd
T

279 lines
14 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.
# 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 诊断)〔fv6〕", 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.combat.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.combat.board_gen.resolve_row(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.combat.enemy.resolve_rowset(int(probe_row.level_index), int(probe_row.node_depth), "battle")
Kit.check("enemy 行集同坐标解析非空(双表精确行契约——battle 行类)", not en_got.is_empty())
# 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重)
db.reset_diagnostics()
var deep: Dictionary = db.combat.board_gen.resolve_row(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.combat.board_gen.resolve_row(min_l, int(min_depths[-1]) + 1)
Kit.check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before)
# 关卡 clamp:请求关卡超最大行 → 取最大关卡行
db.reset_diagnostics()
var high: Dictionary = db.combat.board_gen.resolve_row(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.combat.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.combat.variation.variant_pool(u - 1, tag)
var after: Array = db.combat.variation.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.combat.variation.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.combat.variation.variant_pool(u3, "nonexistent_board_type_tag", "boss"), global_row))
Kit.check("global 行缺省双参调用跳过(既有调用口径零扰动)", not _rows_contain(db.combat.variation.variant_pool(u3, "nonexistent_board_type_tag"), global_row))
Kit.check("global 行 battle 节点静默跳过(门控兜底零报错)", not _rows_contain(db.combat.variation.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.run.shop.product_rows()
if products.is_empty():
Kit.check("ShopTable products 行族非空(表已已入库前提)", false)
return
Kit.check("ShopTable products 行族非空", true)
var first: Dictionary = products[0]
var got: Dictionary = db.run.shop.product_row(String(first.product_id))
Kit.check("shop_product_row 按 product_id 精确命中首行", got == first)
Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.run.shop.product_row("no_such_product_id__").is_empty())
var tier_rows: Array = db.run.shop.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.run.shop.gp_drop_row(tier0).is_empty())
var bonus_rows: Array = db.run.shop.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.run.shop.level_gp_bonus_at(blv) == int(bonus_rows[0].bonus))
Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.run.shop.level_gp_bonus_at(-1) == 0)
var price_rows: Array = db.run.shop.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.run.shop.price_level_bonus_at(plv) == int(price_rows[0].bonus))
# ---- MetaTable 消费面 ----
func _test_meta_accessors(db) -> void:
# 无 forge_products 行族断言(行族/访问器不在表,V20 清单)
var key_rows: Array = db.crossrun.meta.boss_keystone
if not key_rows.is_empty():
var klv: int = int(key_rows[0].level_index)
Kit.check("meta_boss_keystone_row 按关卡序号命中", db.crossrun.meta.boss_keystone_row(klv) == key_rows[0])
var conv_rows: Array = db.crossrun.meta.conversion
if not conv_rows.is_empty():
var cattr: String = String(conv_rows[0].attr)
var conv_hit := {}
for r in db.crossrun.meta.conversion:
if String(r.get("attr", "")) == cattr:
conv_hit = r
break
Kit.check("meta.conversion 行集按 attr 命中(访问器退役改直扫同判据)", conv_hit == 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, kind: String, band: int, eid: String) -> Dictionary:
return {"level_index": l, "row_kind": kind, "depth_band": band, "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, "battle", 5, "goblin"), mk_row.call(1, "battle", 5, "bugbear"),
mk_row.call(1, "battle", 0, "goblin"), mk_row.call(1, "boss", 9, "ogre_chief")])
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, "battle", 0, "drider"), mk_row.call(2, "boss", 9, "drider"),
mk_row.call(2, "boss", 9, "bugbear")])
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, "battle", 5, "goblin"), mk_row.call(1, "battle", 9, "giant_spider"),
mk_row.call(1, "battle", 5, "bugbear"), mk_row.call(1, "battle", 0, "goblin"),
mk_row.call(1, "boss", 9, "ogre_chief")])
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,battle,5)"):
gap_hit = true
Kit.check("行序相邻校验:同带键行被隔断报 rowset_rule 且定位带键〔fv9〕〔fv20〕", gap_hit)
# 负向:同敌种第二行仍被 dup_key 拦(键含 enemy_id——纪律①既有通道不回归)
var t_dup = EnemyTableScript.new()
t_dup.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "battle", 5, "goblin"),
mk_row.call(1, "battle", 0, "goblin"), mk_row.call(1, "boss", 9, "ogre_chief")])
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 唯一)〔fv8〕", dup_hit)
# 负向:域校验不回归(单行 unit_count 越域仍拦——UNIT_COUNT_MAX 单种数语义下的同款域闸)
var t_dom = EnemyTableScript.new()
t_dom.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "battle", 0, "goblin"),
mk_row.call(1, "boss", 9, "ogre_chief")])
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)
# 负向:row_kind 封闭枚举(非法值拒——键面换代后新增判据)
var t_kind = EnemyTableScript.new()
t_kind.rows.assign([mk_row.call(1, "elite", 5, "goblin"), mk_row.call(1, "battle", 0, "goblin"),
mk_row.call(1, "boss", 9, "ogre_chief")])
errs = t_kind.validate()
var kind_hit := false
for e in errs:
if String(e.get("category", "")) == "domain" and String(e.get("message", "")).contains("row_kind"):
kind_hit = true
Kit.check("非法 row_kind 被拒(封闭枚举 {battle, boss})〔fv7〕", kind_hit)
# 负向:anchor 两半——缺 battle band 0 锚行、缺 Boss 行集,各自报 anchor
var t_no_anchor = EnemyTableScript.new()
t_no_anchor.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "boss", 9, "ogre_chief")])
errs = t_no_anchor.validate()
var no_anchor_hit := false
for e in errs:
if String(e.get("category", "")) == "anchor" and String(e.get("message", "")).contains("band 0"):
no_anchor_hit = true
Kit.check("缺 (battle, band 0) 锚行报 anchor〔fv10〕", no_anchor_hit)
var t_no_boss = EnemyTableScript.new()
t_no_boss.rows.assign([mk_row.call(1, "battle", 0, "goblin"), mk_row.call(1, "battle", 5, "bugbear")])
errs = t_no_boss.validate()
var no_boss_hit := false
for e in errs:
if String(e.get("category", "")) == "anchor" and String(e.get("message", "")).contains("Boss"):
no_boss_hit = true
Kit.check("缺 Boss 行集报 anchor(每关 Boss 行集 ≥1)〔fv10〕", no_boss_hit)