diff --git a/autoload/profile_manager.gd b/autoload/profile_manager.gd index 47d55d4..3e6d0ab 100644 --- a/autoload/profile_manager.gd +++ b/autoload/profile_manager.gd @@ -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] diff --git a/autoload/progress_manager.gd b/autoload/progress_manager.gd index 0c9463f..1f25185 100644 --- a/autoload/progress_manager.gd +++ b/autoload/progress_manager.gd @@ -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) diff --git a/autoload/save_manager.gd b/autoload/save_manager.gd index c3c3626..7c3f071 100644 --- a/autoload/save_manager.gd +++ b/autoload/save_manager.gd @@ -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() diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index 720d975..5a66fc6 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -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__` (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). diff --git a/scripts/profiles/progress_updates.gd b/scripts/profiles/progress_updates.gd new file mode 100644 index 0000000..64de70b --- /dev/null +++ b/scripts/profiles/progress_updates.gd @@ -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 diff --git a/scripts/profiles/progress_updates.gd.uid b/scripts/profiles/progress_updates.gd.uid new file mode 100644 index 0000000..1a1b63c --- /dev/null +++ b/scripts/profiles/progress_updates.gd.uid @@ -0,0 +1 @@ +uid://ql1hepqsa81w diff --git a/scripts/profiles/save_factory.gd b/scripts/profiles/save_factory.gd new file mode 100644 index 0000000..ccef906 --- /dev/null +++ b/scripts/profiles/save_factory.gd @@ -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": []}, + } diff --git a/scripts/profiles/save_factory.gd.uid b/scripts/profiles/save_factory.gd.uid new file mode 100644 index 0000000..8a39286 --- /dev/null +++ b/scripts/profiles/save_factory.gd.uid @@ -0,0 +1 @@ +uid://bmeoyopvvcljr diff --git a/scripts/profiles/save_io.gd b/scripts/profiles/save_io.gd new file mode 100644 index 0000000..1105e60 --- /dev/null +++ b/scripts/profiles/save_io.gd @@ -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] diff --git a/scripts/profiles/save_io.gd.uid b/scripts/profiles/save_io.gd.uid new file mode 100644 index 0000000..571261e --- /dev/null +++ b/scripts/profiles/save_io.gd.uid @@ -0,0 +1 @@ +uid://cnsull4s5riep diff --git a/scripts/profiles/save_migration.gd b/scripts/profiles/save_migration.gd new file mode 100644 index 0000000..30c0f81 --- /dev/null +++ b/scripts/profiles/save_migration.gd @@ -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 diff --git a/scripts/profiles/save_migration.gd.uid b/scripts/profiles/save_migration.gd.uid new file mode 100644 index 0000000..585f9d7 --- /dev/null +++ b/scripts/profiles/save_migration.gd.uid @@ -0,0 +1 @@ +uid://ct1yblqd7jxtw diff --git a/tests/profiles/test_profile_integration.gd b/tests/profiles/test_profile_integration.gd new file mode 100644 index 0000000..caf3612 --- /dev/null +++ b/tests/profiles/test_profile_integration.gd @@ -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)) diff --git a/tests/profiles/test_profile_integration.gd.uid b/tests/profiles/test_profile_integration.gd.uid new file mode 100644 index 0000000..ed64290 --- /dev/null +++ b/tests/profiles/test_profile_integration.gd.uid @@ -0,0 +1 @@ +uid://cx1dljkp8ilqk diff --git a/tests/profiles/test_progress_updates.gd b/tests/profiles/test_progress_updates.gd new file mode 100644 index 0000000..72bbf9b --- /dev/null +++ b/tests/profiles/test_progress_updates.gd @@ -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) diff --git a/tests/profiles/test_progress_updates.gd.uid b/tests/profiles/test_progress_updates.gd.uid new file mode 100644 index 0000000..a321053 --- /dev/null +++ b/tests/profiles/test_progress_updates.gd.uid @@ -0,0 +1 @@ +uid://cde72oleqofin diff --git a/tests/profiles/test_save_factory.gd b/tests/profiles/test_save_factory.gd new file mode 100644 index 0000000..eb21adc --- /dev/null +++ b/tests/profiles/test_save_factory.gd @@ -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": []}) diff --git a/tests/profiles/test_save_factory.gd.uid b/tests/profiles/test_save_factory.gd.uid new file mode 100644 index 0000000..3de3876 --- /dev/null +++ b/tests/profiles/test_save_factory.gd.uid @@ -0,0 +1 @@ +uid://bsf48s4utme77 diff --git a/tests/profiles/test_save_io.gd b/tests/profiles/test_save_io.gd new file mode 100644 index 0000000..11ad14c --- /dev/null +++ b/tests/profiles/test_save_io.gd @@ -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)) diff --git a/tests/profiles/test_save_io.gd.uid b/tests/profiles/test_save_io.gd.uid new file mode 100644 index 0000000..a159e7e --- /dev/null +++ b/tests/profiles/test_save_io.gd.uid @@ -0,0 +1 @@ +uid://cuh4j0qf788kx diff --git a/tests/profiles/test_save_migration.gd b/tests/profiles/test_save_migration.gd new file mode 100644 index 0000000..a893ee6 --- /dev/null +++ b/tests/profiles/test_save_migration.gd @@ -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") diff --git a/tests/profiles/test_save_migration.gd.uid b/tests/profiles/test_save_migration.gd.uid new file mode 100644 index 0000000..47f5a88 --- /dev/null +++ b/tests/profiles/test_save_migration.gd.uid @@ -0,0 +1 @@ +uid://d0k3uwi76t0s8