diff --git a/src/game/engine/tests/unit/test_save_manager.gd b/src/game/engine/tests/unit/test_save_manager.gd index 6279e44d..c3fc1936 100644 --- a/src/game/engine/tests/unit/test_save_manager.gd +++ b/src/game/engine/tests/unit/test_save_manager.gd @@ -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", {})