Production fixes surfaced by honest test triage: - hud_groups.gd: _set_group_z crashed on freed HUD nodes — the typed loop variable errors before the is_instance_valid guard runs; prune first - fog_state.gd: _resize cleared _prev_visible (world-space keys survive resizes), so pre-resize tiles never decayed VISIBLE→EXPLORED (D-059) Test debt (T-928/929/934/935/936/937/938/939, T-864, T-973): lambda local-capture bugs rewritten with array captures (now assert exact emission counts), e2e suites updated to the current handshake + StartupMessage protocol and stream-aware reads against the live binary, fog perf test measures steady state, chime test pins the shipped 800ms catalog asset (D-067 amended separately), monologue gdUnit4 API typo, battery-warning tests follow the MetaScreen on_open lifecycle. 3 sprint2 proof tests revived (corner_reveal had passed from the wrong tile — NPC3 blocks (18,14); route corrected). Soft-skips converted to real do_skip reporting. T-1068: 7 orphan .gd.uid deleted, _format_pop/_format_radius deduped into atlas_format.gd (preload, no class_name — headless cache). Suite: 1264 cases/20 failures → 1268/0, independently re-verified (2536/2536, exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
461 lines
16 KiB
GDScript
461 lines
16 KiB
GDScript
## #122: Monologue display — client tests (Sprint 14)
|
|
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
|
|
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
|
|
##
|
|
## API per Tyre architecture review:
|
|
## show_monologue(text, duration, priority=2, is_urgent=false)
|
|
class_name TestMonologueDisplay
|
|
extends GdUnitTestSuite
|
|
|
|
|
|
# Suite prerequisite — reported as a real gdUnit4 skip (visible in the JSON
|
|
# summary) instead of the old push_warning-then-return soft-skip (T-1068).
|
|
func before(
|
|
_do_skip := not ResourceLoader.exists("res://ui/monologue_display.tscn"),
|
|
_skip_reason := "monologue_display.tscn not found"
|
|
) -> void:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _make_display() -> Node:
|
|
if not ResourceLoader.exists("res://ui/monologue_display.tscn"):
|
|
push_warning("TestMonologueDisplay: scene not found — tests skipped")
|
|
return null
|
|
var node = load("res://ui/monologue_display.tscn").instantiate()
|
|
add_child(node)
|
|
return node
|
|
|
|
|
|
func _label_text(d: Node) -> String:
|
|
if d._visible.is_empty():
|
|
return ""
|
|
var slot_node: Node = d._visible[0].node
|
|
return (slot_node.get_child(0) as RichTextLabel).text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle — ensure clean GameState between every test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func before_test() -> void:
|
|
## Reset GameState fields touched by this suite so tests don't bleed into each other.
|
|
## current_monologue: GameState integration tests need null as start state.
|
|
GameState.current_monologue = null
|
|
|
|
func after_test() -> void:
|
|
GameState.current_monologue = null
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Initial state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_nothing_visible_on_init() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
assert_int(d._visible.size()).is_equal(0)
|
|
assert_int(d._queue.size()).is_equal(0)
|
|
d.queue_free()
|
|
|
|
|
|
func test_stagger_timer_zero_on_init() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
|
d.queue_free()
|
|
|
|
|
|
func test_show_monologue_with_empty_text_does_not_set_displaying() -> void:
|
|
# Empty text must be ignored — no visible slot created, no queue entry.
|
|
# Prevents ghost display nodes and stagger timer contamination.
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("", 5.0)
|
|
assert_int(d._visible.size()).is_equal(0)
|
|
assert_int(d._queue.size()).is_equal(0)
|
|
d.queue_free()
|
|
|
|
|
|
func test_empty_text_does_not_advance_stagger_timer() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("", 5.0)
|
|
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
|
d.queue_free()
|
|
|
|
|
|
func test_empty_text_when_slots_full_does_not_enqueue() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
d.show_monologue("", 5.0)
|
|
assert_int(d._queue.size()).is_equal(0)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-line display
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_single_line_goes_to_visible() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("One.", 5.0)
|
|
assert_int(d._visible.size()).is_equal(1)
|
|
assert_int(d._queue.size()).is_equal(0)
|
|
d.queue_free()
|
|
|
|
|
|
func test_show_monologue_sets_stagger_timer() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
var before := float(Time.get_ticks_msec())
|
|
d.show_monologue("Stagger.", 5.0)
|
|
assert_float(d._next_fade_in_msec).is_greater(before)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MAX_VISIBLE = 3 simultaneous lines
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_three_lines_all_visible() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
assert_int(d._visible.size()).is_equal(3)
|
|
assert_int(d._queue.size()).is_equal(0)
|
|
d.queue_free()
|
|
|
|
|
|
func test_fourth_line_queues_when_slots_full() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
d.show_monologue("D", 5.0)
|
|
assert_int(d._visible.size()).is_equal(3)
|
|
assert_int(d._queue.size()).is_equal(1)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No-overwrite contract (P0, #477)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_new_line_does_not_replace_first_visible_line() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("First line.", 10.0)
|
|
var first_text := _label_text(d)
|
|
# Force stagger active; second call must queue, not display
|
|
d._next_fade_in_msec = float(Time.get_ticks_msec()) + 10000.0
|
|
d.show_monologue("Second line.", 5.0)
|
|
assert_that(_label_text(d)).is_equal(first_text)
|
|
d.queue_free()
|
|
|
|
|
|
func test_two_visible_lines_coexist_without_overwriting() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("Alpha", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("Beta", 10.0)
|
|
assert_int(d._visible.size()).is_equal(2)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Priority queue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_queue_sorted_highest_priority_first() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
d.show_monologue("low", 5.0, 1)
|
|
d.show_monologue("high", 5.0, 4)
|
|
d.show_monologue("normal", 5.0, 2)
|
|
assert_int(d._queue[0].priority).is_equal(4)
|
|
d.queue_free()
|
|
|
|
|
|
func test_queue_drop_replaces_lowest_when_full() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
for i in range(d.MAX_QUEUE):
|
|
d.show_monologue("low_%d" % i, 5.0, 1)
|
|
d.show_monologue("critical!", 5.0, 9)
|
|
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
|
|
var has_critical := false
|
|
for e in d._queue:
|
|
if e.priority == 9:
|
|
has_critical = true
|
|
assert_that(has_critical).is_true()
|
|
d.queue_free()
|
|
|
|
|
|
func test_queue_ignores_lower_priority_when_full() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
for i in range(d.MAX_QUEUE):
|
|
d.show_monologue("hi_%d" % i, 5.0, 5)
|
|
d.show_monologue("noise", 1.0, 1)
|
|
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
|
|
for e in d._queue:
|
|
assert_int(e.priority).is_equal(5)
|
|
d.queue_free()
|
|
|
|
|
|
func test_queue_never_exceeds_max_depth() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
for i in range(d.MAX_QUEUE + 20):
|
|
d.show_monologue("flood_%d" % i, 1.0, 2)
|
|
assert_int(d._queue.size()).is_less_equal(d.MAX_QUEUE)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Expire and drain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_expired_slot_removed_from_visible() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("Expires.", 1.0)
|
|
d._visible[0].expire_timer = -0.1
|
|
d._process(0.0)
|
|
assert_int(d._visible.size()).is_equal(0)
|
|
d.queue_free()
|
|
|
|
|
|
func test_queue_drains_when_slot_opens() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
|
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
|
d.show_monologue("Queued.", 5.0)
|
|
d._visible[0].expire_timer = -0.1
|
|
d._next_fade_in_msec = 0.0 # stagger elapsed
|
|
d._process(0.0)
|
|
assert_int(d._queue.size()).is_equal(0)
|
|
assert_int(d._visible.size()).is_equal(3)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stagger
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_second_call_within_stagger_period_queues() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("First.", 5.0)
|
|
# _next_fade_in_msec is now ~150ms in the future
|
|
d.show_monologue("Second.", 5.0)
|
|
assert_int(d._visible.size()).is_equal(1)
|
|
assert_int(d._queue.size()).is_equal(1)
|
|
d.queue_free()
|
|
|
|
|
|
func test_stagger_elapsed_allows_second_visible() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("First.", 5.0)
|
|
d._next_fade_in_msec = 0.0 # manually expire stagger
|
|
d.show_monologue("Second.", 5.0)
|
|
assert_int(d._visible.size()).is_equal(2)
|
|
assert_int(d._queue.size()).is_equal(0)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BBCode output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_text_wrapped_in_italic_bbcode() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("Italic line.", 5.0)
|
|
var txt := _label_text(d)
|
|
assert_that(txt).contains("[i]")
|
|
assert_that(txt).contains("[/i]")
|
|
assert_that(txt).contains("Italic line.")
|
|
d.queue_free()
|
|
|
|
|
|
func test_text_has_color_bbcode() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("Coloured.", 5.0)
|
|
var txt := _label_text(d)
|
|
assert_that(txt).contains("[color=#")
|
|
assert_that(txt).contains("[/color]")
|
|
d.queue_free()
|
|
|
|
|
|
func test_urgent_colour_differs_from_standard() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
|
|
d.show_monologue("Normal.", 5.0, 2, false)
|
|
var std_txt := _label_text(d)
|
|
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
|
d._next_fade_in_msec = 0.0
|
|
|
|
d.show_monologue("Urgent!", 5.0, 3, true)
|
|
var urg_txt := _label_text(d)
|
|
|
|
assert_that(std_txt).is_not_equal(urg_txt)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slot lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_visible_slot_has_tween() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("Has tween.", 5.0)
|
|
assert_that(d._visible[0].tween).is_not_null()
|
|
d.queue_free()
|
|
|
|
|
|
func test_visible_slot_stores_priority() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("Priority 7.", 5.0, 7)
|
|
assert_int(d._visible[0].priority).is_equal(7)
|
|
d.queue_free()
|
|
|
|
|
|
func test_expire_timer_decrements_in_process() -> void:
|
|
var d = _make_display()
|
|
if d == null: return
|
|
d.show_monologue("Timer.", 10.0)
|
|
d._process(1.5)
|
|
assert_float(d._visible[0].expire_timer).is_less(10.0)
|
|
d.queue_free()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GameState integration — current_monologue field (v5, #414)
|
|
# These tests do not instantiate the scene — they verify apply_snapshot()
|
|
# correctly populates and clears current_monologue so the display layer
|
|
# receives valid data (or null) every tick.
|
|
#
|
|
# Joint test plan (sprint-14/joint.md §Test Plan Alignment):
|
|
# "Verify current_monologue: None produces no display (no ghost text from previous tick)"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_gamestate_monologue_null_when_snapshot_omits_field() -> void:
|
|
## D-016: Snapshot without current_monologue must clear the field.
|
|
## Prevents a stale line from a previous tick persisting as ghost text.
|
|
GameState.current_monologue = {"id": "stale", "text": "Old.", "duration_seconds": 3.0}
|
|
GameState.apply_snapshot({"tick": 2})
|
|
assert_that(GameState.current_monologue).is_null()
|
|
|
|
|
|
func test_gamestate_monologue_set_from_snapshot() -> void:
|
|
## apply_snapshot populates current_monologue when the field is a dict.
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"current_monologue": {"id": "m001", "text": "A thought.", "duration_seconds": 5.0},
|
|
})
|
|
assert_that(GameState.current_monologue).is_not_null()
|
|
assert_that(GameState.current_monologue.get("text")).is_equal("A thought.")
|
|
|
|
|
|
func test_gamestate_monologue_null_when_field_is_non_dict() -> void:
|
|
## Defensive: a non-dictionary value from the server must be rejected (null).
|
|
GameState.apply_snapshot({"tick": 1, "current_monologue": "not-a-dict"})
|
|
assert_that(GameState.current_monologue).is_null()
|
|
|
|
|
|
func test_gamestate_monologue_duration_seconds_survives_round_trip() -> void:
|
|
## duration_seconds feeds show_monologue()'s duration param — must not be lost.
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"current_monologue": {"id": "m1", "text": "Test.", "duration_seconds": 7.5},
|
|
})
|
|
var dur: float = float(GameState.current_monologue.get("duration_seconds"))
|
|
assert_that(dur).is_equal_approx(7.5, 0.001)
|
|
|
|
|
|
func test_gamestate_monologue_id_survives_round_trip() -> void:
|
|
## The id field is used by SimBridge carry-forward deduplication (#477).
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"current_monologue": {"id": "enter_cargo_bay_001", "text": "Hmm.", "duration_seconds": 3.0},
|
|
})
|
|
assert_that(GameState.current_monologue.get("id")).is_equal("enter_cargo_bay_001")
|
|
|
|
|
|
func test_gamestate_monologue_replaced_by_next_snapshot() -> void:
|
|
## Each snapshot with a monologue replaces the previous value — no accumulation.
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"current_monologue": {"id": "first", "text": "First.", "duration_seconds": 3.0},
|
|
})
|
|
GameState.apply_snapshot({
|
|
"tick": 2,
|
|
"current_monologue": {"id": "second", "text": "Second.", "duration_seconds": 3.0},
|
|
})
|
|
assert_that(GameState.current_monologue.get("id")).is_equal("second")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# D-049 Z-layer / canvas scope compliance
|
|
# MonologueDisplay must be parented to UILayer (CanvasLayer, layer=20).
|
|
# Two tests: (1) constant sanity, (2) scene tree structural verification.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_canvas_ui_constant_is_20() -> void:
|
|
## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display.
|
|
## Sanity check — constant must not drift from the spec.
|
|
assert_that(Constants.CANVAS_UI).is_equal(20)
|
|
|
|
|
|
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene(
|
|
_do_skip := not ResourceLoader.exists("res://scenes/main.tscn"),
|
|
_skip_reason := "main.tscn not found"
|
|
) -> void:
|
|
## D-049: Structural verification — MonologueDisplay must be a direct child of
|
|
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
|
|
## Catches regressions where the node gets accidentally moved to InsertOverlay
|
|
## (layer=10) or MetaLayer (layer=30), or dropped into the world z-stack.
|
|
##
|
|
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
|
|
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
|
auto_free(scene)
|
|
add_child(scene)
|
|
|
|
var mono: Node = scene.get_node_or_null("UILayer/MonologueDisplay")
|
|
assert_that(mono != null).is_true()
|
|
|
|
var parent: Node = mono.get_parent()
|
|
assert_that(parent is CanvasLayer).is_true()
|
|
assert_that((parent as CanvasLayer).layer).is_equal(Constants.CANVAS_UI)
|