## #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 # Expected ring buffer capacity per spec. const EXPECTED_CAPACITY := 60 const BugReportDialogScene = preload("res://ui/bug_report_dialog.tscn") func after_each() -> void: # Reset GameState fields mutated by tests to prevent cross-test leakage. GameState.current_tick = 0 GameState.room_id = null GameState.player_stance = "Walk" GameState.player_facing = "South" GameState.player_position = Vector2.ZERO GameState.visible_entities = [] GameState.visible_tiles = [] GameState.game_time = {} GameState.current_monologue = null GameState.current_dialogue = null GameState.rng_seed = null # -- Helpers ------------------------------------------------------------------- func _make_dialog() -> Control: # Instantiate via .tscn — preserves the MetaScreen runtime stack. # (Sprint 36 migrated bug_report_dialog.gd to extends MetaScreen; bare # Control.new() + set_script() no longer satisfies the base contract.) var dialog: Control = BugReportDialogScene.instantiate() 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": 23, "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 'unavailable' 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()