- Add free camera mode (F4 toggle): WASD pan, scroll zoom, decoupled from player position (#898) - Strip archetype-driven code: remove character_archetype, lattice_profile, and lattice color palettes from client (#882) - Fix confrontation_monologue signal not firing in headless test mode (#867) - Revive fog state behavioral tests: EXP_EXPLORED persistence, grow-only bounds, texture-resize copy, BoundaryWall handling (#879) - Triage pre-existing test failures: fix examine_display dismiss timing, fog test position fragility, rendering snapshot assertions, time_display format (#871) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
323 lines
12 KiB
GDScript
323 lines
12 KiB
GDScript
## Sprint 17 — Time display on insert HUD (#263)
|
|
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
|
|
##
|
|
## Spec refs:
|
|
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
|
|
## D-051 (diegetic insert display)
|
|
##
|
|
## Implementation: client/ui/time_display.gd — draw-based Control (removed from main scene #786;
|
|
## time display merged into HUD ImplantPanel at InsertOverlay/HUD, see test_hud_* tests below).
|
|
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
|
|
## testability from time_display.gd).
|
|
##
|
|
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
|
|
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
|
|
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
|
|
## Scene integration tests now check InsertOverlay/HUD.get_time_text() instead.
|
|
class_name TestTimeDisplaySprint17
|
|
extends GdUnitTestSuite
|
|
|
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
|
|
|
var _clock: Control = null
|
|
|
|
|
|
func before_test() -> void:
|
|
SimBridge.reset_test_state()
|
|
GameState.game_time = {}
|
|
var ClockScript = load("res://ui/time_display.gd")
|
|
_clock = Control.new()
|
|
_clock.set_script(ClockScript)
|
|
add_child(_clock)
|
|
|
|
|
|
func after_test() -> void:
|
|
if _clock and is_instance_valid(_clock):
|
|
_clock.queue_free()
|
|
_clock = null
|
|
GameState.game_time = {}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Constants.format_game_time() — pure logic, D-031
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_format_midnight() -> void:
|
|
assert_that(Constants.format_game_time(0)).is_equal("00:00")
|
|
|
|
func test_format_morning_start() -> void:
|
|
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
|
|
assert_that(Constants.format_game_time(360)).is_equal("06:00")
|
|
|
|
func test_format_noon() -> void:
|
|
assert_that(Constants.format_game_time(720)).is_equal("12:00")
|
|
|
|
func test_format_evening_start() -> void:
|
|
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
|
|
|
|
func test_format_end_of_day() -> void:
|
|
# Last valid minute — must not wrap or overflow
|
|
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
|
|
|
|
func test_format_pads_single_digit_hour() -> void:
|
|
# 30 min = 00:30
|
|
assert_that(Constants.format_game_time(30)).is_equal("00:30")
|
|
|
|
func test_format_pads_single_digit_minute() -> void:
|
|
# 121 min = 02:01
|
|
assert_that(Constants.format_game_time(121)).is_equal("02:01")
|
|
|
|
func test_format_half_past_hour() -> void:
|
|
assert_that(Constants.format_game_time(90)).is_equal("01:30")
|
|
|
|
func test_format_arbitrary_midday() -> void:
|
|
# 835 min = 13:55
|
|
assert_that(Constants.format_game_time(835)).is_equal("13:55")
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# InsertClock initial state
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_insert_clock_initial_time_str_is_placeholder() -> void:
|
|
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
|
|
assert_that(_clock._time_str).is_equal("--:--")
|
|
|
|
func test_insert_clock_initial_phase_str_is_empty() -> void:
|
|
assert_that(_clock._phase_str).is_equal("")
|
|
|
|
func test_insert_clock_initial_day_str_is_empty() -> void:
|
|
assert_that(_clock._day_str).is_equal("")
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# InsertClock.update_from_state() — reads GameState.game_time
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_update_from_state_formats_time_str() -> void:
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("12:00")
|
|
|
|
func test_update_from_state_sets_phase_str() -> void:
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._phase_str).is_equal("Evening")
|
|
|
|
func test_update_from_state_sets_day_str_one_indexed() -> void:
|
|
# Day 0 from server → "D1" display (1-indexed)
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._day_str).is_equal("D1")
|
|
|
|
func test_update_from_state_day_2() -> void:
|
|
GameState.game_time = {
|
|
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._day_str).is_equal("D2")
|
|
|
|
func test_update_from_state_midnight() -> void:
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("00:00")
|
|
|
|
func test_update_from_state_end_of_day() -> void:
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("23:59")
|
|
|
|
func test_update_from_state_skips_empty_game_time() -> void:
|
|
# Empty game_time must not overwrite --:-- (guard in update_from_state)
|
|
GameState.game_time = {}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("--:--")
|
|
|
|
func test_update_from_state_deduplicates_same_tick() -> void:
|
|
# Calling twice with identical data must produce same result (signature cache)
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("06:00")
|
|
# Call again — result unchanged, no crash
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("06:00")
|
|
|
|
func test_update_from_state_updates_on_new_tick() -> void:
|
|
# time_of_day changes → signature changes → _time_str updates
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("01:00")
|
|
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._time_str).is_equal("02:00")
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_phase_colors_has_morning() -> void:
|
|
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
|
|
|
|
func test_phase_colors_has_afternoon() -> void:
|
|
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
|
|
|
|
func test_phase_colors_has_evening() -> void:
|
|
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
|
|
|
|
func test_phase_colors_has_night() -> void:
|
|
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
|
|
|
|
func test_phase_color_applied_after_update() -> void:
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
|
|
|
|
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
|
|
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
|
|
}
|
|
_clock.update_from_state()
|
|
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# GameState: game_time field parsing (confirms apply_snapshot wiring)
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_game_time_populated_from_snapshot() -> void:
|
|
GameState.apply_snapshot({
|
|
"tick": 5, "entities": [],
|
|
"game_time": {
|
|
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
|
},
|
|
})
|
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
|
|
|
|
func test_game_time_all_four_phases_store_correctly() -> void:
|
|
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
|
|
GameState.apply_snapshot({
|
|
"tick": 1, "entities": [],
|
|
"game_time": {
|
|
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
|
|
},
|
|
})
|
|
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
|
|
|
|
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
|
|
GameState.game_time = {
|
|
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
|
}
|
|
GameState.apply_snapshot({"tick": 2, "entities": []})
|
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
|
|
|
|
func test_game_time_zero_time_of_day_stored() -> void:
|
|
# time_of_day = 0 (midnight) must not be treated as falsy/missing
|
|
GameState.apply_snapshot({
|
|
"tick": 1, "entities": [],
|
|
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
|
|
})
|
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# SimBridge test mode: game_time fields are valid
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_sim_bridge_snapshot_has_game_time() -> void:
|
|
var snap = SimBridge._test_snapshot()
|
|
assert_that(snap.has("game_time")).is_true()
|
|
assert_that(snap.game_time is Dictionary).is_true()
|
|
|
|
func test_sim_bridge_game_time_has_required_fields() -> void:
|
|
var snap = SimBridge._test_snapshot()
|
|
var gt: Dictionary = snap.game_time
|
|
assert_that(gt.has("day")).is_true()
|
|
assert_that(gt.has("time_of_day")).is_true()
|
|
assert_that(gt.has("day_phase")).is_true()
|
|
assert_that(gt.has("tick_rate")).is_true()
|
|
|
|
func test_sim_bridge_time_of_day_is_non_negative() -> void:
|
|
var snap = SimBridge._test_snapshot()
|
|
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
|
|
|
|
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
|
|
# D-031: 1440 game-minutes per day, valid range 0..1439
|
|
var snap = SimBridge._test_snapshot()
|
|
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
|
|
|
|
func test_sim_bridge_day_phase_is_valid() -> void:
|
|
var snap = SimBridge._test_snapshot()
|
|
var phase: String = snap.game_time.get("day_phase", "")
|
|
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Scene: HUD panel at InsertOverlay/HUD (time row merged in, TimeDisplay removed #786)
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_hud_panel_exists_in_insert_overlay() -> void:
|
|
var scene := MAIN_SCENE
|
|
var instance = scene.instantiate()
|
|
auto_free(instance)
|
|
add_child(instance)
|
|
|
|
assert_that(instance.get_node_or_null("InsertOverlay/HUD")).is_not_null()
|
|
|
|
func test_hud_time_row_updates_after_process() -> void:
|
|
var scene := MAIN_SCENE
|
|
var instance = scene.instantiate()
|
|
auto_free(instance)
|
|
add_child(instance)
|
|
|
|
GameState.apply_snapshot({
|
|
"tick": 1, "version": 23, "entities": [],
|
|
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
|
})
|
|
# In test mode SimBridge is disconnected — poll_snapshot() returns null so the
|
|
# SnapshotEventRouter inside main._process() never fires. Call the HUD directly
|
|
# instead, which is what the router would do in a live session.
|
|
var hud = instance.get_node_or_null("InsertOverlay/HUD")
|
|
assert_that(hud).is_not_null()
|
|
hud.update_from_state()
|
|
assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Regression: debug_overlay still reads game_time correctly (#511)
|
|
# -------------------------------------------------------------------------
|
|
|
|
func test_debug_overlay_reads_game_time_day_phase() -> void:
|
|
GameState.apply_snapshot({
|
|
"tick": 1, "entities": [],
|
|
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
|
})
|
|
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
|
|
|
|
func test_debug_overlay_reads_game_time_tick_rate() -> void:
|
|
GameState.apply_snapshot({
|
|
"tick": 1, "entities": [],
|
|
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
|
|
})
|
|
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
|