Add save migration fixtures and failure-mode coverage

This commit is contained in:
zwitschi committed 2026-11-03 07:29:42 +01:00
1 parent b51bdc8efe
commit 60bf9a8ef6
12 files changed
+484 -22

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# Test Fixtures
Committed JSON fixtures used by persistence and migration tests.
- [`saves/settings_0_8.json`](saves/settings_0_8.json) — a 0.8-era settings file missing the later volume, motion and caption keys; loading it must keep the old values and fill the new defaults.
- [`saves/campaign_0_8.json`](saves/campaign_0_8.json) — a 0.8-era campaign after the Hollywood shift; loading it must resolve the Joke Shop as the next venue.
- [`saves/shift_results_0_8.json`](saves/shift_results_0_8.json) — a 0.8-era shift history with two completed venues.
- [`saves/shift_state_0_9_0.json`](saves/shift_state_0_9_0.json) — a version-1 0.9.0 checkpoint at the Joke Shop with door, flashlight and body-camera state set.
These are deliberately schema-less where the old game was schema-less; the
loader fills missing fields from the current defaults rather than rewriting the
player's file on load.
+11
View File
@@ -0,0 +1,11 @@
{
"index": 1,
"next_venue": "joke_shop",
"shift_number": 2,
"difficulty": "hard",
"complete": false,
"completed": [
"hollywood_building"
],
"last_grade": "B"
}
+15
View File
@@ -0,0 +1,15 @@
{
"look_sensitivity": 0.0031,
"hud_font_scale": 1.15,
"invert_y": true,
"head_bob": false,
"input_bindings": {
"inspect": {
"type": "key",
"code": 90
}
},
"ai_difficulty": "hard",
"tutorial_completed": true,
"tutorial_skipped": false
}
+20
View File
@@ -0,0 +1,20 @@
[
{
"scenario_id": "hollywood_building",
"reputation": {
"rating": 81.0,
"grade": "B"
},
"spawned_count": 6,
"completed_at_ms": 1000
},
{
"scenario_id": "joke_shop",
"reputation": {
"rating": 74.5,
"grade": "C"
},
"spawned_count": 5,
"completed_at_ms": 2000
}
]
+26
View File
@@ -0,0 +1,26 @@
{
"version": 1,
"scenario_id": "joke_shop",
"guard_position": [0.0, 0.1, 10.5],
"guard_yaw": 1.25,
"head_pitch": 0.1,
"door_locked": true,
"flashlight_on": true,
"body_camera_on": true,
"npc_counter": 7,
"coordinator": {
"duration_s": 900.0,
"elapsed_s": 540.0,
"current_phase": 3,
"phase_offset_s": 700.0,
"spawned_count": 7,
"responder_outcomes": [],
"investigations": [],
"recorder": {},
"reputation": {},
"crowd": {},
"radio": {},
"scheduler": {}
},
"npcs": []
}
+248
View File
@@ -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