Relocates main_menu, character_creation, settings_dialog, debug_console,
bug_report_dialog, loading_screen from flat client/ui/ into structured
client/ui/meta/screens/<name>/. All six now extend MetaScreen instead
of Control; the base handles open/close lifecycle, visibility,
captures_input, and — for overlays — the sim-pause contract.
Screen policies set per Tyre's proposal:
- settings_dialog: pauses_sim=false, PUSHES onto MetaStack
- debug_console: pauses_sim=true, PUSHES (D-088 routing via base)
- bug_report_dialog: pauses_sim=true, PUSHES
- loading_screen: closable_by_escape=false, PUSHES
- main_menu, character_creation: scene-roots, extend MetaScreen for
the lifecycle contract only, do NOT push onto the stack
character_creation stays at its current surface (tabs, descriptor,
creation_confirmed signal unchanged). Tab consolidation and
CharacterProfile migration happen in Workstreams 5 and 6.
Knock-on changes:
- main.tscn ModalLayer CanvasLayer renamed to MetaLayer; main.gd
@onready refs updated; constants.gd comment updated; test_client_p3
and test_ui_framework_sprint15 assertions updated; test_monologue_display
and .tscn header comments updated.
- OPEN_MENU handler now pushes settings_dialog onto MetaStack before
calling open(). Full ESC priority chain lands in Workstream 4.
- atlas_app.gd: _unhandled_key_input signature widened from
InputEventKey to InputEvent with an is-check, per Godot 4 API. Pre-
existing narrowing was silently tolerated until main.tscn started
fully instantiating under the new pattern.
- test_client_p3: entity_renderer type annotations corrected from
ColorRect to Sprite2D (stale since a prior refactor); facing
indicator rotation assertion switched to angle_difference() for
modular-safe comparison.
Verification:
- gdlint client/scripts/ client/ui/ — zero problems
- godot --headless --path client --quit — no SCRIPT ERROR
- test_client_p3: 24/24 pass
- test_ui_framework_sprint15: 54/54 pass
- test_implant_nav_stack: 52/52 pass
- test_implant_registry: 42/42 pass
- test_implant_app_lifecycle: 36/36 pass
Workstream 1 foundation (84105916) remains unchanged. Workstreams 3-8
follow: protocol layer, Option A sequencing, 3-tab restructure,
Bookmark tab, location picker, Skills stub.
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: MetaLayer = 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("MetaLayer")
|
|
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: MetaLayer (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")
|