refactor(ui): monologue display — multi-line architecture per Tyre review (#122)
Rewrites the monologue display system per Tyre architecture review (Sprint 14): Rendering: - Up to 3 simultaneous visible lines (VBoxContainer, dynamic node creation) - Lines created programmatically as MarginContainer > RichTextLabel per slot - Percentage-based anchors: 5% left, 75–98% vertical (25% area from bottom), 50% max width - Z-layer 7 in UILayer (CanvasLayer 20, D-049) Queue: - 5-entry priority queue; highest priority drains first - On overflow: incoming line replaces lowest-priority queued entry if it outranks it - Lower/equal priority incoming lines silently dropped when queue full API: show_monologue(text, duration, priority=2, is_urgent=false) - Replaces old (text, duration, character_type) signature - main.gd passes priority and is_urgent from MonologueEvent fields - Confrontation monologue: priority=3, is_urgent=true (D-063) Colour: - Reads GameState.lattice_profile at render time (D-032) - lattice_augmented (detective): standard #d0d4e0 / urgent #e0e8f8 - lattice_baseline (smuggler): standard #d8d0c4 / urgent #f0e4d4 - Fallback for unknown profiles; no crash Stagger: 0.15s between consecutive fade-ins (spec §5.4) Opacity: standard 0.85, urgent 1.0; bloom deferred GameState: adds lattice_profile field, parsed from snapshot Tests: 27 gdUnit4 test cases — queue order, priority drop, overflow, stagger, no-overwrite (P0 #477), BBCode output, palette selection, slot lifecycle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
## #122: Monologue display — client tests (Sprint 14)
|
||||
## Tests queue management, character colour, italic BBCode, and no-overwrite contract.
|
||||
## Spec: Sprint 14 briefing (D-016, D-032, D-055, P0 #477).
|
||||
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
|
||||
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
|
||||
##
|
||||
## Approach: instantiate the scene, drive show_monologue() directly, inspect internal
|
||||
## state and label text. Fade timing is tested by manipulating _fade_timer and calling
|
||||
## _process() rather than awaiting real time — keeps the suite fast and deterministic.
|
||||
## API per Tyre architecture review:
|
||||
## show_monologue(text, duration, priority=2, is_urgent=false)
|
||||
## GameState.lattice_profile selects colour palette
|
||||
class_name TestMonologueDisplay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -17,73 +17,81 @@ func _make_display() -> Node:
|
||||
if not ResourceLoader.exists("res://ui/monologue_display.tscn"):
|
||||
push_warning("TestMonologueDisplay: scene not found — tests skipped")
|
||||
return null
|
||||
var scene = load("res://ui/monologue_display.tscn")
|
||||
var node = scene.instantiate()
|
||||
var node = load("res://ui/monologue_display.tscn").instantiate()
|
||||
add_child(node)
|
||||
# _ready() fires here; panel.modulate.a = 0, _displaying = false
|
||||
return node
|
||||
|
||||
|
||||
func _label_text(d: Node) -> String:
|
||||
var slot_node: Node = d._visible[0].node
|
||||
return (slot_node.get_child(0) as RichTextLabel).text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour mapping
|
||||
# Initial state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_detective_colour_is_cool_blue() -> void:
|
||||
func test_nothing_visible_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
var c: Color = d._color_for_character("detective")
|
||||
# Must not be the default/neutral colour
|
||||
assert_that(c).is_not_equal(d.COLOR_DEFAULT)
|
||||
# Blue channel dominant
|
||||
assert_float(c.b).is_greater(c.r)
|
||||
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_smuggler_colour_is_warm_amber() -> void:
|
||||
func test_stagger_timer_zero_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
var c: Color = d._color_for_character("smuggler")
|
||||
assert_that(c).is_not_equal(d.COLOR_DEFAULT)
|
||||
# Red channel dominant (amber)
|
||||
assert_float(c.r).is_greater(c.b)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_unknown_character_returns_default_colour() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
assert_that(d._color_for_character("")).is_equal(d.COLOR_DEFAULT)
|
||||
assert_that(d._color_for_character("merchant")).is_equal(d.COLOR_DEFAULT)
|
||||
if d == null: return
|
||||
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode output — italic + colour tags
|
||||
# Single-line display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_show_monologue_wraps_text_in_italic_bbcode() -> void:
|
||||
func test_single_line_goes_to_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("Test line.", 5.0, "")
|
||||
var txt: String = d.text_label.text
|
||||
assert_that(txt).contains("[i]")
|
||||
assert_that(txt).contains("[/i]")
|
||||
assert_that(txt).contains("Test line.")
|
||||
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_includes_color_tag() -> void:
|
||||
func test_show_monologue_sets_stagger_timer() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("Colour test.", 5.0, "detective")
|
||||
var txt: String = d.text_label.text
|
||||
assert_that(txt).contains("[color=#")
|
||||
assert_that(txt).contains("[/color]")
|
||||
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()
|
||||
|
||||
|
||||
@@ -91,163 +99,244 @@ func test_show_monologue_includes_color_tag() -> void:
|
||||
# No-overwrite contract (P0, #477)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_second_call_does_not_overwrite_active_display() -> void:
|
||||
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, "")
|
||||
assert_that(d._displaying).is_true()
|
||||
var first_text: String = d.text_label.text
|
||||
|
||||
d.show_monologue("Second line.", 5.0, "")
|
||||
# Text label must still show the first line
|
||||
assert_that(d.text_label.text).is_equal(first_text)
|
||||
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_second_call_while_active_is_queued() -> void:
|
||||
func test_two_visible_lines_coexist_without_overwriting() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("First.", 10.0, "")
|
||||
d.show_monologue("Second.", 5.0, "")
|
||||
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_or_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)
|
||||
assert_that(d._queue[0].text).is_equal("Second.")
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queue management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_queue_drains_after_fade_complete() -> void:
|
||||
func test_stagger_elapsed_allows_second_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("Line A.", 1.0, "")
|
||||
d.show_monologue("Line B.", 2.0, "")
|
||||
|
||||
# Simulate fade completing on line A
|
||||
d._displaying = false
|
||||
d._on_fade_complete()
|
||||
|
||||
assert_that(d._displaying).is_true()
|
||||
assert_that(d.text_label.text).contains("Line B.")
|
||||
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()
|
||||
|
||||
|
||||
func test_queue_empty_after_fade_complete_does_nothing() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("Only line.", 1.0, "")
|
||||
d._displaying = false
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# No items queued — should not crash and _displaying stays false
|
||||
d._on_fade_complete()
|
||||
assert_that(d._displaying).is_false()
|
||||
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_queue_preserves_character_type() -> void:
|
||||
func test_text_has_color_bbcode() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("First.", 5.0, "detective")
|
||||
d.show_monologue("Second.", 3.0, "smuggler")
|
||||
|
||||
assert_that(d._queue[0].character_type).is_equal("smuggler")
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_multiple_queued_items_drain_in_order() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("A", 1.0, "")
|
||||
d.show_monologue("B", 1.0, "")
|
||||
d.show_monologue("C", 1.0, "")
|
||||
|
||||
d._displaying = false
|
||||
d._on_fade_complete() # should display B
|
||||
assert_that(d.text_label.text).contains("B")
|
||||
|
||||
d._displaying = false
|
||||
d._on_fade_complete() # should display C
|
||||
assert_that(d.text_label.text).contains("C")
|
||||
|
||||
d._displaying = false
|
||||
d._on_fade_complete() # queue empty — nothing new
|
||||
assert_that(d._displaying).is_false()
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queue depth cap
|
||||
# Lattice colour palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_queue_does_not_exceed_max_depth() -> void:
|
||||
func test_augmented_colour_differs_from_baseline() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
# First call starts displaying immediately (not queued)
|
||||
d.show_monologue("Active.", 99.0, "")
|
||||
if d == null: return
|
||||
|
||||
# Fill queue to max
|
||||
for i in range(d.MAX_QUEUE_DEPTH + 5):
|
||||
d.show_monologue("Overflow %d" % i, 1.0, "")
|
||||
GameState.lattice_profile = "lattice_augmented"
|
||||
d.show_monologue("Detective.", 5.0)
|
||||
var aug_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE_DEPTH)
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Smuggler.", 5.0)
|
||||
var base_txt := _label_text(d)
|
||||
|
||||
assert_that(aug_txt).is_not_equal(base_txt)
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_urgent_colour_differs_from_standard() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
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()
|
||||
|
||||
|
||||
func test_unknown_profile_falls_back_without_crash() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
GameState.lattice_profile = "lattice_hypothetical_tier_x"
|
||||
d.show_monologue("Future proof.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fade timer integration
|
||||
# Slot lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_process_triggers_fade_after_duration() -> void:
|
||||
func test_visible_slot_has_tween() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("Timer test.", 2.0, "")
|
||||
assert_that(d._displaying).is_true()
|
||||
|
||||
# Drive timer past duration without awaiting real time
|
||||
d._fade_timer = 2.1
|
||||
d._process(0.0)
|
||||
|
||||
# _displaying should now be false (fade_out called)
|
||||
assert_that(d._displaying).is_false()
|
||||
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_process_does_not_fade_before_duration() -> void:
|
||||
func test_visible_slot_stores_priority() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
d.show_monologue("Still showing.", 5.0, "")
|
||||
d._fade_timer = 2.0
|
||||
d._process(0.0)
|
||||
assert_that(d._displaying).is_true()
|
||||
if d == null: return
|
||||
d.show_monologue("Priority 7.", 5.0, 7)
|
||||
assert_int(d._visible[0].priority).is_equal(7)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_panel_starts_invisible() -> void:
|
||||
func test_expire_timer_decrements_in_process() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
assert_float(d.text_panel.modulate.a).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_not_displaying_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null:
|
||||
return
|
||||
assert_that(d._displaying).is_false()
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user