33 tests: 17 for ring buffer (capacity, overwrite, JSONL format, seed), 16 for insert-off behavior (diegetic test, cursor transitions, cross-system). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user