## 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 at InsertOverlay/TimeDisplay. ## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for ## testability from time_display.gd:46 inline Constants.format_game_time(tod)). ## ## 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. ## If the rendering approach changes (e.g. to Label nodes), these tests should switch to ## reading Label.text via public node paths 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: InsertClock node at InsertOverlay/TimeDisplay # ------------------------------------------------------------------------- func test_insert_clock_exists_in_ui_layer() -> void: var scene := MAIN_SCENE var instance = scene.instantiate() auto_free(instance) add_child(instance) assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null() func test_insert_clock_time_str_updates_after_process() -> void: var scene := MAIN_SCENE var instance = scene.instantiate() auto_free(instance) add_child(instance) GameState.apply_snapshot({ "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], "game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"}, }) instance._process(0.016) var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay") assert_that(clock).is_not_null() assert_that(clock._time_str).is_equal("12:00") # ------------------------------------------------------------------------- # 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")