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:
parent
6e17c474d2
commit
ea704c7875
1 changed files with 257 additions and 28 deletions
|
|
@ -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", {})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue