Files
settled-reach/client/tests/test_p0_regressions.gd
T
jpmschweitzerandClaude Opus 4.7 60733738f2 fix(client): PR #135 review — T1/T3/H4-H7 blocking + nits
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>
2026-04-22 10:06:39 +02:00

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()