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>
215 lines
10 KiB
GDScript
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")
|