mirror of
https://github.com/deunix-educ/aventuriers-des-mots.git
synced 2026-10-13 02:11:58 +02:00
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:
@@ -1,3 +1,35 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Creation, selection et suppression des profils enfants.
|
## Creation, selection et suppression des profils enfants. Chaque profil a un
|
||||||
## Logique detaillee a partir de l'etape "Profils et sauvegardes".
|
## 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]
|
||||||
|
|||||||
@@ -1,3 +1,58 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Competences, missions debloquees et planification des revisions par profil.
|
## Competences, missions debloquees et planification des revisions du profil
|
||||||
## Logique detaillee a partir de l'etape "Logique pure" (voir docs/specs/decisions.md, point 5).
|
## 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)
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Serialisation des sauvegardes par profil dans user:// (ecriture atomique,
|
## Serialisation des sauvegardes par profil dans user:// (ecriture atomique,
|
||||||
## copie de secours, migration).
|
## copie de secours, migration). Delegue le travail reel a SaveIO,
|
||||||
## Logique detaillee a partir de l'etape "Profils et sauvegardes".
|
## 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()
|
||||||
|
|||||||
@@ -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.
|
- `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é.
|
- `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.
|
- 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).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ql1hepqsa81w
|
||||||
@@ -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": []},
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bmeoyopvvcljr
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cnsull4s5riep
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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": []})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bsf48s4utme77
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cuh4j0qf788kx
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user