Files
code_of_conquest_dnd/client/tests/unit/test_entities.gd
Phillip Tarrant 5149e817f6 fix(contract): repair 3-arg LogPlayer callers + digest article bug
Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.

Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.

Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".

Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
  calling_id; a calling passed into the race_id slot (the exact shape
  of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
  digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
  allowed_callings enum matches Callings.IDS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:34:05 -05:00

87 lines
3.6 KiB
GDScript

extends "res://addons/gut/test.gd"
const Ids = preload("res://scripts/ids.gd")
const LogPlayer = preload("res://scripts/canon_log/log_player.gd")
const LogLocation = preload("res://scripts/canon_log/log_location.gd")
const PartyMember = preload("res://scripts/canon_log/party_member.gd")
const Quest = preload("res://scripts/canon_log/quest.gd")
const Humiliation = preload("res://scripts/canon_log/humiliation.gd")
func test_ids_regex():
assert_true(Ids.is_valid("greywater_docks"))
assert_false(Ids.is_valid("Bad Id"))
assert_false(Ids.is_valid("has-hyphen"))
func test_log_player_carries_race_and_calling_and_no_luck_number():
var p := LogPlayer.new("Aldric", "beastfolk", "cutpurse", "Fortune spits on you")
var d := p.to_dict()
assert_eq(d["race_id"], "beastfolk")
assert_eq(d["calling_id"], "cutpurse")
assert_false("luck" in d, "numeric Luck never reaches the log (§7)")
assert_false("class_id" in d)
assert_eq(d.keys().size(), 4)
func test_log_player_rejects_a_dead_calling():
var p := LogPlayer.new()
assert_false(p.set_calling_id("priest"), "priest is not a calling")
assert_true(p.set_calling_id("bonesetter"))
assert_false(p.set_race_id("orc"))
assert_true(p.set_race_id("dwarf"))
func test_log_player_ctor_populates_both_race_and_calling():
# Regression: three call sites once passed the OLD 3-arg (name, class_id,
# luck_descriptor) shape against the NEW 4-arg (name, race_id, calling_id,
# luck_descriptor) constructor. Every param has a default, so GDScript
# compiled it silently — the string meant as a calling landed in the
# race_id slot, got rejected, and both fields came out "". This asserts
# a properly-shaped call yields BOTH fields non-empty.
var p := LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you")
var d := p.to_dict()
assert_ne(d["race_id"], "", "race_id must not be empty")
assert_ne(d["calling_id"], "", "calling_id must not be empty")
func test_log_player_ctor_rejects_calling_passed_as_race():
# The exact shape of the bug: calling three broken call sites used to pass —
# LogPlayer.new(name, class_id, luck_descriptor) against the NEW ctor
# (name, race_id, calling_id, luck_descriptor). "sellsword" lands in the
# race_id slot (rejected — not a race), the descriptor lands in the
# calling_id slot (rejected — not a calling), and luck_descriptor is left
# at its default. Every field ends up "", which 422s against the schema.
var p := LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
var d := p.to_dict()
assert_eq(d["race_id"], "", "a calling id is not a legal race_id")
assert_eq(d["calling_id"], "", "a luck descriptor is not a legal calling_id")
assert_eq(d["luck_descriptor"], "", "old 3-arg call shape must not produce a usable player row")
func test_party_member_clamps_disposition():
var m = PartyMember.new("brannoc_thane", "Brannoc Thane", 200)
assert_eq(m.disposition, 100)
m.set_disposition(-200)
assert_eq(m.disposition, -100)
func test_quest_status_enum():
var q = Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find it")
assert_false(q.set_status("abandoned"))
assert_eq(q.status, "active")
assert_true(q.set_status("complete"))
func test_humiliation_clamps_weight_and_turn():
var h = Humiliation.new("h_1", "vomited on a shrine", 11, -3)
assert_eq(h.weight, 10)
assert_eq(h.turn, 0)
func test_round_trip_each_entity():
var loc = LogLocation.new("greywater_docks", "the Greywater docks")
assert_eq(LogLocation.from_dict(loc.to_dict()).to_dict(), loc.to_dict())
var m = PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15)
assert_eq(PartyMember.from_dict(m.to_dict()).to_dict(), m.to_dict())