extends RefCounted ## Persistence migration and failure-mode tests. The fixtures represent a ## 0.8-era player profile and a 0.9.0 checkpoint; loading them must fill current ## defaults without rewriting or losing the old fields. const Campaign := preload("res://src/core/campaign.gd") const ScenarioFactory := preload("res://src/content/scenario_factory.gd") const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd") const FixtureDir := "res://src/test/fixtures/saves/" const UserFiles := ["settings.json", "campaign.json", "shift_results.json", "shift_state.json"] func _save_instance() -> Object: var save_script: GDScript = load("res://src/autoload/save.gd") return save_script.new() func _fixture(name: String) -> String: return FileAccess.get_file_as_string(FixtureDir + name) func _user_path(name: String) -> String: return ProjectSettings.globalize_path("user://" + name) func _write_user(name: String, text: String) -> void: var file := FileAccess.open("user://" + name, FileAccess.WRITE) if file == null: return file.store_string(text) file.close() func _read_user(name: String) -> String: var path := "user://" + name if not FileAccess.file_exists(path): return "" return FileAccess.get_file_as_string(path) func _remove_user(name: String) -> void: var path := _user_path(name) if FileAccess.file_exists("user://" + name) or DirAccess.dir_exists_absolute(path): DirAccess.remove_absolute(path) func _snapshot_files() -> Dictionary: var snapshot := {} for name in UserFiles: snapshot[name] = _read_user(name) return snapshot func _restore_files(snapshot: Dictionary) -> void: for name in UserFiles: _remove_user(name) _remove_user(name + ".tmp") var text := str(snapshot.get(name, "")) if not text.is_empty(): _write_user(name, text) func test_settings_0_8_fixture_fills_new_defaults() -> bool: var backup := _snapshot_files() _write_user("settings.json", _fixture("settings_0_8.json")) var save := _save_instance() var loaded: Dictionary = save.load_settings() var a := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0031), "old sensitivity survives") var b := TestUtils.check(is_equal_approx(float(loaded.get("hud_font_scale", 0.0)), 1.15), "old font scale survives") var c := TestUtils.check_eq(bool(loaded.get("invert_y", false)), true, "old invert setting survives") var d := TestUtils.check_eq(bool(loaded.get("head_bob", true)), false, "old head-bob setting survives") var e := TestUtils.check_eq(str(loaded.get("ai_difficulty", "")), "hard", "old difficulty survives") var f := TestUtils.check_eq(bool(loaded.get("tutorial_completed", false)), true, "old tutorial completion survives") var g := TestUtils.check(is_equal_approx(float(loaded.get("master_volume", 0.0)), 1.0), "new master-volume default is filled") var h := TestUtils.check_eq(bool(loaded.get("captions_enabled", false)), true, "new caption default is filled") var i := TestUtils.check_eq(bool(loaded.get("reduce_motion", true)), false, "new reduced-motion default is filled") save.free() _restore_files(backup) return a and b and c and d and e and f and g and h and i func test_campaign_0_8_fixture_keeps_progress() -> bool: var backup := _snapshot_files() _write_user("campaign.json", _fixture("campaign_0_8.json")) var save := _save_instance() var campaign := Campaign.new() campaign.setup(save.load_campaign()) var a := TestUtils.check_eq(campaign.index, 1, "campaign index survives") var b := TestUtils.check_eq(campaign.next_venue(), "joke_shop", "next venue survives") var c := TestUtils.check_eq(campaign.shift_number, 2, "shift number survives") var d := TestUtils.check_eq(campaign.difficulty, "hard", "difficulty survives") var e := TestUtils.check_eq(campaign.completed, ["hollywood_building"], "history survives") var f := TestUtils.check_eq(campaign.last_grade, "B", "last grade survives") save.free() _restore_files(backup) return a and b and c and d and e and f func test_history_0_8_fixture_loads() -> bool: var backup := _snapshot_files() _write_user("shift_results.json", _fixture("shift_results_0_8.json")) var save := _save_instance() var results: Array = save.load_shift_results() var a := TestUtils.check_eq(results.size(), 2, "both old results load") var b := TestUtils.check_eq(str(results[0].get("scenario_id", "")), "hollywood_building", "first result keeps its venue") var c := TestUtils.check_eq(str(results[1].get("scenario_id", "")), "joke_shop", "second result keeps its venue") save.free() _restore_files(backup) return a and b and c func test_checkpoint_0_9_0_fixture_restores() -> bool: var backup := _snapshot_files() _write_user("shift_state.json", _fixture("shift_state_0_9_0.json")) var save := _save_instance() var state: Dictionary = save.load_shift_state() var a := TestUtils.check_eq(int(state.get("version", 0)), 1, "checkpoint version survives") var b := TestUtils.check_eq(str(state.get("scenario_id", "")), "joke_shop", "checkpoint venue survives") var c := TestUtils.check_eq(bool(state.get("door_locked", false)), true, "door state survives") var d := TestUtils.check_eq(bool(state.get("flashlight_on", false)), true, "flashlight state survives") var e := TestUtils.check_eq(bool(state.get("body_camera_on", false)), true, "body-camera state survives") var coordinator := ShiftCoordinator.new() coordinator.setup(ScenarioFactory.joke_shop(), 900.0, 7) coordinator.restore(state.get("coordinator", {})) var f := TestUtils.check(is_equal_approx(coordinator.elapsed_s, 540.0), "elapsed time restores") var g := TestUtils.check_eq(coordinator.current_phase, 3, "late phase restores") var h := TestUtils.check(is_equal_approx(coordinator.phase_offset_s, 700.0), "phase offset restores") save.free() _restore_files(backup) return a and b and c and d and e and f and g and h func test_checkpoint_round_trip_all_venues() -> bool: var backup := _snapshot_files() var venues := [ ["hollywood_building", ScenarioFactory.hollywood_building()], ["joke_shop", ScenarioFactory.joke_shop()], ["tv_studio", ScenarioFactory.tv_studio()], ] var save := _save_instance() var ok := true for index in venues.size(): var venue_id: String = venues[index][0] var scenario: VenueScenario = venues[index][1] var coordinator := ShiftCoordinator.new() coordinator.setup(scenario, 900.0, index + 3) coordinator.elapsed_s = 300.0 + index * 60.0 coordinator.recorder.start_incident("guest", "focus") var state := { "version": 1, "scenario_id": venue_id, "guard_position": [1.0, 0.1, 8.0 + index], "guard_yaw": 0.5, "head_pitch": 0.1, "door_locked": index % 2 == 0, "flashlight_on": true, "body_camera_on": true, "npc_counter": index + 1, "coordinator": coordinator.snapshot(), "npcs": [{"npc_id": "guest_%d" % index, "npc_type": 0}], } ok = TestUtils.check_eq(int(save.save_shift_state(state)), OK, "%s checkpoint writes" % venue_id) and ok var loaded: Dictionary = save.load_shift_state() ok = TestUtils.check_eq(str(loaded.get("scenario_id", "")), venue_id, "%s checkpoint keeps its venue" % venue_id) and ok ok = TestUtils.check_eq(bool(loaded.get("flashlight_on", false)), true, "%s checkpoint keeps flashlight state" % venue_id) and ok ok = TestUtils.check_eq(bool(loaded.get("body_camera_on", false)), true, "%s checkpoint keeps body-camera state" % venue_id) and ok ok = TestUtils.check_eq(bool(loaded.get("door_locked", false)), index % 2 == 0, "%s checkpoint keeps door state" % venue_id) and ok ok = TestUtils.check_eq((loaded.get("npcs", []) as Array).size(), 1, "%s checkpoint keeps NPC rows" % venue_id) and ok var restored := ShiftCoordinator.new() restored.setup(scenario, 900.0, index + 3) restored.restore(loaded.get("coordinator", {})) ok = TestUtils.check(is_equal_approx(restored.elapsed_s, 300.0 + index * 60.0), "%s checkpoint restores elapsed time" % venue_id) and ok ok = TestUtils.check(restored.recorder.has_open_incident("guest"), "%s checkpoint restores an open incident" % venue_id) and ok save.free() _restore_files(backup) return ok func test_clean_install_has_safe_defaults() -> bool: var backup := _snapshot_files() for name in UserFiles: _remove_user(name) var save := _save_instance() var settings: Dictionary = save.load_settings() var a := TestUtils.check(is_equal_approx(float(settings.get("look_sensitivity", 0.0)), 0.0022), "clean install gets default sensitivity") var b := TestUtils.check_eq(str(settings.get("ai_difficulty", "")), "normal", "clean install gets normal difficulty") var c := TestUtils.check(save.load_campaign().is_empty(), "clean install has no campaign") var d := TestUtils.check_eq(save.load_shift_results().size(), 0, "clean install has no history") var e := TestUtils.check(save.load_shift_state().is_empty(), "clean install has no checkpoint") save.free() _restore_files(backup) return a and b and c and d and e func test_corrupt_json_falls_back_without_crashing() -> bool: var backup := _snapshot_files() _write_user("settings.json", "{not valid json") _write_user("campaign.json", "{not valid json") _write_user("shift_results.json", "{not valid json") _write_user("shift_state.json", "{not valid json") var save := _save_instance() var a := TestUtils.check(is_equal_approx(float(save.load_settings().get("look_sensitivity", 0.0)), 0.0022), "corrupt settings fall back to defaults") var b := TestUtils.check(save.load_campaign().is_empty(), "corrupt campaign returns an empty dictionary") var c := TestUtils.check_eq(save.load_shift_results().size(), 0, "corrupt history returns an empty array") var d := TestUtils.check(save.load_shift_state().is_empty(), "corrupt checkpoint returns an empty dictionary") save.free() _restore_files(backup) return a and b and c and d func test_partial_temp_file_is_ignored() -> bool: var backup := _snapshot_files() _remove_user("settings.json") _write_user("settings.json.tmp", "{\"look_sensitivity\": 0.009, ") var save := _save_instance() var loaded: Dictionary = save.load_settings() var ok := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0022), "a leftover partial temp file is not treated as settings") save.free() _restore_files(backup) return ok func test_failed_atomic_write_keeps_existing_save() -> bool: var backup := _snapshot_files() _remove_user("settings.json") _remove_user("settings.json.tmp") var save := _save_instance() var first := TestUtils.check_eq(int(save.save_settings({"look_sensitivity": 0.0022})), OK, "baseline settings write succeeds") var temp_path := _user_path("settings.json.tmp") var made := DirAccess.make_dir_absolute(temp_path) var err: int = save.save_settings({"look_sensitivity": 0.009}) var a := TestUtils.check(made == OK, "test can block the temp path") var b := TestUtils.check(err != OK, "blocked temp path reports a write error") var loaded: Dictionary = save.load_settings() var c := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0022), "a failed replace leaves the valid settings file intact") DirAccess.remove_absolute(temp_path) save.free() _restore_files(backup) return first and a and b and c