test(save-manager): Add test cases for save/load functionality, including assertions for slot management and game state persistence

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 22:30:36 -07:00
parent 6e17c474d2
commit ea704c7875

View file

@ -1,34 +1,263 @@
extends GutTest
## SaveManager unit tests.
## SaveManager unit tests — on-disk save/load layer.
##
## PENDING: SaveManager is currently a 3-line stub
## (`src/game/engine/src/core/save_manager.gd`). The previous test suite was
## written against a full SAVE_DIR / SAVE_EXTENSION / save_game / load_game /
## save_slot / load_slot / get_save_slots / delete_save / MAX_SLOTS API that
## no longer exists in this repo. `GameState.serialize()` / `deserialize()`
## are implemented (iter 7l Player fields + iter 7i Unit fields land in
## those snapshots) but the on-disk file layer has not been re-ported.
## Iter 7o Track C: SaveManager was restored from a 3-line stub to a real
## file layer over GameState.serialize() / deserialize(). These tests cover:
## * round-trip integrity (write → load into cleared state → fields match)
## * byte-identical re-save (deterministic JSON stringify)
## * multi-slot isolation (slot 0 and slot 1 don't collide)
## * get_save_slots() lists only populated slots with correct metadata
## * delete_save() removes a slot and leaves others intact
## * missing-file and out-of-range loads fail gracefully (no crash)
## * autosave() writes a dedicated slot without affecting manual slot list
##
## When SaveManager is rebuilt, these round-trip tests should be updated
## against the current Player/Unit/City shapes:
## Player (iter 7l): index, player_name, race_id, gender_preset, is_human,
## color, gold, gold_per_turn, golden_age_active, happiness,
## culture_per_turn, culture_total, researching, research_progress,
## science_per_turn, researched_techs, schools, mana_pool, mana_income,
## mana_cap, pending_improvements, strategic_axes, ascension_active
## Unit (iter 7i): unit_id, display_name, position, owner, hp, max_hp,
## attack, defense, ranged_attack, movement_remaining, max_movement,
## has_attacked, is_fortified, fortified_turns, xp, level, promo_ids,
## equipped_items, infusions, channeled_infusion
## City: id, city_name, owner, position, is_capital, turn_founded,
## buildings, production_queue, production_progress (population /
## food_stored / culture_stored now live on the Rust GdCity instance and
## are read via getters, not fields).
## Tests operate on user://saves/ and clean up after themselves. They do not
## depend on DataLoader or full game bootstrap — only GameState.initialize_game
## plus a couple of Players are needed to exercise the serialize path.
const SaveManagerScript: GDScript = preload("res://engine/src/core/save_manager.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
func test_save_manager_is_pending() -> void:
pending(
"SaveManager is a stub — on-disk save/load layer not yet re-ported. "
+ "GameState.serialize/deserialize are implemented and covered "
+ "indirectly by other tests."
func before_each() -> void:
_reset_save_dir()
_seed_game_state()
func after_each() -> void:
_reset_save_dir()
## ── round-trip integrity ───────────────────────────────────────────────
func test_save_then_load_restores_scalar_fields() -> void:
var save_err: Error = SaveManagerScript.save_game(0)
assert_eq(save_err, OK, "save_game(0) must succeed")
# Mutate in-memory state so a failed load is visible.
GameState.turn_number = 999
GameState.era = 7
GameState.current_player_index = 3
GameState.players = []
var load_err: Error = SaveManagerScript.load_game(0)
assert_eq(load_err, OK, "load_game(0) must succeed")
assert_eq(GameState.turn_number, 12, "turn_number must be restored")
assert_eq(GameState.era, 2, "era must be restored")
assert_eq(GameState.current_player_index, 1, "current_player_index must be restored")
assert_eq(GameState.players.size(), 2, "players array must be restored")
func test_save_then_load_restores_player_fields() -> void:
SaveManagerScript.save_game(0)
GameState.players = []
SaveManagerScript.load_game(0)
assert_eq(GameState.players.size(), 2, "two players round-tripped")
var p0: RefCounted = GameState.players[0]
assert_eq(p0.player_name, "Thorin", "player 0 name restored")
assert_eq(p0.race_id, "dwarf", "player 0 race restored")
assert_eq(p0.gold, 125, "player 0 gold restored")
assert_true(p0.is_human, "player 0 is_human restored")
var p1: RefCounted = GameState.players[1]
assert_eq(p1.player_name, "Arwen", "player 1 name restored")
assert_eq(p1.race_id, "high_elf", "player 1 race restored")
func test_resave_is_byte_identical() -> void:
## Envelope-level timestamp changes between saves, so compare the
## deterministic `game_state` subsection instead.
SaveManagerScript.save_game(0)
var first_state: String = JSON.stringify(_read_slot_game_state(0), "\t", true)
SaveManagerScript.load_game(0)
SaveManagerScript.save_game(0)
var second_state: String = JSON.stringify(_read_slot_game_state(0), "\t", true)
assert_eq(first_state, second_state, "save→load→save game_state must be identical")
## ── multi-slot isolation ───────────────────────────────────────────────
func test_slots_are_independent() -> void:
SaveManagerScript.save_game(0)
GameState.turn_number = 50
GameState.players[0].gold = 9999
SaveManagerScript.save_game(1)
SaveManagerScript.load_game(0)
assert_eq(GameState.turn_number, 12, "slot 0 reload restores turn 12")
assert_eq(GameState.players[0].gold, 125, "slot 0 reload restores gold 125")
SaveManagerScript.load_game(1)
assert_eq(GameState.turn_number, 50, "slot 1 reload restores turn 50")
assert_eq(GameState.players[0].gold, 9999, "slot 1 reload restores gold 9999")
## ── get_save_slots ─────────────────────────────────────────────────────
func test_get_save_slots_lists_populated_slots_only() -> void:
SaveManagerScript.save_game(0)
SaveManagerScript.save_game(3)
var metas: Array[Dictionary] = SaveManagerScript.get_save_slots()
assert_eq(metas.size(), 2, "only two populated slots must appear")
assert_eq(metas[0].get("slot"), 0, "first entry is slot 0")
assert_eq(metas[1].get("slot"), 3, "second entry is slot 3")
assert_eq(metas[0].get("turn"), 12, "slot 0 meta turn")
assert_eq(metas[0].get("era"), 2, "slot 0 meta era")
assert_eq(metas[0].get("player_name"), "Thorin", "slot 0 meta picks human player")
assert_gt(metas[0].get("timestamp", 0), 0, "slot 0 meta timestamp populated")
func test_get_save_slots_is_empty_when_no_saves() -> void:
var metas: Array[Dictionary] = SaveManagerScript.get_save_slots()
assert_eq(metas.size(), 0, "no slots → empty array")
## ── delete_save ────────────────────────────────────────────────────────
func test_delete_save_removes_slot() -> void:
SaveManagerScript.save_game(0)
SaveManagerScript.save_game(1)
var del_err: Error = SaveManagerScript.delete_save(0)
assert_eq(del_err, OK, "delete_save(0) must succeed")
assert_false(SaveManagerScript.slot_exists(0), "slot 0 removed")
assert_true(SaveManagerScript.slot_exists(1), "slot 1 untouched")
func test_delete_missing_slot_returns_does_not_exist() -> void:
var del_err: Error = SaveManagerScript.delete_save(5)
assert_eq(del_err, ERR_DOES_NOT_EXIST, "deleting an empty slot reports ERR_DOES_NOT_EXIST")
## ── error paths ────────────────────────────────────────────────────────
func test_load_missing_slot_returns_does_not_exist() -> void:
var err: Error = SaveManagerScript.load_game(4)
assert_eq(err, ERR_DOES_NOT_EXIST, "loading an empty slot must not crash")
func test_out_of_range_slot_rejected() -> void:
var save_err: Error = SaveManagerScript.save_game(SaveManagerScript.MAX_SLOTS)
assert_eq(save_err, ERR_INVALID_PARAMETER, "slot == MAX_SLOTS is rejected")
var load_err: Error = SaveManagerScript.load_game(-1)
assert_eq(load_err, ERR_INVALID_PARAMETER, "slot == -1 is rejected")
# SaveManager reports via push_error; mark tracked errors as handled so
# GUT does not flag the test as having unexpected errors.
for err: GutTrackedError in get_errors():
err.handled = true
func test_corrupt_file_returns_error_not_crash() -> void:
SaveManagerScript._ensure_save_dir()
var path: String = SaveManagerScript.SAVE_DIR + "save_2" + SaveManagerScript.SAVE_EXTENSION
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
file.store_string("{not valid json")
file.close()
var err: Error = SaveManagerScript.load_game(2)
assert_ne(err, OK, "corrupt file does not report OK")
assert_ne(err, ERR_DOES_NOT_EXIST, "corrupt file is not reported as missing")
for tracked: GutTrackedError in get_errors():
tracked.handled = true
## ── autosave ───────────────────────────────────────────────────────────
func test_autosave_writes_dedicated_slot() -> void:
SaveManagerScript.autosave()
var autosave_path: String = (
SaveManagerScript.SAVE_DIR
+ SaveManagerScript.AUTOSAVE_SLOT_NAME
+ SaveManagerScript.SAVE_EXTENSION
)
assert_true(FileAccess.file_exists(autosave_path), "autosave file must exist")
# Manual slot list must ignore the autosave file.
var metas: Array[Dictionary] = SaveManagerScript.get_save_slots()
assert_eq(metas.size(), 0, "autosave does not appear in manual slot list")
func test_autosave_then_load_autosave_round_trips() -> void:
SaveManagerScript.autosave()
GameState.turn_number = 1
GameState.players = []
var err: Error = SaveManagerScript.load_autosave()
assert_eq(err, OK, "load_autosave must succeed")
assert_eq(GameState.turn_number, 12, "autosave restores turn_number")
assert_eq(GameState.players.size(), 2, "autosave restores players")
## ── helpers ────────────────────────────────────────────────────────────
func _seed_game_state() -> void:
# Build GameState directly — `initialize_game` calls DataLoader which is
# not bootstrapped in the GUT harness. Only the fields SaveManager reads
# need to be set; other GameState fields are left at their declared defaults.
GameState.current_theme = "fantasy"
GameState.turn_number = 12
GameState.era = 2
GameState.current_player_index = 1
GameState.players = []
GameState.layers = []
GameState.transit_nodes = []
GameState.wonders_built = {}
GameState.ascension_rituals = {}
GameState.ley_anchors = []
GameState.npc_buildings = []
GameState._npc_buildings_by_tile = {}
GameState.game_settings = {"num_players": 2}
var human: RefCounted = PlayerScript.new()
human.player_name = "Thorin"
human.race_id = "dwarf"
human.is_human = true
human.gold = 125
human.gold_per_turn = 7
human.happiness = 4
human.culture_total = 33
GameState.add_player(human)
var ai: RefCounted = PlayerScript.new()
ai.player_name = "Arwen"
ai.race_id = "high_elf"
ai.is_human = false
ai.gold = 80
GameState.add_player(ai)
func _reset_save_dir() -> void:
if not DirAccess.dir_exists_absolute(SaveManagerScript.SAVE_DIR):
return
var dir: DirAccess = DirAccess.open(SaveManagerScript.SAVE_DIR)
if dir == null:
return
dir.list_dir_begin()
var name: String = dir.get_next()
while name != "":
if not dir.current_is_dir():
dir.remove(name)
name = dir.get_next()
dir.list_dir_end()
func _read_slot_game_state(slot: int) -> Dictionary:
var path: String = (
SaveManagerScript.SAVE_DIR
+ "save_%d" % slot
+ SaveManagerScript.SAVE_EXTENSION
)
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
return {}
var text: String = file.get_as_text()
file.close()
var parser: JSON = JSON.new()
if parser.parse(text) != OK:
return {}
if not parser.data is Dictionary:
return {}
var envelope: Dictionary = parser.data as Dictionary
return envelope.get("game_state", {})