Add save migration fixtures and failure-mode coverage
This commit is contained in:
1 parent
b51bdc8efe
commit
60bf9a8ef6
12 files changed
+484
-22
No files matched your search
@@ -0,0 +1,248 @@
|
||||
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
|
||||
Reference in new issue
Block a user