Code changes addressing PR #135 review (Tyre + Hoshe): - **T3 (blocking):** test_merge_path_flows_sprint37.gd `_load_main_menu` and `_load_char_create` now assert the scene loaded instead of silently returning. Missing .tscn → red test, not falsely green. - **T1:** sim_bridge.gd signal `handshake_complete(protocol_version: int)` was D-192 residue with no listeners. Drop the int parameter entirely and the literal-0 emit. - **H4:** test_new_game_catalog_snapshot_resolves_loading_state now asserts SimBridge.state == CONNECTED terminus, not just the loading flag — guarantees full flow completion, not merely flag-clear. - **H5:** test_protocol_bridge.gd file-level comment refreshed; drops reference to removed protocol-version check tests. - **H6:** test_p0_regressions.gd `_make_snapshot_bytes` comment refreshed and version field removed from fixture dict (D-192: not required). - **H7:** test_merge_path_flows_sprint37.gd `_make_catalog_snapshot` drops version field from fixture dict (D-192). Follow-up tickets filed for reviewer suggestions: - **T2:** #889 — revive EntityRenderer sprite constants coverage (D-044 ENTITY_WIDTH/HEIGHT, asserted by deleted test_sprite_integration). - **T4:** #890 — UI timeout fallback for bookmark catalog wait in main_menu (systemic 'catalog never arrives' class beyond #872's TCP-batch race). - **T5/T6:** #891 — scene-flow test tier docs + test-only reset helpers (SimBridge.reset_for_test, MetaStack.reset_for_test) + minimal public API on scenes so UI refactors don't break all four flow tests simultaneously. Verification: - `make lint-client` — no script errors - `gdlint client/scripts/ client/ui/` — no problems - `make test-client` — 2428/2488 passing. 60 remaining failures are pre-existing, unrelated to sprint 37 (test_dialogue_sprint20 #558 signals, test_input_roundtrip integration-sans-server, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
GDScript
307 lines
11 KiB
GDScript
## Client P0 regression tests: guards for Bug #5 (monologue lost), Bug #2 (camera drift),
|
|
## and Bug #872 (bookmark_catalog lost on batch receive).
|
|
## These must pass before any other client testing is meaningful.
|
|
##
|
|
## Bug #5: Monologue text lost when server sends snapshots faster than client
|
|
## consumes them. Fix: carry-forward one-shot events in receive_bytes().
|
|
## Bug #2: Camera doesn't center at startup / drifts during pause. Fix: anchor
|
|
## pattern with smoothing disabled until first snapshot applied.
|
|
## Bug #872: bookmark_catalog silently dropped when tick 0 (with catalog) and tick 1
|
|
## (without catalog) arrive in the same TCP batch. Fix: carry-forward
|
|
## bookmark_catalog in receive_bytes() like save_result/settings_response.
|
|
##
|
|
## Spec ref: stig-round3.md Section 1 (P0 tests #1, #2).
|
|
class_name TestP0Regressions
|
|
extends GdUnitTestSuite
|
|
|
|
|
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
|
|
|
var _instance: Node = null
|
|
|
|
|
|
func before_test() -> void:
|
|
SimBridge.reset_test_state()
|
|
SimBridge._last_snapshot = null
|
|
GameState.current_tick = 0
|
|
GameState.player_position = Vector2.ZERO
|
|
GameState.visible_entities = []
|
|
GameState.visible_tiles = []
|
|
GameState.visible_positions = {}
|
|
GameState.current_monologue = null
|
|
GameState.current_dialogue = null
|
|
GameState.game_time = {}
|
|
GameState.pending_recognitions = []
|
|
|
|
|
|
func after_test() -> void:
|
|
if _instance and is_instance_valid(_instance):
|
|
_instance.queue_free()
|
|
_instance = null
|
|
|
|
|
|
# -- Helpers -------------------------------------------------------------------
|
|
|
|
## Encode a minimal valid snapshot as MessagePack bytes.
|
|
## Protocol.decode_snapshot() requires: tick, entities (with kind as bare string
|
|
## for unit enum variants per rmp_serde wire format). D-192 dropped the version
|
|
## field — kept here inertly in existing fixtures so decode still accepts either
|
|
## shape while older tests migrate.
|
|
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
|
|
var snapshot := {
|
|
"tick": overrides.get("tick", 1),
|
|
"entities": overrides.get("entities", [{
|
|
"entity_id": 1,
|
|
"x": 10.0,
|
|
"y": 10.0,
|
|
"z": 0,
|
|
"kind": "Player",
|
|
"visibility": "Forward",
|
|
}]),
|
|
"game_time": {
|
|
"day": 0,
|
|
"time_of_day": 0,
|
|
"day_phase": "Morning",
|
|
"tick_rate": overrides.get("tick_rate", "Full"),
|
|
},
|
|
"player_facing": "North",
|
|
"player_stance": "Walk",
|
|
"player_inventory": [],
|
|
"visible_tiles": [],
|
|
"nearby_interactions": [],
|
|
"pending_recognitions": [],
|
|
}
|
|
# Only include current_monologue if explicitly provided (null omitted)
|
|
if overrides.has("current_monologue"):
|
|
snapshot["current_monologue"] = overrides["current_monologue"]
|
|
if overrides.has("current_dialogue"):
|
|
snapshot["current_dialogue"] = overrides["current_dialogue"]
|
|
if overrides.has("bookmark_catalog"):
|
|
snapshot["bookmark_catalog"] = overrides["bookmark_catalog"]
|
|
var result = Messagepack.encode(snapshot)
|
|
return result.value
|
|
|
|
|
|
# -- Bug #5: Monologue carry-forward ------------------------------------------
|
|
# The server sends one-shot monologue in a snapshot. If the server sends another
|
|
# snapshot (without monologue) before the client polls, the old snapshot is
|
|
# overwritten. The carry-forward logic in receive_bytes() must preserve the
|
|
# monologue from the overwritten snapshot.
|
|
|
|
func test_monologue_not_lost_on_snapshot_overwrite() -> void:
|
|
# Snapshot 1: server sends monologue
|
|
var bytes_with_mono := _make_snapshot_bytes({
|
|
"tick": 1,
|
|
"current_monologue": {
|
|
"id": "test_mono_001",
|
|
"text": "Something about this manifest doesn't add up.",
|
|
"duration_seconds": 5.0,
|
|
},
|
|
})
|
|
SimBridge.receive_bytes(bytes_with_mono)
|
|
|
|
# Snapshot 2: server sends next tick WITHOUT monologue (overwrite scenario)
|
|
var bytes_without_mono := _make_snapshot_bytes({
|
|
"tick": 2,
|
|
})
|
|
SimBridge.receive_bytes(bytes_without_mono)
|
|
|
|
# Assert: _last_snapshot must still carry the monologue from snapshot 1.
|
|
# This is the carry-forward fix for Bug #5.
|
|
assert_that(SimBridge._last_snapshot).is_not_null()
|
|
var mono: Variant = SimBridge._last_snapshot.get("current_monologue")
|
|
assert_that(mono).is_not_null()
|
|
assert_that(mono is Dictionary).is_true()
|
|
assert_that(mono.get("text")).is_equal("Something about this manifest doesn't add up.")
|
|
|
|
|
|
func test_monologue_not_duplicated_after_consumption() -> void:
|
|
# After the client consumes a carried-forward monologue, the next snapshot
|
|
# without monologue should NOT carry forward again (it was already consumed).
|
|
var bytes_with_mono := _make_snapshot_bytes({
|
|
"tick": 1,
|
|
"current_monologue": {
|
|
"id": "test_mono_002",
|
|
"text": "The corridors feel different at night.",
|
|
"duration_seconds": 3.0,
|
|
},
|
|
})
|
|
SimBridge.receive_bytes(bytes_with_mono)
|
|
|
|
# Client polls and consumes the monologue.
|
|
# In live mode, poll_snapshot() returns _last_snapshot and sets it to null.
|
|
# In test mode, poll_snapshot() returns _test_snapshot() instead, so we
|
|
# simulate the live-mode consumption path directly.
|
|
var snapshot = SimBridge._last_snapshot
|
|
SimBridge._last_snapshot = null
|
|
assert_that(snapshot).is_not_null()
|
|
GameState.apply_snapshot(snapshot)
|
|
# apply_snapshot sets current_monologue, then main._consume_monologue() clears it.
|
|
# Simulate consumption:
|
|
GameState.current_monologue = null
|
|
|
|
# Next snapshot arrives without monologue — _last_snapshot was null after poll,
|
|
# so no carry-forward should happen.
|
|
var bytes_next := _make_snapshot_bytes({"tick": 2})
|
|
SimBridge.receive_bytes(bytes_next)
|
|
|
|
var mono: Variant = SimBridge._last_snapshot.get("current_monologue")
|
|
assert_that(mono).is_null()
|
|
|
|
|
|
func test_monologue_carry_forward_preserves_newest() -> void:
|
|
# If two snapshots both have monologue, the second one wins (no accumulation).
|
|
var bytes_mono1 := _make_snapshot_bytes({
|
|
"tick": 1,
|
|
"current_monologue": {
|
|
"id": "first",
|
|
"text": "First thought.",
|
|
"duration_seconds": 3.0,
|
|
},
|
|
})
|
|
SimBridge.receive_bytes(bytes_mono1)
|
|
|
|
var bytes_mono2 := _make_snapshot_bytes({
|
|
"tick": 2,
|
|
"current_monologue": {
|
|
"id": "second",
|
|
"text": "Second thought.",
|
|
"duration_seconds": 3.0,
|
|
},
|
|
})
|
|
SimBridge.receive_bytes(bytes_mono2)
|
|
|
|
# Second monologue should win — latest snapshot takes precedence.
|
|
var mono: Variant = SimBridge._last_snapshot.get("current_monologue")
|
|
assert_that(mono).is_not_null()
|
|
assert_that(mono.get("text")).is_equal("Second thought.")
|
|
|
|
|
|
# -- Bug #2: Camera static during pause ---------------------------------------
|
|
# During pause, the server sends snapshots with unchanged player position.
|
|
# The camera must remain at the same position — no drift, no snap to origin.
|
|
|
|
func test_camera_static_during_pause() -> void:
|
|
# 1. Instantiate main scene — camera anchors at test mode player position
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
|
var expected_pos := Vector2(10, 10) * Constants.TILE_SIZE # (320, 320)
|
|
|
|
# 2. Verify camera is anchored at expected position
|
|
assert_that(camera.global_position).is_equal(expected_pos)
|
|
assert_that(_instance._camera_anchored).is_true()
|
|
|
|
# 3. Set game state to Paused
|
|
GameState.game_time = {
|
|
"day": 0,
|
|
"time_of_day": 0,
|
|
"day_phase": "Morning",
|
|
"tick_rate": "Paused",
|
|
}
|
|
|
|
# 4. Process a frame — in test mode, poll_snapshot returns same position
|
|
# (no movement inputs queued), simulating server pause behavior
|
|
_instance._process(0.016)
|
|
|
|
# 5. Camera must remain at the same position (no drift)
|
|
assert_that(camera.global_position).is_equal(expected_pos)
|
|
|
|
|
|
func test_camera_anchored_after_pause_unpause() -> void:
|
|
# Verify camera stays properly anchored through a pause → unpause cycle.
|
|
var scene := MAIN_SCENE
|
|
_instance = scene.instantiate()
|
|
auto_free(_instance)
|
|
add_child(_instance)
|
|
|
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
|
var expected_pos := Vector2(10, 10) * Constants.TILE_SIZE
|
|
|
|
# Process several frames with Paused tick rate
|
|
GameState.game_time.tick_rate = "Paused"
|
|
for i in 5:
|
|
_instance._process(0.016)
|
|
|
|
assert_that(camera.global_position).is_equal(expected_pos)
|
|
assert_that(_instance._camera_anchored).is_true()
|
|
|
|
# Unpause and process — camera should track the (potentially moved) player
|
|
GameState.game_time.tick_rate = "Full"
|
|
_instance._process(0.016)
|
|
|
|
# Camera still tracking player (position may have changed due to test_snapshot)
|
|
var player_pos := GameState.player_position * Constants.TILE_SIZE
|
|
assert_that(camera.global_position).is_equal(player_pos)
|
|
|
|
|
|
# -- Bug #872: bookmark_catalog carry-forward ---------------------------------
|
|
# Server sends bookmark_catalog on tick 0. If tick 1 arrives before poll_snapshot()
|
|
# is called (same TCP batch), the inner receive loop overwrites _last_snapshot and
|
|
# the catalog is silently lost. The carry-forward fix must preserve the catalog.
|
|
|
|
func test_bookmark_catalog_not_lost_on_snapshot_overwrite() -> void:
|
|
var catalog := {
|
|
"bookmarks": [
|
|
{
|
|
"id": "bm_tycoon_arion",
|
|
"title": "Arion Freight Broker",
|
|
"subtitle": "Start at Arion orbital",
|
|
"flavor": "Commodities and logistics.",
|
|
"default_location": "arion",
|
|
"allowed_locations": ["arion"],
|
|
"allowed_locations_cultures": ["arion"],
|
|
"career": "tycoon",
|
|
"starting_capital_tractus": 50000,
|
|
}
|
|
]
|
|
}
|
|
|
|
# Tick 0: server sends catalog automatically after handshake
|
|
var bytes_tick0 := _make_snapshot_bytes({
|
|
"tick": 0,
|
|
"bookmark_catalog": catalog,
|
|
})
|
|
SimBridge.receive_bytes(bytes_tick0)
|
|
|
|
# Tick 1: server sends next tick WITHOUT catalog (fast server, same TCP batch)
|
|
var bytes_tick1 := _make_snapshot_bytes({"tick": 1})
|
|
SimBridge.receive_bytes(bytes_tick1)
|
|
|
|
# Assert: catalog must survive the overwrite — this is the Bug #872 fix.
|
|
assert_that(SimBridge._last_snapshot).is_not_null()
|
|
var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog")
|
|
assert_that(bmc).is_not_null()
|
|
assert_that(bmc is Dictionary).is_true()
|
|
var bookmarks: Variant = bmc.get("bookmarks")
|
|
assert_that(bookmarks is Array).is_true()
|
|
assert_that((bookmarks as Array).size()).is_equal(1)
|
|
assert_that((bookmarks as Array)[0].get("id")).is_equal("bm_tycoon_arion")
|
|
|
|
|
|
func test_bookmark_catalog_not_carried_forward_after_consumption() -> void:
|
|
# After poll_snapshot() consumes the catalog, the next snapshot without catalog
|
|
# must NOT carry it forward (it was already consumed and the scene transitioned).
|
|
var catalog := {
|
|
"bookmarks": [{"id": "bm_test", "title": "Test", "subtitle": "", "flavor": "",
|
|
"default_location": "arion", "allowed_locations": [], "allowed_locations_cultures": [],
|
|
"career": "tycoon", "starting_capital_tractus": 0}]
|
|
}
|
|
var bytes_tick0 := _make_snapshot_bytes({"tick": 0, "bookmark_catalog": catalog})
|
|
SimBridge.receive_bytes(bytes_tick0)
|
|
|
|
# Simulate poll_snapshot() consumption — sets _last_snapshot to null
|
|
var snapshot = SimBridge._last_snapshot
|
|
SimBridge._last_snapshot = null
|
|
assert_that(snapshot).is_not_null()
|
|
|
|
# Next snapshot arrives without catalog — no carry-forward should happen
|
|
var bytes_tick1 := _make_snapshot_bytes({"tick": 1})
|
|
SimBridge.receive_bytes(bytes_tick1)
|
|
|
|
var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog")
|
|
assert_that(bmc).is_null()
|