# tests/unit/data_config/forge_table_test.gd — 锻造工坊配置表单测(迭代 23 T1) # 覆盖(需求卡片 §7.2):V1 六路线 schema 零违例(id 唯一/类型轴枚举/值域——出域行逐类构造各一行全部被拒) # + V11 满级特效留空(行字段闭集=无特效参数位 + 效果类型闭集 24 种零新增)+ 几何价格梯 20/30/45/67/101。 # 运行:godot --headless --path game --script res://tests/unit/data_config/forge_table_test.gd(expect 0) # 协议(ADR-0004 + tests/README):extends SceneTree + quit(code);负向用例资源直构不碰生产表(enemy_table 先例)。 extends SceneTree const ConfigDBScript := preload("res://core/config_db.gd") const ForgeTableScript := preload("res://data/schemas/forge_table.gd") const EffectEngineScript := preload("res://core/effect_engine.gd") const Kit := preload("res://tests/unit/test_kit.gd") func _init() -> void: var db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) Kit.check("生产 forge.tres 随全表加载 + validate 零违例〔锻造工坊批 V1〕", load_ok and db.diagnostics_count("error") == 0) if load_ok: _test_production_rows(db) _test_price_ladder(db) _test_negative_cases() _test_effect_kinds_closed() quit(Kit.finish("forge_table_test")) # ---- 生产表六行正向(V1 生产面 + V11 行字段闭集)---- func _test_production_rows(db) -> void: var rows: Array = db.forge_routes() Kit.check("六路线行族规模恰 6(三武器+三甲)〔锻造工坊批 V1〕", rows.size() == 6) var ids := [] for row in rows: ids.append(String(row.get("route_id", ""))) ids.sort() Kit.check("路线 id 全集 = {sword, spear, staff, light, heavy, ward}〔锻造工坊批 V1〕", ids == ["heavy", "light", "spear", "staff", "sword", "ward"]) # V11:行字段闭集——键集恰 12 项,无特效参数位(满级特效本期不做,契约 7) var expect_keys := ForgeTableScript.ROW_KEYS.duplicate() expect_keys.sort() var keys_closed := true for row in rows: var ks := (row as Dictionary).keys() ks.sort() if ks != expect_keys: keys_closed = false Kit.check("六行字段键集恰 = ROW_KEYS 闭集(无特效参数位)〔锻造工坊批 V11〕", keys_closed) # 初始持有恰长剑+轻甲(语义继承自已退役属性锻造行) var initial := [] for row in rows: if bool(row.get("initial_owned", false)): initial.append(String(row.get("route_id", ""))) initial.sort() Kit.check("初始持有恰 sword+light〔锻造工坊批 V1〕", initial == ["light", "sword"]) # 默认两轴(契约 2 所有权移交——裸体态 normal/light) Kit.check("默认攻击类型轴 = normal(常驻持有)〔锻造工坊批 V1〕", db.forge_default_attack_type() == "normal") Kit.check("默认甲型轴 = light(常驻持有)〔锻造工坊批 V1〕", db.forge_default_armor_type() == "light") # 类型轴分族与并联双通道前置(4b-1 裁) Kit.check("长枪/法杖 ← 长剑练满(并联双通道①)〔锻造工坊批 V1〕", String(db.forge_route("spear").get("prereq_route", "")) == "sword" and String(db.forge_route("staff").get("prereq_route", "")) == "sword") Kit.check("重甲/魔法护甲 ← 轻甲练满(并联双通道①)〔锻造工坊批 V1〕", String(db.forge_route("heavy").get("prereq_route", "")) == "light" and String(db.forge_route("ward").get("prereq_route", "")) == "light") Kit.check("四待解锁路线一次性解锁价 = 200〔锻造工坊批 V1〕", int(db.forge_route("spear").get("unlock_price", 0)) == 200 and int(db.forge_route("staff").get("unlock_price", 0)) == 200 and int(db.forge_route("heavy").get("unlock_price", 0)) == 200 and int(db.forge_route("ward").get("unlock_price", 0)) == 200) # 甲型基础 AC(provisional 现役结构值——settlement.tres 矩阵同行) Kit.check("甲型基础 AC = 轻 10 / 重 12 / 魔法护甲 9〔锻造工坊批 V1〕", int(db.forge_route("light").get("base_ac", 0)) == 10 and int(db.forge_route("heavy").get("base_ac", 0)) == 12 and int(db.forge_route("ward").get("base_ac", 0)) == 9) # 按类型轴反查(甲路线 id=甲型同值的显式化入口) Kit.check("forge_armor_route_by_type(ward) = 魔法护甲锻造〔锻造工坊批 V1〕", String(db.forge_armor_route_by_type("ward").get("route_id", "")) == "ward") Kit.check("forge_weapon_route_by_type(pierce) = 长枪锻造〔锻造工坊批 V1〕", String(db.forge_weapon_route_by_type("pierce").get("route_id", "")) == "spear") # 缺行/未知轴 = 空字典合法态 Kit.check("未知 route_id / 未知类型轴 = 空字典〔锻造工坊批 V1〕", db.forge_route("blade").is_empty() and db.forge_armor_route_by_type("plate").is_empty()) # ---- 几何价格梯(V2 价格面单测半边——购买写在 T3 冒烟)---- func _test_price_ladder(db) -> void: var row: Dictionary = db.forge_route("sword") var prices := [] for owned in range(5): prices.append(ForgeTableScript.level_price(row, owned)) Kit.check("长剑各级价格 = 20/30/45/67/101(基础 20×150%^已购级数)〔锻造工坊批 V2〕", prices == [20, 30, 45, 67, 101]) Kit.check("已满级(owned=5)再购价照算 151(cap 闸归写口,价格函数纯几何)〔锻造工坊批 V2〕", ForgeTableScript.level_price(row, 5) == 151) # ---- 负向:出域行逐类构造各一行全部被拒(V1)---- func _base_rows() -> Array[Dictionary]: var out: Array[Dictionary] = [] var mk := func(rid: String, kind: String, axis: String, initial: bool, prereq: String, unlock: int) -> Dictionary: return {"route_id": rid, "display_name": rid + "锻造", "kind": kind, "type_axis": axis, "cap": 5, "per_level_gain": 1, "base_ac": 10 if kind == "armor" else 0, "base_price": 20, "price_ratio_pct": 150, "initial_owned": initial, "prereq_route": prereq, "unlock_price": unlock} out.append(mk.call("sword", "weapon", "normal", true, "", 0)) out.append(mk.call("spear", "weapon", "pierce", false, "sword", 200)) out.append(mk.call("staff", "weapon", "magic", false, "sword", 200)) out.append(mk.call("light", "armor", "light", true, "", 0)) out.append(mk.call("heavy", "armor", "heavy", false, "light", 200)) out.append(mk.call("ward", "armor", "ward", false, "light", 200)) return out func _has_cat(errs: Array, cat: String) -> bool: for e in errs: if String(e.get("category", "")) == cat: return true return false # 多字段变异器(lambda 单表达式装不下的用例——命名函数承载) func _mut_spear_initial(t) -> void: t.routes[1]["initial_owned"] = true t.routes[1]["prereq_route"] = "" t.routes[1]["unlock_price"] = 0 func _test_negative_cases() -> void: # 夹具本身须零违例(否则负向判读失效) var t_ok = ForgeTableScript.new() t_ok.routes = _base_rows() Kit.check("负向夹具(合法六行)validate 零违例〔锻造工坊批 V1〕", t_ok.validate().is_empty()) var cases := [] # route_id 出域 cases.append(["route_id 出域即拒", func(t): t.routes[0]["route_id"] = "blade", "enum"]) # route_id 重复 cases.append(["route_id 重复即拒", func(t): t.routes[1]["route_id"] = "sword", "dup_key"]) # kind 出域 cases.append(["kind 出域即拒", func(t): t.routes[1]["kind"] = "shield", "enum"]) # 武器类型轴出域 cases.append(["武器 type_axis 出域即拒", func(t): t.routes[1]["type_axis"] = "ward", "enum"]) # 同攻击类型两路线 cases.append(["同攻击类型第二路线即拒", func(t): t.routes[2]["type_axis"] = "normal", "dup_key"]) # cap 越域 cases.append(["cap 越域即拒", func(t): t.routes[0]["cap"] = 0, "domain"]) # 每级增量越域 cases.append(["per_level_gain 越域即拒", func(t): t.routes[0]["per_level_gain"] = -1, "domain"]) # 基础价越域 cases.append(["base_price 越域即拒", func(t): t.routes[0]["base_price"] = 0, "domain"]) # 公比越域 cases.append(["price_ratio_pct 越域即拒", func(t): t.routes[0]["price_ratio_pct"] = 99, "domain"]) # 武器行 base_ac 非零 cases.append(["武器行 base_ac 非零即拒", func(t): t.routes[0]["base_ac"] = 5, "cross_field"]) # 甲行 base_ac 越域 cases.append(["甲行 base_ac 越域即拒", func(t): t.routes[3]["base_ac"] = -1, "domain"]) # 待解锁行无解锁价 cases.append(["待解锁行无一次性解锁价即拒", func(t): t.routes[1]["unlock_price"] = 0, "cross_field"]) # 初始持有行带前置 cases.append(["初始持有行带前置即拒", func(t): t.routes[0]["prereq_route"] = "spear", "cross_field"]) # 初始持有行带解锁价 cases.append(["初始持有行带解锁价即拒", func(t): t.routes[3]["unlock_price"] = 100, "cross_field"]) # 前置出域 cases.append(["prereq_route 出域即拒", func(t): t.routes[1]["prereq_route"] = "blade", "enum"]) # 前置类型不符(武器←甲) cases.append(["前置类型不符即拒(武器行前置甲路线)", func(t): t.routes[1]["prereq_route"] = "light", "cross_field"]) # 前置自指 cases.append(["前置自指即拒", func(t): t.routes[1]["prereq_route"] = "spear", "cross_field"]) # 规模非六 cases.append(["行族规模非六即拒", func(t): t.routes.pop_back(), "structural"]) # 初始持有非一武器一甲(spear 转初始且清前置/解锁价——隔离出 coverage 单类) cases.append(["初始持有非一武器一甲即拒", Callable(self, "_mut_spear_initial"), "coverage"]) # 默认轴≠初始路线类型轴 cases.append(["默认攻击轴≠初始武器类型轴即拒", func(t): t.default_attack_type = "pierce", "cross_field"]) # 默认甲轴出域 cases.append(["默认甲轴出域即拒", func(t): t.default_armor_type = "plate", "enum"]) # 同甲型两路线 cases.append(["同甲型第二路线即拒", func(t): t.routes[4]["type_axis"] = "light", "dup_key"]) for c in cases: var label: String = c[0] var mutate: Callable = c[1] var expect_cat: String = c[2] var t = ForgeTableScript.new() t.routes = _base_rows() mutate.call(t) var errs: Array = t.validate() Kit.check("锻造 schema 负向:%s〔锻造工坊批 V1〕" % label, _has_cat(errs, expect_cat)) # ---- V11:效果类型闭集零新增(满级特效本期不开新词)---- # 实数订正(L2 在册):卡片 F11「24 种」系 2026-10-07 设计文档事实快照——迭代 20 T1 +7、迭代 22 T3 +1 # 后现行 31;本批零扩签,钉现行数(将来任何一批加词即转红提示复核)。 func _test_effect_kinds_closed() -> void: Kit.check("效果引擎效果类型闭集 31 种零新增(锻造工坊批不开效果类型词)〔锻造工坊批 V11〕", EffectEngineScript.EFFECT_KINDS.size() == 31) Kit.check("效果引擎事件词表在册 20 种(本批零扩签参照面)〔锻造工坊批 V11〕", EffectEngineScript.EVENTS.size() == 20)