SessionManager.new_game() now returns "" on dir creation failure instead of proceeding with a broken game-id. Main menu guards against empty return. Test suite tracks and cleans up created save directories in after_test(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
209 lines
7.5 KiB
GDScript
209 lines
7.5 KiB
GDScript
## Sprint 19 — Game session management (#258, D-085)
|
|
## Per-game save directories: created on New Game, resumed via game-id.
|
|
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
|
class_name TestSessionManagerSprint19
|
|
extends GdUnitTestSuite
|
|
|
|
# Game IDs created during the current test — deleted in after_test().
|
|
var _created_ids: Array = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func before_test() -> void:
|
|
GameState.current_game_id = ""
|
|
_created_ids = []
|
|
|
|
|
|
func after_test() -> void:
|
|
for game_id in _created_ids:
|
|
var path := "user://saves/" + game_id
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
|
_created_ids.clear()
|
|
GameState.current_game_id = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: call new_game() and track the created directory for cleanup.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _new_game() -> String:
|
|
var game_id := SessionManager.new_game()
|
|
if not game_id.is_empty():
|
|
_created_ids.append(game_id)
|
|
return game_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GameState.current_game_id field
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_current_game_id_field_exists() -> void:
|
|
## D-085: GameState must have current_game_id field.
|
|
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
|
"GameState must have 'current_game_id' field (D-085 #258)"
|
|
).is_true()
|
|
|
|
|
|
func test_current_game_id_default_is_empty_string() -> void:
|
|
## Before any session starts, current_game_id is empty.
|
|
GameState.current_game_id = ""
|
|
assert_str(GameState.current_game_id).override_failure_message(
|
|
"GameState.current_game_id default must be empty string"
|
|
).is_empty()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SessionManager autoload exists
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_session_manager_autoload_exists() -> void:
|
|
## SessionManager must be registered as an autoload.
|
|
var sm := Engine.get_singleton("SessionManager")
|
|
assert_that(sm != null).override_failure_message(
|
|
"SessionManager must be registered as autoload in project.godot (#258)"
|
|
).is_true()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# new_game() — game-id format and GameState update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_new_game_returns_non_empty_string() -> void:
|
|
var game_id := _new_game()
|
|
assert_str(game_id).override_failure_message(
|
|
"SessionManager.new_game() must return a non-empty game-id string"
|
|
).is_not_empty()
|
|
|
|
|
|
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
|
var game_id := _new_game()
|
|
assert_str(GameState.current_game_id).override_failure_message(
|
|
"new_game() must set GameState.current_game_id"
|
|
).is_equal(game_id)
|
|
|
|
|
|
func test_new_game_id_format_has_two_dashes() -> void:
|
|
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
|
var game_id := _new_game()
|
|
var parts := game_id.split("-")
|
|
assert_int(parts.size()).override_failure_message(
|
|
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
|
).is_equal(3)
|
|
|
|
|
|
func test_new_game_id_first_part_is_8_digits() -> void:
|
|
## First part is YYYYMMDD — 8 decimal digits.
|
|
var game_id := _new_game()
|
|
var parts := game_id.split("-")
|
|
assert_int(parts[0].length()).override_failure_message(
|
|
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
|
).is_equal(8)
|
|
|
|
|
|
func test_new_game_id_second_part_is_6_digits() -> void:
|
|
## Second part is HHMMSS — 6 decimal digits.
|
|
var game_id := _new_game()
|
|
var parts := game_id.split("-")
|
|
assert_int(parts[1].length()).override_failure_message(
|
|
"game-id second part (time) must be 6 characters (HHMMSS)"
|
|
).is_equal(6)
|
|
|
|
|
|
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
|
## Third part is 6 hex characters (RNG seed).
|
|
var game_id := _new_game()
|
|
var parts := game_id.split("-")
|
|
assert_int(parts[2].length()).override_failure_message(
|
|
"game-id third part (hex seed) must be 6 characters"
|
|
).is_equal(6)
|
|
|
|
|
|
func test_new_game_ids_are_unique() -> void:
|
|
## Two rapid new_game() calls should produce different IDs
|
|
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
|
var id1 := _new_game()
|
|
var id2 := _new_game()
|
|
# Check that hex seeds differ (they almost certainly will)
|
|
var seed1 := id1.split("-")[2]
|
|
var seed2 := id2.split("-")[2]
|
|
assert_str(seed1).override_failure_message(
|
|
"Successive new_game() calls should have different RNG seeds"
|
|
).is_not_equal(seed2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resume_game() — sets GameState.current_game_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_resume_game_sets_current_game_id() -> void:
|
|
var test_id := "20260225-143022-a7b3f1"
|
|
SessionManager.resume_game(test_id)
|
|
assert_str(GameState.current_game_id).override_failure_message(
|
|
"resume_game() must set GameState.current_game_id to the given id"
|
|
).is_equal(test_id)
|
|
|
|
|
|
func test_resume_game_overwrites_previous_game_id() -> void:
|
|
SessionManager.resume_game("20260225-100000-aabbcc")
|
|
SessionManager.resume_game("20260225-120000-112233")
|
|
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main menu scene
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_main_menu_scene_exists() -> void:
|
|
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
|
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
|
).is_true()
|
|
|
|
|
|
func test_main_menu_instantiates_without_crash() -> void:
|
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
|
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
|
return
|
|
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
|
auto_free(scene)
|
|
add_child(scene)
|
|
assert_that(scene).is_not_null()
|
|
|
|
|
|
func test_main_menu_has_new_game_button() -> void:
|
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
|
return
|
|
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
|
auto_free(scene)
|
|
add_child(scene)
|
|
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
|
assert_that(btn != null).override_failure_message(
|
|
"Main menu must have VBox/NewGameBtn (#258)"
|
|
).is_true()
|
|
|
|
|
|
func test_main_menu_has_continue_button() -> void:
|
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
|
return
|
|
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
|
auto_free(scene)
|
|
add_child(scene)
|
|
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
|
assert_that(btn != null).override_failure_message(
|
|
"Main menu must have VBox/ContinueBtn (#258)"
|
|
).is_true()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Project main scene changed to main_menu.tscn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func test_project_main_scene_is_main_menu() -> void:
|
|
## D-085: project boots to main menu, not directly to game scene.
|
|
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
|
assert_str(scene_path).override_failure_message(
|
|
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
|
).is_equal("res://scenes/main_menu.tscn")
|