- Preload stance icons at _ready() into cache Dictionary - Null-guard shader + theme loads with push_error() + early return - Guard _icon_mat.set_shader_parameter inside if _icon_mat: - Icon size 18→20px per D-086, container height 44→46px to fit - Add _apply_stance test coverage (known + unknown stance) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
12 KiB
GDScript
346 lines
12 KiB
GDScript
## Sprint 15 — Basic UI framework validation tests (#74)
|
|
## Validates HUD structure, z-layer hierarchy, insert_active control,
|
|
## and monologue display wiring per D-049, D-056, D-057, D-061, OQ-07.
|
|
class_name TestUIFrameworkSprint15
|
|
extends GdUnitTestSuite
|
|
|
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
|
|
|
var _instance: Node = null
|
|
|
|
|
|
func before_test() -> void:
|
|
SimBridge.reset_test_state()
|
|
GameState.current_tick = 0
|
|
GameState.player_position = Vector2.ZERO
|
|
GameState.visible_entities = []
|
|
GameState.visible_tiles = []
|
|
GameState.visible_positions = {}
|
|
GameState.current_monologue = null
|
|
GameState.current_dialogue = null
|
|
GameState.insert_active = true
|
|
|
|
|
|
func after_test() -> void:
|
|
if _instance and is_instance_valid(_instance):
|
|
_instance.queue_free()
|
|
_instance = null
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# D-076: Layout constants
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_dialogue_max_width_set() -> void:
|
|
# DIALOGUE_MAX_WIDTH = 1200px (supersedes D-076 640px default per Tyre review).
|
|
assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(1200)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# D-049: Z-layer scene hierarchy
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_insert_overlay_is_canvas_layer_10() -> void:
|
|
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
|
|
# Constants.CANVAS_INSERT must match.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var insert_overlay: CanvasLayer = _instance.get_node("InsertOverlay")
|
|
assert_that(insert_overlay).is_not_null()
|
|
assert_that(insert_overlay.layer).is_equal(Constants.CANVAS_INSERT)
|
|
|
|
|
|
func test_ui_layer_is_canvas_layer_20() -> void:
|
|
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var ui_layer: CanvasLayer = _instance.get_node("UILayer")
|
|
assert_that(ui_layer).is_not_null()
|
|
assert_that(ui_layer.layer).is_equal(Constants.CANVAS_UI)
|
|
|
|
|
|
func test_modal_layer_is_canvas_layer_30() -> void:
|
|
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var modal_layer: CanvasLayer = _instance.get_node("ModalLayer")
|
|
assert_that(modal_layer).is_not_null()
|
|
assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL)
|
|
|
|
|
|
func test_ui_layer_above_insert_overlay() -> void:
|
|
# D-049: UILayer (20) must render above InsertOverlay (10).
|
|
assert_that(Constants.CANVAS_UI).is_greater(Constants.CANVAS_INSERT)
|
|
|
|
|
|
func test_modal_layer_above_ui_layer() -> void:
|
|
# D-049: ModalLayer (30) must render above UILayer (20).
|
|
assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# D-049: Required nodes exist in correct layers
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_monologue_display_exists_in_ui_layer() -> void:
|
|
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("UILayer/MonologueDisplay")).is_not_null()
|
|
|
|
|
|
func test_stance_indicator_exists_in_ui_layer() -> void:
|
|
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("UILayer/StanceIndicator")).is_not_null()
|
|
|
|
|
|
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
|
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
|
|
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
|
|
|
|
|
|
func test_hud_exists_in_insert_overlay() -> void:
|
|
# D-051: HUD moved to InsertOverlay (layer 10) for bloom treatment (#786, PR #115).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("InsertOverlay/HUD")).is_not_null()
|
|
|
|
|
|
func test_interaction_list_exists_in_insert_overlay() -> void:
|
|
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("InsertOverlay/InteractionList")).is_not_null()
|
|
|
|
|
|
func test_dialogue_box_exists_in_insert_overlay() -> void:
|
|
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("InsertOverlay/DialogueBox")).is_not_null()
|
|
|
|
|
|
func test_world_radial_exists_in_insert_overlay() -> void:
|
|
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("InsertOverlay/WorldRadial")).is_not_null()
|
|
|
|
|
|
func test_cursor_renderer_exists_in_ui_layer() -> void:
|
|
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("UILayer/CursorRenderer")).is_not_null()
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# OQ-07 / D-056: insert_active controls z-layer 6 visibility
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_gamestate_insert_active_defaults_true() -> void:
|
|
# OQ-07: v0.1 characters all have inserts — default true.
|
|
assert_that(GameState.insert_active).is_true()
|
|
|
|
|
|
func test_insert_active_propagates_on_process() -> void:
|
|
# OQ-07 (#522): After apply_snapshot with insert_active=false,
|
|
# the next _process() call must propagate the state to z-layer-6 nodes.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
# Inject a snapshot with insert_active = false
|
|
var snap := SimBridge._test_snapshot()
|
|
snap["insert_active"] = false
|
|
GameState.apply_snapshot(snap)
|
|
assert_that(GameState.insert_active).is_false()
|
|
|
|
|
|
func test_insert_active_true_from_snapshot() -> void:
|
|
# OQ-07: Snapshot with insert_active=true keeps GameState in default-on state.
|
|
var snap := SimBridge._test_snapshot()
|
|
snap["insert_active"] = true
|
|
GameState.apply_snapshot(snap)
|
|
assert_that(GameState.insert_active).is_true()
|
|
|
|
|
|
func test_insert_active_missing_field_defaults_true() -> void:
|
|
# OQ-07: Old servers without insert_active field must not disable the insert.
|
|
var snap := SimBridge._test_snapshot()
|
|
snap.erase("insert_active")
|
|
GameState.apply_snapshot(snap)
|
|
assert_that(GameState.insert_active).is_true()
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# #241 stub: follow_target_id for entity sprite system
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_follow_target_id_stub_exists() -> void:
|
|
# #72 / #241: follow_target_id stub must exist on GameState with default -1.
|
|
# Populated by server ticket #241 (Follow verb) when it lands.
|
|
assert_that(GameState.follow_target_id).is_equal(-1)
|
|
|
|
|
|
func test_follow_target_id_is_negative_one_by_default() -> void:
|
|
# #72: -1 means "not following" — client #72 checks this for entity highlight.
|
|
SimBridge.reset_test_state()
|
|
GameState.apply_snapshot(SimBridge._test_snapshot())
|
|
# Server doesn't send follow_target_id yet — must stay -1 after snapshot
|
|
assert_that(GameState.follow_target_id).is_equal(-1)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Monologue display wiring (#414)
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_monologue_display_receives_first_tick_monologue() -> void:
|
|
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
|
|
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
# Tick 1 snapshot has a monologue (SimBridge test mode)
|
|
# _ready() consumes tick 0 (no monologue). _process() here gets tick 1.
|
|
_instance._process(0.016)
|
|
|
|
# If monologue_display received it, current_monologue is cleared (consume-once)
|
|
assert_that(GameState.current_monologue).is_null()
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Regression: Sprint 14 integration proofs (D-030 regression markers)
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_fog_group_exists_in_world() -> void:
|
|
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("World/FogGroup")).is_not_null()
|
|
|
|
|
|
func test_floor_tiles_in_fog_group() -> void:
|
|
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("World/FogGroup/FloorTiles")).is_not_null()
|
|
|
|
|
|
func test_entities_in_ysort_group() -> void:
|
|
# Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100).
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null()
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# #71: Tilemap z-filter — FloorTiles only renders floor level 0
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_tile_renderer_skips_nonzero_z() -> void:
|
|
# #71: Tiles with z != 0 must be filtered out by update_tiles().
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var tile_renderer: TileMapLayer = _instance.get_node("World/FogGroup/FloorTiles")
|
|
assert_that(tile_renderer).is_not_null()
|
|
|
|
# Feed tiles at z=0 and z=1
|
|
var tiles: Array = [
|
|
{"x": 0, "y": 0, "z": 0, "type": "floor"},
|
|
{"x": 1, "y": 0, "z": 1, "type": "floor"},
|
|
{"x": 2, "y": 0, "z": 0, "type": "wall"},
|
|
{"x": 3, "y": 0, "z": 2, "type": "door"},
|
|
]
|
|
tile_renderer.update_tiles(tiles)
|
|
|
|
# z=0 tiles should be present
|
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(0, 0))).is_not_equal(-1)
|
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(2, 0))).is_not_equal(-1)
|
|
# z=1 and z=2 tiles should NOT be present (-1 = no cell)
|
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(1, 0))).is_equal(-1)
|
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 0))).is_equal(-1)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Stance indicator icon tests (#787)
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_stance_indicator_applies_known_stance() -> void:
|
|
# #787: _apply_stance updates icon + label for each known stance.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var si = _instance.get_node_or_null("UILayer/StanceIndicator")
|
|
assert_that(si).is_not_null()
|
|
si._apply_stance("Careful")
|
|
assert_that(si.get_current_stance()).is_equal("Careful")
|
|
|
|
|
|
func test_stance_indicator_unknown_stance_no_crash() -> void:
|
|
# #787: Unknown stance falls back gracefully — no crash, no texture change.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var si = _instance.get_node_or_null("UILayer/StanceIndicator")
|
|
assert_that(si).is_not_null()
|
|
si._apply_stance("InvalidStance")
|
|
assert_that(si.get_current_stance()).is_equal("InvalidStance")
|