Removes the version-mismatch guard from Protocol.decode_snapshot() and the PROTOCOL_VERSION constant from the client (server side done in #874). Core changes: - protocol.gd: remove const PROTOCOL_VERSION, remove version mismatch guard, remove "version" from return dict, add gauntlet_mode/room_id decode - sim_bridge.gd: remove handshake version check; relax handshake guard to require only a valid Dictionary (server no longer sends protocol_version); emit handshake_complete(0) for API compat - loading_screen.gd: drop "· protocol N" suffix from version label - test_harness.gd: replace Protocol.PROTOCOL_VERSION with literal 23 Test updates (21 files): replace "version": Protocol.PROTOCOL_VERSION with "version": 23 in all snapshot bytes dicts; remove snapshot.version == N assertions; remove version-rejection tests (test_rejects_version_6, test_decode_snapshot_rejects_missing_version, test_decode_snapshot_rejects_old_version, test_protocol_rejects_version_mismatch, test_sim_bridge_test_snapshot_uses_current_protocol_version). Also includes: #872 bookmark_catalog carry-forward regression test, and #873 merge-path flow tests (test_merge_path_flows_sprint37.gd). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
363 lines
15 KiB
GDScript
363 lines
15 KiB
GDScript
## #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()
|