Files
code_of_conquest_dnd/client/tests/unit/test_creation_copy.gd
Phillip Tarrant 6291f21a00 fix(theme-guard): close the drift guard's third miss — the meta-guard's own continue whitelisted the gap
test_committed_tres_matches_builder has now been "fixed" three times (traps.md #12).
v1 hardcoded 3 checks; v2 iterated ThemeKeys.ALL but missed the six font roles; v3
added a meta-guard specifically to catch "the builder styles a thing guarded by
nothing" — but that meta-guard contained `if fresh.get_type_variation_base(variation)
== &"": continue`, which whitelisted exactly the case it was built to catch. The
builder styles RichTextLabel directly (build_game_theme.gd's _rich_text()), with no
set_type_variation, so it reports base "" and was skipped unconditionally — and
RichTextLabel is what renders every line of DM prose the creation screen shows
(_origin_text, _detail_blurb). Nothing compared its fonts, font size, or colour, nor
the theme-level default_font/default_font_size, against the committed .tres.

Adds ThemeKeys.BASE_TYPES for base Control types the builder styles directly, folds
it into the drift guard's coverage, replaces the meta-guard's continue with an
assertion that names the offending type, and adds the missing theme-level default
font/size comparison. Proved with three reverted breaks: a RichTextLabel font-size
edit, a theme-level default_font_size edit, and an unregistered "GhostRole" variation
— all three now go red and name the drifted property.

Also: strengthens test_creation_copy's error-copy sweep to assert a mapped error
actually reaches its authored line rather than silently falling through to the
generic UNSPOKEN fallback (CreationCopy.UNSPOKEN satisfied all four prior checks,
so a renamed validator string could regress silently); and removes two assertions
that were true by construction / already true before their test's action ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:47:24 -05:00

215 lines
10 KiB
GDScript

extends "res://addons/gut/test.gd"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
var world
func before_each():
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _draft(race := "human", calling := "sellsword") -> CreationDraft:
var d := CreationDraft.new()
d.character_seed = 8675309
d.set_race(race)
d.set_calling(calling)
d.name = "Aldric"
return d
func test_calling_detail_reads_the_table_rather_than_repeating_it():
# The guard that fails if anyone types "d8" into a string literal. If someone
# retunes the Cutpurse's hit die, this test keeps passing and the CARD FOLLOWS —
# which is the whole point. A hardcoded line would go red here.
#
# The talent is asserted through the DERIVED phrase ("talent second wind"), not
# the raw id ("second_wind") the card used to print — but it is still read from
# Callings.talent() at assert time, so hardcoding a talent into the format string
# still goes red (traps.md #7: anchor to the full derived phrase).
for id in Callings.IDS:
var line := CreationCopy.calling_detail(id)
assert_string_contains(line, "d%d" % Callings.hit_die(id))
assert_string_contains(line, CreationCopy.armor_word(id))
assert_string_contains(line, "talent %s" % CreationCopy.talent_word(id))
assert_string_contains(line, "picks %d skills" % Callings.skill_count(id))
for s in Callings.saves(id):
assert_string_contains(line, s.to_upper())
func test_the_talent_is_humanised_and_no_card_line_shows_a_raw_id():
# The player read "talent second_wind" off the Sellsword's card. Four of the seven
# talents carry an underscore — the card printed the ID.
assert_eq(CreationCopy.talent_word("sellsword"), "second wind")
assert_eq(CreationCopy.talent_word("cutpurse"), "backstab", "an underscore-free talent is untouched")
var underscored := 0
for id in Callings.IDS:
if Callings.talent(id).contains("_"):
underscored += 1
assert_false(CreationCopy.calling_detail(id).contains("_"),
"%s's card line shows a raw snake_case id: %s" % [id, CreationCopy.calling_detail(id)])
assert_eq(underscored, 4,
"four talents carry an underscore — if that ever hits zero this guard stops guarding")
func test_casters_show_a_pool_and_martials_show_cooldowns():
assert_string_contains(CreationCopy.calling_detail("hedge_mage"), "MP (MAG)")
assert_string_contains(CreationCopy.calling_detail("bonesetter"), "MP (FTH)")
assert_string_contains(CreationCopy.calling_detail("sellsword"), "cooldowns")
assert_false(CreationCopy.calling_detail("sellsword").contains("MP"))
func test_race_trait_lines_are_derived_from_the_race_table():
assert_string_contains(CreationCopy.race_trait("human"), "every save")
assert_string_contains(CreationCopy.race_trait("human"), "skill of choice")
assert_string_contains(CreationCopy.race_trait("elf"), "Perception")
assert_string_contains(CreationCopy.race_trait("elf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Poison")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Claws")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Keen scent")
func test_every_race_has_a_non_empty_trait_line():
for id in Races.IDS:
assert_ne(CreationCopy.race_trait(id).strip_edges(), "", "%s has no trait line" % id)
func test_skill_labels_are_human_readable():
assert_eq(CreationCopy.skill_label("sleight_of_hand"), "sleight of hand")
assert_eq(CreationCopy.skill_label("faith_lore"), "faith lore")
assert_eq(CreationCopy.skill_label("stealth"), "stealth")
func test_nothing_in_the_copy_mentions_luck():
# §7 — the player must never be able to CALCULATE that he is cursed.
for id in Callings.IDS:
assert_false(CreationCopy.calling_detail(id).to_lower().contains("luck"))
assert_false(CreationCopy.calling_detail(id).to_lower().contains("lck"))
for id in Races.IDS:
assert_false(CreationCopy.race_trait(id).to_lower().contains("luck"))
assert_false(CreationCopy.race_trait(id).to_lower().contains("lck"))
# ------------------------------------------------------------------- §13: the nag
func test_the_nag_is_authored_copy_not_the_validator_s_grammar():
# §13. The exact strings a human must be able to judge, pinned. Anchored to the
# authored phrase itself — asserting only "no underscore" would pass on any
# string that happens to have none, including a blank one.
var human := _draft("human", "sellsword")
human.name = ""
assert_eq(CreationCopy.error_line("player name is required", human), "name yourself")
# "sellsword picks 2 skills, got 0" -> the count is DERIVED from the rules table.
assert_eq(Callings.skill_count("sellsword"), 2, "the sellsword picks 2, or the line below means nothing")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 0", human),
"choose two more proficiencies")
human.toggle_skill("athletics")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 1", human),
"choose one more proficiency", "one is singular, and the number is never hardcoded")
# The same error against a calling that picks FOUR must say four — the proof the
# number comes from Callings.skill_count and not from a literal "two".
var thief := _draft("dwarf", "cutpurse")
assert_eq(Callings.skill_count("cutpurse"), 4, "the cutpurse picks 4, or the line below means nothing")
assert_eq(CreationCopy.error_line("cutpurse picks 4 skills, got 0", thief),
"choose four more proficiencies")
assert_eq(CreationCopy.error_line("human must choose a bonus skill", human),
"one more proficiency, any of them — take it")
func test_an_unmapped_error_still_speaks_in_voice():
# §13: "write fallbacks as content, not as error handling." An error nobody
# anticipated (broken content; a spend the screen cannot produce) must never put
# the raw string on the label, and must never blank it either.
var d := _draft()
for raw in ["unresolved ref: item:ghost_blade", "seed is required and must be an integer",
"spend exceeds the pool of 3 (got 5)", "cannot spend on 'lck' — not an attribute"]:
var line := CreationCopy.error_line(raw, d)
assert_eq(line, CreationCopy.UNSPOKEN, "an unmapped error degrades to the authored fallback: %s" % raw)
assert_ne(line, raw)
assert_ne(CreationCopy.UNSPOKEN.strip_edges(), "", "the fallback is CONTENT — it is never blank")
assert_false(CreationCopy.UNSPOKEN.contains("_"))
func _is_deliberately_unmapped(raw: String) -> bool:
# The shapes error_line has NO authored line for, on purpose, because the
# screen structurally cannot produce them (CreationDraft always emits an
# int seed and a spend built only from increment/decrement, which can
# never exceed the pool or name a non-attribute) — a malformed seed or
# spend, or a broken content reference, is not a player mistake to voice
# in character; test_an_unmapped_error_still_speaks_in_voice already pins
# the fallback for exactly this set. Named by the validator's own prefixes
# so this stays independent of error_line's internals.
return (raw.begins_with("unresolved ref:")
or raw.begins_with("seed is required")
or raw.begins_with("spend")
or raw.begins_with("cannot spend on")
or raw == "skills must be an array")
func test_no_error_the_validator_can_raise_reaches_the_player_raw():
# Sweep EVERY error NewGame.validate actually produces for a draft the screen can
# be driven into (plus the hostile shapes only a saga could hand it), and assert
# the player never sees the id, the underscore, or the validator's grammar — AND,
# for every shape error_line actually has an authored line for, that the line
# fired rather than silently falling through to the generic UNSPOKEN fallback.
#
# CreationCopy.UNSPOKEN ("something in this does not hold — look again") is
# itself non-blank, isn't the raw string, has no underscore, and has no "got " —
# so the four checks below cannot tell "correctly mapped to its authored line"
# from "silently fell through." Without the UNSPOKEN check, renaming a validator
# string in new_game.gd so an error_line branch stops matching would leave every
# error line reading the generic fallback, suite green.
var d := _draft()
var hostiles: Array = [
{"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked
{"skills": ["athletics"]}, # too few
{"skills": ["athletics", "athletics"]}, # duplicate
{"skills": ["athletics", "sorcery"]}, # not in the pool
{"race_id": "elf", "skills": ["athletics", "perception"], "bonus_skill": ""}, # granted already
{"bonus_skill": ""}, # human, no bonus
{"bonus_skill": "athletics", "skills": ["athletics", "endurance"]}, # bonus == a pick
{"bonus_skill": "swimming"}, # no such skill
{"race_id": "dwarf", "bonus_skill": "stealth"}, # dwarf gets none
{"race_id": "orc"}, {"calling_id": "paladin"}, # off the tables
{"seed": "8675309"}, {"spend": {"lck": 3}}, {"spend": {"str": 9}},
]
var base := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309,
"spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
var seen := 0
var mapped_seen := 0
for h in hostiles:
var creation := base.duplicate(true)
for k in h:
creation[k] = h[k]
var errors: Array = NewGame.validate(_deserter(), world, creation)
assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h))
for e in errors:
seen += 1
var raw := str(e)
var line: String = CreationCopy.error_line(raw, d)
assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % raw)
assert_ne(line, raw, "'%s' reached the player raw" % raw)
assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [raw, line])
assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [raw, line])
if not _is_deliberately_unmapped(raw):
mapped_seen += 1
assert_ne(line, CreationCopy.UNSPOKEN,
"'%s' has an authored error_line branch but silently fell through to the generic fallback" % raw)
assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two")
assert_gt(mapped_seen, 8, "the sweep must actually exercise error_line's mapped branches, not just the deliberately-unmapped ones")