Add profile and save persistence, wired into the three autoloads

scripts/profiles/: SaveIO handles real file I/O in user://saves (one
file per profile_id, atomic write via temp-file-then-rename, backup
copy before each overwrite, corrupted-main falls back to backup,
schema-invalid content is rejected like corruption). SaveMigration
and SaveFactory are pure; SaveMigration refuses a schema_version it
doesn't recognize (older or newer) rather than guessing. ProgressUpdates
is pure and wires ReviewScheduler/DifficultyManager onto a save's
skills dict, including per-session overdue-review marking.

The three autoloads (SaveManager, ProfileManager, ProgressManager)
stay thin: in-memory current-profile state and orchestration only,
no logic of their own, so the actual behavior lives in the tested
pure/file-IO modules above rather than in untestable glue.

26 new headless tests, including the failure modes called out in the
launch prompt: a truncated .tmp file never corrupts the readable
save, a corrupted main file falls back to its backup, two profiles
never mix (down through the real autoload layer, not just SaveIO),
and an unknown future schema_version is rejected outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 17:12:50 +02:00
parent 2fe455f5d1
commit ebabe91821
22 changed files with 696 additions and 6 deletions
+34 -2
View File
@@ -1,3 +1,35 @@
extends Node
## Creation, selection et suppression des profils enfants.
## Logique detaillee a partir de l'etape "Profils et sauvegardes".
## Creation, selection et suppression des profils enfants. Chaque profil a un
## profile_id distinct ; SaveManager garantit qu'un profil ne lit ni n'ecrit
## jamais le fichier d'un autre (voir "Points durs" du prompt de lancement).
var current_profile_id: String = ""
func create_profile(display_name: String, school_level: String) -> String:
var profile_id := _generate_profile_id()
SaveManager.load_or_create(profile_id, display_name, school_level)
return profile_id
## Charge le profil dans ProgressManager et le marque comme profil courant.
## Ne fait rien si le profil n'existe pas.
func select_profile(profile_id: String) -> bool:
if not ProgressManager.load_profile(profile_id):
return false
current_profile_id = profile_id
return true
func delete_profile(profile_id: String) -> void:
SaveManager.delete(profile_id)
if current_profile_id == profile_id:
current_profile_id = ""
func list_profiles() -> Array[String]:
return SaveManager.list_profile_ids()
func _generate_profile_id() -> String:
return "profile_%d_%d" % [Time.get_unix_time_from_system(), randi() % 10000]
+57 -2
View File
@@ -1,3 +1,58 @@
extends Node
## Competences, missions debloquees et planification des revisions par profil.
## Logique detaillee a partir de l'etape "Logique pure" (voir docs/specs/decisions.md, point 5).
## Competences, missions debloquees et planification des revisions du profil
## selectionne. Delegue les calculs a ProgressUpdates (scripts/profiles/), qui
## s'appuie sur ReviewScheduler et DifficultyManager (scripts/pedagogy/).
## L'ecriture sur disque reste groupee : save_now() est appele en fin de
## mission, pas a chaque tentative (docs/specs/decisions.md, point 7).
var _current_save: Dictionary = {}
var _current_profile_id: String = ""
## Charge la sauvegarde existante d'un profil et demarre une nouvelle seance
## (voir ProgressUpdates.start_session). Faux si le profil n'a pas de
## sauvegarde lisible.
func load_profile(profile_id: String) -> bool:
var data: Variant = SaveManager.load(profile_id)
if data == null:
return false
_current_profile_id = profile_id
_current_save = ProgressUpdates.start_session(data)
return true
func record_attempt(
skill_id: String, success: bool, hint_used: bool, exercise_type: String
) -> void:
var attempt := {
"skill": skill_id,
"success": success,
"hint_used": hint_used,
"exercise_type": exercise_type,
}
_current_save = ProgressUpdates.record_attempt(_current_save, attempt)
func unlock_region(region_id: String) -> void:
_current_save = ProgressUpdates.unlock_region(_current_save, region_id)
func next_difficulty_for_skill(skill_id: String, current_difficulty: int) -> int:
return ProgressUpdates.next_difficulty_for_skill(_current_save, skill_id, current_difficulty)
func get_skill_state(skill_id: String) -> Dictionary:
var skills: Dictionary = _current_save.get("skills", {})
return skills.get(skill_id, ReviewScheduler.initial_state())
func current_save() -> Dictionary:
return _current_save
## A appeler en fin de mission (pas apres chaque tentative) pour persister la
## progression du profil courant.
func save_now() -> bool:
if _current_profile_id.is_empty():
return false
return SaveManager.save(_current_profile_id, _current_save)
+37 -2
View File
@@ -1,4 +1,39 @@
extends Node
## Serialisation des sauvegardes par profil dans user:// (ecriture atomique,
## copie de secours, migration).
## Logique detaillee a partir de l'etape "Profils et sauvegardes".
## copie de secours, migration). Delegue le travail reel a SaveIO,
## SaveFactory et SaveMigration (scripts/profiles/), qui restent testables
## sans arbre de scenes.
var _save_schema: Dictionary = {}
func _ready() -> void:
_save_schema = DataLoader.load_schema("save")
## Cree une nouvelle sauvegarde si le profil n'en a pas encore.
func load_or_create(profile_id: String, display_name: String, school_level: String) -> Dictionary:
var existing: Variant = SaveIO.read(profile_id, _save_schema)
if existing != null:
return existing
var created := SaveFactory.new_save(profile_id, display_name, school_level)
SaveIO.write(profile_id, created)
return created
## Lit la sauvegarde d'un profil existant. Null si elle est introuvable (et sa
## copie de secours aussi).
func load(profile_id: String) -> Variant:
return SaveIO.read(profile_id, _save_schema)
func save(profile_id: String, data: Dictionary) -> bool:
return SaveIO.write(profile_id, data)
func delete(profile_id: String) -> void:
SaveIO.delete(profile_id)
func list_profile_ids() -> Array[String]:
return SaveIO.list_profile_ids()
+9
View File
@@ -105,3 +105,12 @@ Ajouts à l'arborescence proposée :
- `DictationAnalyzer` aligne les mots en comparant une version en minuscules (alignement insensible à la casse) : sinon une majuscule manquante en début de mot est vue comme une substitution par `WordAligner` et jamais testée comme erreur de capitalisation à part (point 6 des décisions). Les mots originaux (avec leur casse) restent utilisés pour construire les erreurs et pour la vérification de majuscule.
- `ReviewScheduler.record_attempt` est une fonction pure qui duplique l'état (`Dictionary.duplicate(true)`) plutôt que de le modifier en place ; `ProgressManager` (étape « Profils et sauvegardes ») sera responsable de stocker l'état retourné.
- Le planning de révision est entièrement piloté par le numéro de séance (`current_session`), jamais par une horloge : paliers `[1, 3, 6, 12]` séances après la dernière réussite à un palier donné, comme décidé au point 5. Une compétence « acquise » dont le délai est dépassé sans nouvelle tentative passe à « à réviser » via `ReviewScheduler.mark_overdue_as_review`, à appeler par `ProgressManager` en début de séance.
## 13. Étape « Profils et sauvegardes » : précisions prises en cours d'implémentation
- Pas de classes de modèle dans `scripts/models/` (présentes dans l'arborescence indicative du §7) : les sauvegardes restent des `Dictionary` validés contre `data/schemas/save.schema.json`, comme les autres données. Ajout à la liste des écarts du point 9 ci-dessus.
- Découpage en couches : `scripts/profiles/save_io.gd` (lecture/écriture atomique, copie de secours, pas pur — touche le système de fichiers), `save_migration.gd` et `save_factory.gd` (purs), `progress_updates.gd` (pur, s'appuie sur `ReviewScheduler`/`DifficultyManager`). Les autoloads `SaveManager`, `ProfileManager`, `ProgressManager` restent de la colle fine : aucune logique propre, seulement de l'orchestration et de l'état en mémoire (profil courant, sauvegarde courante).
- `id` de profil généré par `ProfileManager` : `profile_<epoch>_<alea>` (conforme au motif `^[a-z][a-z0-9_]*$` du schéma). Pas de saisie de pseudonyme distinct de `display_name` à ce stade — à revoir si besoin à l'étape « Scènes ».
- Génie du moteur JSON de Godot : les nombres relus depuis un fichier sont toujours des `float`, jamais des `int`, même pour un entier comme `session_count`. GDScript convertit implicitement à l'affectation dans une variable typée `int`, mais les comparaisons strictes dans les tests doivent caster explicitement (`int(...)`), sinon GUT émet un avertissement.
- `JSON.parse_string` sur un contenu invalide imprime une erreur moteur (`Parse JSON failed`), même quand l'appelant la gère correctement via la valeur de retour. GUT la compte comme un échec de test par défaut : les tests qui provoquent volontairement cette corruption doivent appeler `assert_engine_error("Parse JSON failed")` pour la marquer comme attendue.
- Les indicateurs « erreurs fréquentes » et l'espace adulte (§9) ne sont pas implémentés : explicitement hors périmètre de la première version jouable (prompt de lancement, Objectif).
+60
View File
@@ -0,0 +1,60 @@
class_name ProgressUpdates
extends RefCounted
## Fonctions pures qui appliquent une tentative d'exercice ou un debut de
## seance a une sauvegarde (dictionnaire conforme a save.schema.json), en
## s'appuyant sur ReviewScheduler et DifficultyManager (scripts/pedagogy/).
## ProgressManager (autoload) est seul responsable d'appeler ces fonctions et
## de persister le resultat via SaveManager ; l'ecriture sur disque reste
## groupee en fin de mission (docs/specs/decisions.md, point 7).
## attempt : {"skill": String, "success": bool, "hint_used": bool,
## "exercise_type": String}.
static func record_attempt(save_data: Dictionary, attempt: Dictionary) -> Dictionary:
var updated: Dictionary = save_data.duplicate(true)
var skills: Dictionary = updated["skills"]
var skill_id: String = attempt["skill"]
var skill_state: Dictionary = (
skills[skill_id] if skills.has(skill_id) else ReviewScheduler.initial_state()
)
var current_session: int = updated["session_count"]
skills[skill_id] = ReviewScheduler.record_attempt(skill_state, attempt, current_session)
updated["skills"] = skills
return updated
## A appeler une fois en debut de seance, avant toute tentative : incremente
## session_count et bascule en "a_reviser" les competences "acquise" dont le
## delai de revision est depasse sans nouvelle pratique.
static func start_session(save_data: Dictionary) -> Dictionary:
var updated: Dictionary = save_data.duplicate(true)
updated["session_count"] = int(updated["session_count"]) + 1
var current_session: int = updated["session_count"]
var skills: Dictionary = updated["skills"]
var updated_skills: Dictionary = {}
for skill_id: String in skills:
updated_skills[skill_id] = ReviewScheduler.mark_overdue_as_review(
skills[skill_id], current_session
)
updated["skills"] = updated_skills
return updated
static func next_difficulty_for_skill(
save_data: Dictionary, skill_id: String, current_difficulty: int
) -> int:
var skills: Dictionary = save_data["skills"]
if not skills.has(skill_id):
return current_difficulty
var recent: Array[bool] = skills[skill_id]["recent_results"]
return DifficultyManager.next_difficulty(current_difficulty, recent)
static func unlock_region(save_data: Dictionary, region_id: String) -> Dictionary:
var updated: Dictionary = save_data.duplicate(true)
var regions: Array = updated["unlocked_regions"]
if not regions.has(region_id):
regions.append(region_id)
updated["unlocked_regions"] = regions
return updated
+1
View File
@@ -0,0 +1 @@
uid://ql1hepqsa81w
+17
View File
@@ -0,0 +1,17 @@
class_name SaveFactory
extends RefCounted
## Construit une nouvelle sauvegarde vide pour un profil, conforme a
## data/schemas/save.schema.json. Code pur.
static func new_save(profile_id: String, display_name: String, school_level: String) -> Dictionary:
return {
"schema_version": SaveMigration.CURRENT_SCHEMA_VERSION,
"profile_id": profile_id,
"display_name": display_name,
"school_level": school_level,
"session_count": 0,
"unlocked_regions": [],
"skills": {},
"rewards": {"stars": 0, "items": []},
}
+1
View File
@@ -0,0 +1 @@
uid://bmeoyopvvcljr
+115
View File
@@ -0,0 +1,115 @@
class_name SaveIO
extends RefCounted
## Lecture/ecriture des sauvegardes par profil dans user://saves, avec
## ecriture atomique (fichier temporaire puis remplacement), copie de secours
## en cas de corruption du fichier principal, et migration de schema a la
## lecture. Pas pur (touche le systeme de fichiers), mais sans dependance a
## l'arbre de scenes : testable en headless avec de vrais chemins user://.
## Un profil ne lit et n'ecrit jamais que son propre fichier (nomme par
## profile_id) : deux profils ne peuvent pas se melanger.
const SAVE_DIR := "user://saves"
static func write(profile_id: String, data: Dictionary) -> bool:
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
var tmp_path := _tmp_path(profile_id)
var tmp_file := FileAccess.open(tmp_path, FileAccess.WRITE)
if tmp_file == null:
return false
tmp_file.store_string(JSON.stringify(data))
tmp_file.close()
var main_path := _save_path(profile_id)
if FileAccess.file_exists(main_path):
_copy_file(main_path, _backup_path(profile_id))
var dir := DirAccess.open(SAVE_DIR)
if dir == null:
return false
return dir.rename(tmp_path, main_path) == OK
## Lit la sauvegarde d'un profil, deja migree vers le schema courant et
## validee contre `schema`. Retourne null si le fichier principal est absent,
## corrompu ou invalide ET que la copie de secours l'est aussi (ou n'existe
## pas).
static func read(profile_id: String, schema: Dictionary) -> Variant:
var from_main: Variant = _try_read(_save_path(profile_id), schema)
if from_main != null:
return from_main
return _try_read(_backup_path(profile_id), schema)
static func delete(profile_id: String) -> void:
var dir := DirAccess.open(SAVE_DIR)
if dir == null:
return
for path in [_save_path(profile_id), _backup_path(profile_id), _tmp_path(profile_id)]:
if FileAccess.file_exists(path):
dir.remove(path.get_file())
static func list_profile_ids() -> Array[String]:
var ids: Array[String] = []
var dir := DirAccess.open(SAVE_DIR)
if dir == null:
return ids
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if entry.ends_with(".json") and not entry.ends_with(".backup.json"):
ids.append(entry.get_basename())
entry = dir.get_next()
return ids
static func _try_read(path: String, schema: Dictionary) -> Variant:
var parsed: Variant = _read_raw_json(path)
if typeof(parsed) != TYPE_DICTIONARY:
return null
var migrated: Dictionary = SaveMigration.migrate(parsed)
if migrated.is_empty():
return null
var errors: Array[String] = SchemaValidator.validate(migrated, schema)
if not errors.is_empty():
return null
return migrated
static func _read_raw_json(path: String) -> Variant:
if not FileAccess.file_exists(path):
return null
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return null
var text := file.get_as_text()
if text.strip_edges().is_empty():
return null
return JSON.parse_string(text)
static func _copy_file(from_path: String, to_path: String) -> void:
var source := FileAccess.open(from_path, FileAccess.READ)
if source == null:
return
var bytes := source.get_buffer(source.get_length())
source.close()
var dest := FileAccess.open(to_path, FileAccess.WRITE)
if dest == null:
return
dest.store_buffer(bytes)
dest.close()
static func _save_path(profile_id: String) -> String:
return "%s/%s.json" % [SAVE_DIR, profile_id]
static func _tmp_path(profile_id: String) -> String:
return "%s/%s.json.tmp" % [SAVE_DIR, profile_id]
static func _backup_path(profile_id: String) -> String:
return "%s/%s.backup.json" % [SAVE_DIR, profile_id]
+1
View File
@@ -0,0 +1 @@
uid://cnsull4s5riep
+30
View File
@@ -0,0 +1,30 @@
class_name SaveMigration
extends RefCounted
## Migration des sauvegardes entre versions de schema. Code pur. Une seule
## version existe pour l'instant (1) ; chaque montee de version future ajoute
## une etape a la chaine ci-dessous, sans jamais reecrire les etapes passees.
const CURRENT_SCHEMA_VERSION := 1
## Retourne un dictionnaire vide si la version est absente, invalide, ou plus
## recente que ce que cette version du moteur sait lire : refuser plutot que
## de deviner et corrompre la progression de l'enfant.
static func migrate(data: Dictionary) -> Dictionary:
if not data.has("schema_version"):
return {}
var version: Variant = data["schema_version"]
if typeof(version) != TYPE_INT and typeof(version) != TYPE_FLOAT:
return {}
var version_int: int = int(version)
if version_int < 1 or version_int > CURRENT_SCHEMA_VERSION:
return {}
var migrated: Dictionary = data.duplicate(true)
# Chaine de montees de version, a completer quand CURRENT_SCHEMA_VERSION
# augmentera :
# if version_int == 1:
# migrated = _migrate_v1_to_v2(migrated)
# version_int = 2
migrated["schema_version"] = CURRENT_SCHEMA_VERSION
return migrated
+1
View File
@@ -0,0 +1 @@
uid://ct1yblqd7jxtw
@@ -0,0 +1,64 @@
extends GutTest
## Tests d'integration des autoloads ProfileManager / SaveManager /
## ProgressManager : la "colle" fine entre la logique pure (deja testee
## isolement) et la persistance reelle sur disque.
var created_profile_ids: Array[String] = []
func after_each() -> void:
for profile_id in created_profile_ids:
ProfileManager.delete_profile(profile_id)
created_profile_ids.clear()
func test_create_and_select_profile_round_trips_through_disk() -> void:
var profile_id := ProfileManager.create_profile("Explorateur", "CE1")
created_profile_ids.append(profile_id)
assert_true(ProfileManager.select_profile(profile_id))
assert_eq(ProfileManager.current_profile_id, profile_id)
ProgressManager.record_attempt("accord_groupe_nominal", true, false, "multiple_choice")
assert_true(ProgressManager.save_now())
var reloaded: Variant = SaveManager.load(profile_id)
assert_not_null(reloaded)
assert_true(reloaded["skills"].has("accord_groupe_nominal"))
func test_selecting_unknown_profile_fails_without_side_effects() -> void:
assert_false(ProfileManager.select_profile("profile_does_not_exist"))
func test_two_profiles_are_isolated_through_the_autoload_layer() -> void:
var profile_a := ProfileManager.create_profile("Alice", "CE1")
var profile_b := ProfileManager.create_profile("Bob", "CE2")
created_profile_ids.append(profile_a)
created_profile_ids.append(profile_b)
ProfileManager.select_profile(profile_a)
ProgressManager.record_attempt("accord_groupe_nominal", true, false, "multiple_choice")
ProgressManager.save_now()
ProfileManager.select_profile(profile_b)
ProgressManager.record_attempt("homophones", false, true, "sentence_correction")
ProgressManager.save_now()
var save_a: Variant = SaveManager.load(profile_a)
var save_b: Variant = SaveManager.load(profile_b)
assert_true(save_a["skills"].has("accord_groupe_nominal"))
assert_false(save_a["skills"].has("homophones"))
assert_true(save_b["skills"].has("homophones"))
assert_false(save_b["skills"].has("accord_groupe_nominal"))
func test_delete_profile_removes_it_from_disk_and_listing() -> void:
var profile_id := ProfileManager.create_profile("Explorateur", "CE1")
assert_true(ProfileManager.list_profiles().has(profile_id))
ProfileManager.delete_profile(profile_id)
assert_false(ProfileManager.list_profiles().has(profile_id))
assert_null(SaveManager.load(profile_id))
@@ -0,0 +1 @@
uid://cx1dljkp8ilqk
+83
View File
@@ -0,0 +1,83 @@
extends GutTest
## Tests de ProgressUpdates : application pure des tentatives et des debuts de
## seance a une sauvegarde.
func _base_save() -> Dictionary:
return SaveFactory.new_save("profile_test", "Explorateur", "CE1")
func test_record_attempt_creates_skill_state_on_first_attempt() -> void:
var data := _base_save()
var attempt := {
"skill": "accord_groupe_nominal",
"success": true,
"hint_used": false,
"exercise_type": "multiple_choice",
}
var updated := ProgressUpdates.record_attempt(data, attempt)
assert_true(updated["skills"].has("accord_groupe_nominal"))
assert_eq(
updated["skills"]["accord_groupe_nominal"]["status"], ReviewScheduler.STATUS_EN_ACQUISITION
)
func test_record_attempt_uses_current_session_count() -> void:
var data := _base_save()
data["session_count"] = 5
var attempt := {
"skill": "accord_groupe_nominal",
"success": true,
"hint_used": false,
"exercise_type": "multiple_choice",
}
var updated := ProgressUpdates.record_attempt(data, attempt)
var skill_state: Dictionary = updated["skills"]["accord_groupe_nominal"]
assert_eq(skill_state["last_session_practiced"], 5)
func test_record_attempt_does_not_mutate_input() -> void:
var data := _base_save()
var attempt := {
"skill": "accord_groupe_nominal",
"success": true,
"hint_used": false,
"exercise_type": "multiple_choice",
}
ProgressUpdates.record_attempt(data, attempt)
assert_eq(data["skills"], {})
func test_start_session_increments_session_count() -> void:
var data := _base_save()
var updated := ProgressUpdates.start_session(data)
assert_eq(updated["session_count"], 1)
updated = ProgressUpdates.start_session(updated)
assert_eq(updated["session_count"], 2)
func test_start_session_marks_overdue_skills_as_a_reviser() -> void:
var data := _base_save()
var acquise_state: Dictionary = ReviewScheduler.initial_state()
acquise_state["status"] = ReviewScheduler.STATUS_ACQUISE
acquise_state["next_review_session"] = 2
data["skills"] = {"accord_groupe_nominal": acquise_state}
data["session_count"] = 1
var updated := ProgressUpdates.start_session(data)
assert_eq(updated["session_count"], 2)
assert_eq(
updated["skills"]["accord_groupe_nominal"]["status"], ReviewScheduler.STATUS_A_REVISER
)
func test_unlock_region_adds_region_once() -> void:
var data := _base_save()
data = ProgressUpdates.unlock_region(data, "foret_des_accords")
data = ProgressUpdates.unlock_region(data, "foret_des_accords")
assert_eq(data["unlocked_regions"], ["foret_des_accords"])
func test_next_difficulty_for_unknown_skill_returns_current_difficulty() -> void:
var data := _base_save()
assert_eq(ProgressUpdates.next_difficulty_for_skill(data, "inconnue", 2), 2)
@@ -0,0 +1 @@
uid://cde72oleqofin
+23
View File
@@ -0,0 +1,23 @@
extends GutTest
## Tests de SaveFactory : une nouvelle sauvegarde doit etre conforme a
## data/schemas/save.schema.json.
var save_schema: Dictionary = {}
func before_all() -> void:
save_schema = DataLoader.load_schema("save")
func test_new_save_is_valid_against_the_save_schema() -> void:
var data := SaveFactory.new_save("profile_abc123", "Explorateur", "CE1")
var errors := SchemaValidator.validate(data, save_schema)
assert_eq(errors, [])
func test_new_save_starts_at_session_zero_with_no_skills() -> void:
var data := SaveFactory.new_save("profile_abc123", "Explorateur", "CE1")
assert_eq(data["session_count"], 0)
assert_eq(data["skills"], {})
assert_eq(data["unlocked_regions"], [])
assert_eq(data["rewards"], {"stars": 0, "items": []})
+1
View File
@@ -0,0 +1 @@
uid://bsf48s4utme77
+122
View File
@@ -0,0 +1,122 @@
extends GutTest
## Tests de SaveIO : ecriture atomique, copie de secours, isolation entre
## profils. Utilise de vrais chemins user:// (headless Godot les fournit),
## avec des profile_id de test nettoyes avant et apres la suite.
const PROFILE_IDS: Array[String] = [
"test_save_io_roundtrip",
"test_save_io_profile_a",
"test_save_io_profile_b",
"test_save_io_truncated_tmp",
"test_save_io_corrupted_main",
"test_save_io_invalid_content",
"test_save_io_missing",
"test_save_io_delete",
]
var save_schema: Dictionary = {}
func before_all() -> void:
save_schema = DataLoader.load_schema("save")
for profile_id in PROFILE_IDS:
SaveIO.delete(profile_id)
func after_all() -> void:
for profile_id in PROFILE_IDS:
SaveIO.delete(profile_id)
func test_write_then_read_roundtrip() -> void:
var data := SaveFactory.new_save("test_save_io_roundtrip", "Explorateur", "CE1")
data["session_count"] = 3
assert_true(SaveIO.write("test_save_io_roundtrip", data))
var read_back: Variant = SaveIO.read("test_save_io_roundtrip", save_schema)
assert_eq(int(read_back["session_count"]), 3)
assert_eq(read_back["profile_id"], "test_save_io_roundtrip")
func test_two_profiles_do_not_mix() -> void:
var data_a := SaveFactory.new_save("test_save_io_profile_a", "Alice", "CE1")
data_a["session_count"] = 1
var data_b := SaveFactory.new_save("test_save_io_profile_b", "Bob", "CE2")
data_b["session_count"] = 7
SaveIO.write("test_save_io_profile_a", data_a)
SaveIO.write("test_save_io_profile_b", data_b)
var read_a: Variant = SaveIO.read("test_save_io_profile_a", save_schema)
var read_b: Variant = SaveIO.read("test_save_io_profile_b", save_schema)
assert_eq(read_a["display_name"], "Alice")
assert_eq(int(read_a["session_count"]), 1)
assert_eq(read_b["display_name"], "Bob")
assert_eq(int(read_b["session_count"]), 7)
func test_missing_profile_returns_null() -> void:
assert_null(SaveIO.read("test_save_io_missing", save_schema))
func test_truncated_tmp_file_does_not_corrupt_the_readable_save() -> void:
# Simule un plantage pendant l'ecriture : le fichier temporaire existe,
# tronque et invalide, mais n'a jamais ete renomme sur le fichier
# principal. La derniere sauvegarde valide doit rester lisible.
var profile_id := "test_save_io_truncated_tmp"
var data := SaveFactory.new_save(profile_id, "Explorateur", "CE1")
SaveIO.write(profile_id, data)
var tmp_path := "%s/%s.json.tmp" % [SaveIO.SAVE_DIR, profile_id]
var tmp_file := FileAccess.open(tmp_path, FileAccess.WRITE)
tmp_file.store_string('{"schema_version": 1, "profile_id": ')
tmp_file.close()
var read_back: Variant = SaveIO.read(profile_id, save_schema)
assert_not_null(read_back)
assert_eq(read_back["profile_id"], profile_id)
func test_corrupted_main_file_falls_back_to_backup() -> void:
var profile_id := "test_save_io_corrupted_main"
var first_save := SaveFactory.new_save(profile_id, "Explorateur", "CE1")
first_save["session_count"] = 1
SaveIO.write(profile_id, first_save)
# Deuxieme ecriture : copie la version precedente (valide) en secours
# avant de l'ecraser.
var second_save := first_save.duplicate(true)
second_save["session_count"] = 2
SaveIO.write(profile_id, second_save)
var main_path := "%s/%s.json" % [SaveIO.SAVE_DIR, profile_id]
var main_file := FileAccess.open(main_path, FileAccess.WRITE)
main_file.store_string("ceci n'est pas du json valide {{{")
main_file.close()
var read_back: Variant = SaveIO.read(profile_id, save_schema)
assert_engine_error("Parse JSON failed")
assert_not_null(read_back)
assert_eq(int(read_back["session_count"]), 1)
func test_schema_invalid_content_without_backup_returns_null() -> void:
var profile_id := "test_save_io_invalid_content"
var main_path := "%s/%s.json" % [SaveIO.SAVE_DIR, profile_id]
DirAccess.make_dir_recursive_absolute(SaveIO.SAVE_DIR)
var main_file := FileAccess.open(main_path, FileAccess.WRITE)
main_file.store_string(JSON.stringify({"schema_version": 1, "profile_id": profile_id}))
main_file.close()
assert_null(SaveIO.read(profile_id, save_schema))
func test_delete_removes_main_backup_and_tmp_files() -> void:
var profile_id := "test_save_io_delete"
var data := SaveFactory.new_save(profile_id, "Explorateur", "CE1")
SaveIO.write(profile_id, data)
SaveIO.write(profile_id, data)
SaveIO.delete(profile_id)
assert_null(SaveIO.read(profile_id, save_schema))
assert_false(SaveIO.list_profile_ids().has(profile_id))
+1
View File
@@ -0,0 +1 @@
uid://cuh4j0qf788kx
+36
View File
@@ -0,0 +1,36 @@
extends GutTest
## Tests de SaveMigration : garde-fous sur la version de schema.
func test_current_version_is_returned_unchanged() -> void:
var data := {"schema_version": 1, "profile_id": "profile_test"}
var migrated := SaveMigration.migrate(data)
assert_eq(migrated["schema_version"], 1)
assert_eq(migrated["profile_id"], "profile_test")
func test_missing_version_is_rejected() -> void:
var data := {"profile_id": "profile_test"}
assert_eq(SaveMigration.migrate(data), {})
func test_future_unknown_version_is_rejected_rather_than_guessed() -> void:
var data := {"schema_version": 99, "profile_id": "profile_test"}
assert_eq(SaveMigration.migrate(data), {})
func test_zero_or_negative_version_is_rejected() -> void:
var data := {"schema_version": 0, "profile_id": "profile_test"}
assert_eq(SaveMigration.migrate(data), {})
func test_non_numeric_version_is_rejected() -> void:
var data := {"schema_version": "one", "profile_id": "profile_test"}
assert_eq(SaveMigration.migrate(data), {})
func test_migration_does_not_mutate_the_input_in_place() -> void:
var data := {"schema_version": 1, "profile_id": "profile_test"}
var migrated := SaveMigration.migrate(data)
migrated["profile_id"] = "changed"
assert_eq(data["profile_id"], "profile_test")
@@ -0,0 +1 @@
uid://d0k3uwi76t0s8