diff --git a/client/tests/test_bug_report_ring_buffer.gd b/client/tests/test_bug_report_ring_buffer.gd new file mode 100644 index 000000000..61350f8ac --- /dev/null +++ b/client/tests/test_bug_report_ring_buffer.gd @@ -0,0 +1,346 @@ +## #507: WRONG button ring buffer tests. +## Tests the 60-tick rolling history upgrade to bug_report_dialog.gd. +## Covers ring buffer capacity, circular overwrite, JSONL format compliance, +## replay compatibility, seed file, and regression against MVP behavior. +## +## MVP baseline (must still pass): snapshot.json, render.txt, description.txt. +## New outputs: inputs.jsonl, snapshots.jsonl, seed.txt. +## +## Spec refs: D-030 (testability), D-020 (ObserverSnapshot boundary) +## Sprint 11 client.md §Notes #507 +class_name TestBugReportRingBuffer +extends GdUnitTestSuite + + +var BugReportDialogScript = load("res://ui/bug_report_dialog.gd") + +# Expected ring buffer capacity per spec. +const EXPECTED_CAPACITY := 60 + + +# -- Helpers ------------------------------------------------------------------- + +func _make_dialog() -> Control: + var dialog = Control.new() + dialog.set_script(BugReportDialogScript) + auto_free(dialog) + add_child(dialog) + return dialog + + +## Create a minimal valid PlayerInput dict matching the replay.rs wire format. +## replay.rs expects: {"tick": N, "action": "MoveNorth"} or action as dict. +func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary: + return {"tick": tick, "action": action} + + +## Make a minimal snapshot JSON string for snapshot buffer testing. +func _make_snapshot_json(tick: int) -> String: + return JSON.stringify({ + "tick": tick, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + }) + + +## Check a string is valid JSON array — mirrors replay.rs line expectation. +func _is_valid_json_array(line: String) -> bool: + if line.is_empty(): + return false + var result = JSON.parse_string(line) + return result != null and result is Array + + +# -- Ring buffer capacity ------------------------------------------------------ + +func test_buffer_capacity_is_60() -> void: + # Spec: "Maintain a 60-entry circular buffer" + var dialog = _make_dialog() + if not dialog.has_method("_get_buffer_capacity"): + push_warning("TestBugReportRingBuffer: _get_buffer_capacity not found — awaiting #507") + return + assert_that(dialog._get_buffer_capacity()).override_failure_message( + "Ring buffer capacity must be 60 per spec" + ).is_equal(EXPECTED_CAPACITY) + + +func test_snapshot_buffer_capacity_is_60() -> void: + # Parallel snapshot buffer must match input buffer capacity. + var dialog = _make_dialog() + if not dialog.has_method("_get_snapshot_buffer_capacity"): + push_warning("TestBugReportRingBuffer: _get_snapshot_buffer_capacity not found — awaiting #507") + return + assert_that(dialog._get_snapshot_buffer_capacity()).is_equal(EXPECTED_CAPACITY) + + +# -- Pre-allocation ----------------------------------------------------------- + +func test_input_buffer_preallocated_at_ready() -> void: + # Spec: "pre-allocate the 60-slot arrays at startup. Do not allocate on every tick." + var dialog = _make_dialog() + if not dialog.has_method("_get_input_buffer"): + push_warning("TestBugReportRingBuffer: _get_input_buffer not found — awaiting #507") + return + var buf = dialog._get_input_buffer() + assert_that(buf is Array).override_failure_message( + "Input buffer must be an Array" + ).is_true() + assert_that(buf.size()).override_failure_message( + "Input buffer must be pre-allocated at capacity (%d slots)" % EXPECTED_CAPACITY + ).is_equal(EXPECTED_CAPACITY) + + +# -- Push and fill behavior --------------------------------------------------- + +func test_push_fills_input_buffer() -> void: + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs"): + push_warning("TestBugReportRingBuffer: _push_tick_inputs not found — awaiting #507") + return + if not dialog.has_method("_get_filled_input_count"): + push_warning("TestBugReportRingBuffer: _get_filled_input_count not found — awaiting #507") + return + for i in range(10): + dialog._push_tick_inputs(i, [_make_input(i)]) + assert_that(dialog._get_filled_input_count()).override_failure_message( + "10 pushed ticks should produce filled count of 10" + ).is_equal(10) + + +func test_push_fills_snapshot_buffer() -> void: + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_snapshot"): + push_warning("TestBugReportRingBuffer: _push_tick_snapshot not found — awaiting #507") + return + if not dialog.has_method("_get_filled_snapshot_count"): + push_warning("TestBugReportRingBuffer: _get_filled_snapshot_count not found — awaiting #507") + return + for i in range(10): + dialog._push_tick_snapshot(_make_snapshot_json(i)) + assert_that(dialog._get_filled_snapshot_count()).is_equal(10) + + +# -- Circular overwrite ------------------------------------------------------- + +func test_circular_overwrite_evicts_oldest() -> void: + # After 61 pushes, filled count must be 60 (oldest evicted). + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"): + push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507") + return + for i in range(EXPECTED_CAPACITY + 1): + dialog._push_tick_inputs(i, [_make_input(i)]) + assert_that(dialog._get_filled_input_count()).override_failure_message( + "After 61 pushes, buffer must hold exactly 60 entries (oldest evicted)" + ).is_equal(EXPECTED_CAPACITY) + + +func test_circular_overwrite_keeps_newest_inputs() -> void: + # After 61 pushes, the flushed JSONL must contain tick 1..60 (not tick 0). + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"): + push_warning("TestBugReportRingBuffer: ring buffer flush API not found — awaiting #507") + return + # Push 61 ticks. Tick 0 should be evicted; ticks 1-60 should be present. + for i in range(EXPECTED_CAPACITY + 1): + dialog._push_tick_inputs(i, [_make_input(i, "MoveNorth")]) + var jsonl: String = dialog._format_inputs_jsonl() + # Tick 0 action would be at position 0, but after 61 pushes, position 0 was overwritten. + # We check that the output has exactly 60 lines. + var lines: PackedStringArray = jsonl.split("\n", false) + # Filter out blank lines (PackedStringArray has no filter — convert to Array first) + var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty()) + assert_that(non_blank.size()).override_failure_message( + "After 61 pushes, JSONL output must have exactly 60 non-blank lines" + ).is_equal(EXPECTED_CAPACITY) + + +func test_exact_60_pushes_no_eviction() -> void: + # After exactly 60 pushes, all 60 are present. + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"): + push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507") + return + for i in range(EXPECTED_CAPACITY): + dialog._push_tick_inputs(i, [_make_input(i)]) + assert_that(dialog._get_filled_input_count()).override_failure_message( + "After exactly 60 pushes, all 60 entries should be present" + ).is_equal(EXPECTED_CAPACITY) + + +# -- JSONL format compliance (replay.rs contract) ---------------------------- + +func test_inputs_jsonl_each_line_is_json_array() -> void: + # replay.rs: "Each line is a JSON array of PlayerInput for one tick." + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"): + push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507") + return + for i in range(3): + dialog._push_tick_inputs(i, [_make_input(i)]) + var jsonl: String = dialog._format_inputs_jsonl() + var lines: PackedStringArray = jsonl.split("\n", false) + var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty()) + for line in non_blank: + assert_that(_is_valid_json_array(line)).override_failure_message( + "Each JSONL line must be a valid JSON array, got: %s" % line + ).is_true() + + +func test_inputs_jsonl_idle_tick_is_empty_array() -> void: + # replay.rs: "Empty array = idle tick (no input sent)." + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"): + push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507") + return + dialog._push_tick_inputs(0, []) # Idle tick: no inputs + var jsonl: String = dialog._format_inputs_jsonl() + var lines: PackedStringArray = jsonl.split("\n", false) + var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty()) + assert_that(non_blank.size()).override_failure_message( + "One idle tick should produce exactly 1 JSONL line" + ).is_equal(1) + assert_that(non_blank[0].strip_edges()).override_failure_message( + "Idle tick line must be an empty JSON array '[]'" + ).is_equal("[]") + + +func test_inputs_jsonl_has_tick_field() -> void: + # replay.rs PlayerInput expects {"tick": N, "action": "..."} + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"): + push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507") + return + dialog._push_tick_inputs(42, [_make_input(42, "MoveEast")]) + var jsonl: String = dialog._format_inputs_jsonl() + # Parse the first line and verify structure + var lines: PackedStringArray = jsonl.split("\n", false) + var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty()) + assert_that(non_blank.size()).is_greater(0) + var arr = JSON.parse_string(non_blank[0]) + assert_that(arr is Array).is_true() + assert_that(arr.size() > 0).is_true() + var first_input: Dictionary = arr[0] + assert_that(first_input.has("tick")).override_failure_message( + "Each PlayerInput in JSONL must have 'tick' field" + ).is_true() + assert_that(first_input.has("action")).override_failure_message( + "Each PlayerInput in JSONL must have 'action' field" + ).is_true() + + +func test_inputs_jsonl_multiple_actions_per_tick() -> void: + # replay.rs supports multiple PlayerInput per tick (one array entry per action). + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"): + push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507") + return + dialog._push_tick_inputs(0, [ + _make_input(0, "MoveNorth"), + _make_input(0, "Pause"), + ]) + var jsonl: String = dialog._format_inputs_jsonl() + var lines: PackedStringArray = jsonl.split("\n", false) + var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty()) + assert_that(non_blank.size()).is_equal(1) + var arr = JSON.parse_string(non_blank[0]) + assert_that(arr is Array).is_true() + assert_that(arr.size()).override_failure_message( + "Tick with 2 actions must have 2 entries in the JSONL array" + ).is_equal(2) + + +func test_snapshots_jsonl_each_line_is_valid_json() -> void: + # Each snapshot line should be valid JSON (parsed snapshot). + var dialog = _make_dialog() + if not dialog.has_method("_push_tick_snapshot") or not dialog.has_method("_format_snapshots_jsonl"): + push_warning("TestBugReportRingBuffer: snapshots JSONL API not found — awaiting #507") + return + for i in range(3): + dialog._push_tick_snapshot(_make_snapshot_json(i)) + var jsonl: String = dialog._format_snapshots_jsonl() + var lines: PackedStringArray = jsonl.split("\n", false) + var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty()) + assert_that(non_blank.size()).is_equal(3) + for line in non_blank: + var parsed = JSON.parse_string(line) + assert_that(parsed != null).override_failure_message( + "Each snapshot line must be valid JSON, got: %s" % line + ).is_true() + assert_that(parsed is Dictionary).override_failure_message( + "Each snapshot line must parse to a Dictionary" + ).is_true() + + +# -- Seed file ---------------------------------------------------------------- + +func test_seed_written_on_capture() -> void: + # Spec: "Include the server's current RNG seed... Output as seed.txt" + # We can't easily verify file output in headless mode, but we verify + # the dialog has the method to produce the seed value. + var dialog = _make_dialog() + if not dialog.has_method("_get_current_seed"): + push_warning("TestBugReportRingBuffer: _get_current_seed not found — awaiting #507") + return + # Should return some Variant (int or String) without crashing + var seed_val = dialog._get_current_seed() + assert_that(seed_val != null).override_failure_message( + "_get_current_seed() must not return null (use 0 or 'unknown' if seed unavailable)" + ).is_true() + + +# -- Room metadata regression ------------------------------------------------- + +func test_description_txt_still_contains_room_id() -> void: + # Regression: MVP description.txt must still include room metadata. + GameState.current_tick = 5 + GameState.room_id = "warehouse_01" + GameState.player_stance = "Walk" + GameState.player_facing = "North" + GameState.player_position = Vector2(10.0, 10.0) + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.game_time = {} + GameState.current_monologue = null + GameState.current_dialogue = null + var dialog = _make_dialog() + # Call the render text method to verify room shows up in render output + # (description.txt itself writes via FileAccess, difficult in headless — test the render path) + if dialog.has_method("_render_snapshot_text"): + # The text render is for render.txt, but description.txt room comes from GameState.room_id + # We indirectly verify by checking that the game state has the right fields. + assert_that(GameState.room_id).is_equal("warehouse_01") + GameState.room_id = null + + +# -- MVP regression ----------------------------------------------------------- + +func test_render_snapshot_text_still_works() -> void: + # Regression: MVP _render_snapshot_text() must still work after upgrade. + GameState.current_tick = 10 + GameState.player_position = Vector2(5.0, 5.0) + GameState.player_facing = "East" + GameState.player_stance = "Careful" + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.game_time = {} + GameState.current_monologue = null + GameState.current_dialogue = null + var dialog = _make_dialog() + if not dialog.has_method("_render_snapshot_text"): + push_warning("TestBugReportRingBuffer: _render_snapshot_text missing — regression risk") + return + var text: String = dialog._render_snapshot_text() + assert_that(text.length() > 0).is_true() + assert_that(text.contains("t10")).is_true() + + +func test_dialog_is_active_api_unchanged() -> void: + # Regression: public API from MVP must be unchanged. + var dialog = _make_dialog() + assert_that(dialog.has_method("start_capture")).override_failure_message( + "start_capture() must still be present (MVP regression)" + ).is_true() + assert_that(dialog.has_method("is_active")).override_failure_message( + "is_active() must still be present (MVP regression)" + ).is_true() diff --git a/client/tests/test_insert_off_behavior.gd b/client/tests/test_insert_off_behavior.gd new file mode 100644 index 000000000..77b8a1500 --- /dev/null +++ b/client/tests/test_insert_off_behavior.gd @@ -0,0 +1,393 @@ +## #522: OQ-07 resolution — no-insert interaction behavior. +## Tests that when insert_active == false, interaction labels disappear +## (diegetic test from D-056 and D-057), and that cursor and interaction +## list agree on insert state. +## +## D-056: "Interaction labels render on z-layer 6 (insert overlay). +## If the insert is off, labels disappear." +## D-057: "Labels render on z-layer 6. If insert is off, labels disappear." +## +## OQ-07 resolution (expected option a): cursor shape still changes +## (character physically orients), but verb labels are suppressed. +## +## Spec refs: D-056, D-057, D-048, D-049 +## Ticket #522 +class_name TestInsertOffBehavior +extends GdUnitTestSuite + + +# -- Helpers ------------------------------------------------------------------- + +func _make_cursor() -> Node: + for path in ["res://ui/cursor_state_machine.tscn", "res://scenes/cursor.tscn", "res://ui/cursor.tscn"]: + if ResourceLoader.exists(path): + var scene = load(path) + var cursor = scene.instantiate() + add_child(cursor) + return cursor + return null + + +func _make_cursor_or_skip() -> Node: + var cursor = _make_cursor() + if cursor == null: + push_warning("TestInsertOffBehavior: cursor scene not found — test skipped (awaiting #522)") + return cursor + + +func _make_interaction_list() -> Node: + for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn", + "res://scenes/interaction_list.tscn"]: + if ResourceLoader.exists(path): + var scene = load(path) + var node = scene.instantiate() + add_child(node) + return node + return null + + +func _make_list_or_skip() -> Node: + var list = _make_interaction_list() + if list == null: + push_warning("TestInsertOffBehavior: interaction list scene not found — test skipped (awaiting #522)") + return list + + +func _set_test_interactions() -> void: + GameState.nearby_interactions = [{ + "entity_id": 2, + "entity_type": "Npc", + "distance": 1, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + ], + }] + + +func _clear_test_interactions() -> void: + GameState.nearby_interactions = [] + + +# -- Interaction list: diegetic test (D-056, D-057) --------------------------- + +func test_insert_off_hides_interaction_list() -> void: + # D-056/D-057 diegetic test: "If the insert is off, labels disappear." + var list = _make_list_or_skip() + if list == null: + return + _set_test_interactions() + if list.has_method("set_insert_active"): + list.set_insert_active(false) + if list.has_method("update_from_state"): + list.update_from_state() + if list.has_method("is_showing"): + assert_that(list.is_showing()).override_failure_message( + "D-056/D-057 diegetic test: interaction list must be hidden when insert is off" + ).is_false() + elif list.has_method("get_visible_verb_count"): + assert_that(list.get_visible_verb_count()).override_failure_message( + "Interaction list must show 0 verbs when insert is off" + ).is_equal(0) + list.queue_free() + _clear_test_interactions() + + +func test_insert_on_shows_interaction_list() -> void: + # When insert is on (normal state), verbs should be visible. + var list = _make_list_or_skip() + if list == null: + return + _set_test_interactions() + if list.has_method("set_insert_active"): + list.set_insert_active(true) + if list.has_method("update_from_state"): + list.update_from_state() + if list.has_method("is_showing"): + assert_that(list.is_showing()).override_failure_message( + "Interaction list must be visible when insert is on and verbs present" + ).is_true() + list.queue_free() + _clear_test_interactions() + + +func test_insert_reenable_restores_list() -> void: + # Toggling insert off then on restores verb list visibility. + var list = _make_list_or_skip() + if list == null: + return + _set_test_interactions() + if not list.has_method("set_insert_active"): + list.queue_free() + _clear_test_interactions() + return + list.set_insert_active(false) + if list.has_method("update_from_state"): + list.update_from_state() + list.set_insert_active(true) + if list.has_method("update_from_state"): + list.update_from_state() + if list.has_method("is_showing"): + assert_that(list.is_showing()).override_failure_message( + "Re-enabling insert must restore list visibility" + ).is_true() + list.queue_free() + _clear_test_interactions() + + +# -- Cursor: insert-off behavior (OQ-07 resolution option a) ------------------ + +func test_insert_off_suppresses_should_show_interactions() -> void: + # OQ-07 option (a): labels are suppressed when insert is off. + # Regardless of cursor visual state, should_show_interactions() must return false. + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if not cursor.has_method("set_insert_active"): + push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522") + cursor.queue_free() + return + cursor.set_insert_active(false) + if cursor.has_method("should_show_interactions"): + assert_that(cursor.should_show_interactions()).override_failure_message( + "should_show_interactions() must return false when insert is off" + ).is_false() + cursor.queue_free() + + +func test_insert_on_restores_should_show_interactions() -> void: + # After re-enabling insert, interactions should show again (not in weapon mode). + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if not cursor.has_method("set_insert_active"): + push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522") + cursor.queue_free() + return + cursor.set_insert_active(false) + cursor.set_insert_active(true) + if cursor.has_method("should_show_interactions"): + assert_that(cursor.should_show_interactions()).override_failure_message( + "should_show_interactions() must return true after re-enabling insert" + ).is_true() + cursor.queue_free() + + +func test_insert_off_at_startup_no_corruption() -> void: + # Insert can be off from the start — no state machine corruption. + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if not cursor.has_method("set_insert_active") or not cursor.has_method("get_state"): + cursor.queue_free() + return + cursor.set_insert_active(false) + # Cursor must be in a valid state (not null/error) + var state_str := str(cursor.get_state()) + assert_that(["Default", "EntityHover", "ObjectHover", "WeaponAim"].has(state_str)).override_failure_message( + "Cursor must be in a valid state after insert-off at startup, got: %s" % state_str + ).is_true() + cursor.queue_free() + + +# -- OQ-07 option (a): cursor shape still changes, labels suppressed ---------- + +func test_insert_off_option_a_cursor_still_transitions() -> void: + # Option (a): cursor shape changes even when insert off + # (character physically orients to target, just no labels). + # If implementation chose option (b) instead, this test would fail — that's informative. + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if not cursor.has_method("set_insert_active") or not cursor.has_method("set_hover_target"): + push_warning("TestInsertOffBehavior: set_insert_active or set_hover_target not found — awaiting #522") + cursor.queue_free() + return + cursor.set_insert_active(false) + cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": true}) + # Option (a): cursor SHOULD be in EntityHover despite insert being off + # Option (b): cursor would stay in Default + # Test documents expected behavior — fail message explains which option is active + if cursor.has_method("get_state"): + var state := str(cursor.get_state()) + # If this assertion fails, option (b) was implemented (cursor full-suppressed). + # Neither (a) nor (b) is wrong — this test documents which was chosen. + # Consult decisions/perception.md for the OQ-07 amendment. + assert_that(state).override_failure_message( + "OQ-07 option (a): cursor should still transition to EntityHover when insert is off. " + + "If this fails, option (b) was implemented (full suppression) — update this test to assert 'Default' instead." + ).is_equal("EntityHover") + cursor.queue_free() + + +# -- Edge cases --------------------------------------------------------------- + +func test_insert_off_plus_weapon_mode_stays_suppressed() -> void: + # Insert-off AND weapon mode: should_show_interactions() must still return false. + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if not cursor.has_method("set_insert_active") or not cursor.has_method("set_weapon_mode"): + cursor.queue_free() + return + cursor.set_insert_active(false) + cursor.set_weapon_mode(true) + if cursor.has_method("should_show_interactions"): + assert_that(cursor.should_show_interactions()).override_failure_message( + "Insert-off + weapon mode must keep should_show_interactions() false" + ).is_false() + cursor.queue_free() + + +func test_insert_off_shift_override_still_suppressed() -> void: + # OQ-07: Insert-off trumps Shift override. + # Shift restores interactions in weapon mode, but insert-off is a harder constraint. + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if not cursor.has_method("set_insert_active") or not cursor.has_method("set_shift_held"): + cursor.queue_free() + return + cursor.set_insert_active(false) + if cursor.has_method("set_shift_held"): + cursor.set_shift_held(true) + if cursor.has_method("should_show_interactions"): + assert_that(cursor.should_show_interactions()).override_failure_message( + "Insert-off must suppress interactions even with Shift held (insert-off > shift override)" + ).is_false() + cursor.queue_free() + + +func test_insert_off_rapid_toggle_no_corruption() -> void: + # Rapid on/off toggle must not leave either system in inconsistent state. + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if not cursor.has_method("set_insert_active"): + cursor.queue_free() + return + for _i in range(10): + cursor.set_insert_active(false) + cursor.set_insert_active(true) + # After 10 toggles, insert should be on + if cursor.has_method("should_show_interactions"): + assert_that(cursor.should_show_interactions()).override_failure_message( + "After rapid toggle ending on 'on', interactions must be visible" + ).is_true() + cursor.queue_free() + + +# -- Cross-system consistency (cursor + list agree) --------------------------- + +func test_cursor_and_list_agree_on_insert_off() -> void: + # Both cursor and interaction list must be suppressed when insert is off. + # This tests the integration contract — both must read from the same source of truth. + var cursor = _make_cursor_or_skip() + var list = _make_list_or_skip() + + if cursor == null or list == null: + if cursor != null: cursor.queue_free() + if list != null: list.queue_free() + return + + _set_test_interactions() + + if cursor.has_method("set_insert_active") and list.has_method("set_insert_active"): + cursor.set_insert_active(false) + list.set_insert_active(false) + if list.has_method("update_from_state"): + list.update_from_state() + + var cursor_suppressed := true + if cursor.has_method("should_show_interactions"): + cursor_suppressed = not cursor.should_show_interactions() + + var list_suppressed := true + if list.has_method("is_showing"): + list_suppressed = not list.is_showing() + elif list.has_method("get_visible_verb_count"): + list_suppressed = list.get_visible_verb_count() == 0 + + assert_that(cursor_suppressed).override_failure_message( + "Cursor: should_show_interactions() must return false when insert is off" + ).is_true() + assert_that(list_suppressed).override_failure_message( + "Interaction list: must be hidden when insert is off" + ).is_true() + + cursor.queue_free() + list.queue_free() + _clear_test_interactions() + + +func test_game_state_insert_active_field_exists() -> void: + # GameState must have an insert_active field after #522 implementation. + assert_that(GameState.get("insert_active") != null or "insert_active" in GameState).override_failure_message( + "GameState must have insert_active field after #522 — awaiting implementation" + ).is_true() + + +func test_game_state_insert_active_default_true() -> void: + # Default state: insert is on (player starts with a functioning neural insert). + if not ("insert_active" in GameState): + push_warning("TestInsertOffBehavior: GameState.insert_active not found — awaiting #522") + return + assert_that(GameState.insert_active).override_failure_message( + "GameState.insert_active should default to true (insert is normally on)" + ).is_true() + + +# -- D-049: z-layer verification (labels on layer 6) ------------------------- + +func test_interaction_list_on_insert_layer() -> void: + # D-056/D-057: "Labels render on z-layer 6 (insert overlay)" + # This is already tested in test_interaction_list.gd but we verify here + # that the z-layer is CANVAS_INSERT (the insert overlay layer). + var list = _make_list_or_skip() + if list == null: + return + if list.has_method("get_z_layer"): + assert_that(list.get_z_layer()).override_failure_message( + "Interaction list labels must render on CANVAS_INSERT (z-layer 6 per D-049)" + ).is_equal(Constants.CANVAS_INSERT) + list.queue_free() + + +# -- Regression: existing behavior unchanged ---------------------------------- + +func test_sprint_suppression_still_works_with_insert_on() -> void: + # D-055 regression: Sprint suppresses interaction list regardless of insert state. + var list = _make_list_or_skip() + if list == null: + return + _set_test_interactions() + # Ensure insert is on (should not affect sprint suppression) + if list.has_method("set_insert_active"): + list.set_insert_active(true) + GameState.player_stance = "Sprint" + if list.has_method("update_from_state"): + list.update_from_state() + if list.has_method("is_showing"): + assert_that(list.is_showing()).override_failure_message( + "Sprint must suppress interaction list even when insert is on (D-055 regression)" + ).is_false() + elif list.has_method("get_visible_verb_count"): + assert_that(list.get_visible_verb_count()).is_equal(0) + list.queue_free() + _clear_test_interactions() + GameState.player_stance = "Walk" + + +func test_weapon_mode_suppression_still_works_with_insert_on() -> void: + # D-056 regression: weapon mode suppresses interactions when insert is on. + var cursor = _make_cursor_or_skip() + if cursor == null: + return + if cursor.has_method("set_insert_active"): + cursor.set_insert_active(true) # Insert is on + if cursor.has_method("set_weapon_mode"): + cursor.set_weapon_mode(true) + if cursor.has_method("should_show_interactions"): + assert_that(cursor.should_show_interactions()).override_failure_message( + "Weapon mode must suppress interactions when insert is on (D-056 regression)" + ).is_false() + cursor.queue_free()