Add settings screen and adult space with progress report and save export/restore (0.4.0)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-10-11 22:49:01 +02:00
parent 97a065879c
commit dbf513e686
45 changed files with 1390 additions and 14 deletions
+14
View File
@@ -0,0 +1,14 @@
extends GutTest
## Chaque notion (skill) presente dans les donnees doit avoir un intitule
## affichable dans l'espace adulte.
func test_every_skill_has_a_label() -> void:
var file := FileAccess.open(UIText.LOCALIZATION_PATH, FileAccess.READ)
var labels: Dictionary = JSON.parse_string(file.get_as_text())
var seen := {}
for rule: Dictionary in ExerciseEngine._rules.values():
seen[rule["skill"]] = true
assert_gt(seen.size(), 0)
for skill_id: String in seen:
assert_true(labels.has("skill.%s" % skill_id), "Intitule manquant : skill.%s" % skill_id)
+1
View File
@@ -0,0 +1 @@
uid://dri3p6o43m5io
+70
View File
@@ -0,0 +1,70 @@
extends GutTest
## Tests de ProgressReport : synthese pour l'adulte et detection des notions a
## retravailler.
func _save_with_skill(state: Dictionary) -> Dictionary:
var save := SaveFactory.new_save("test_report", "Lea", "CE1")
save["skills"] = {"accord_groupe_nominal": state}
return save
func _state(status: String, results: Array, hints: Array) -> Dictionary:
return {
"status": status,
"recent_results": results,
"recent_hint_used": hints,
"sessions_succeeded": [],
"formats_succeeded": [],
"last_session_practiced": 0,
"review_interval_index": 0,
"next_review_session": 0,
}
func test_empty_profile_has_no_skills_and_zero_counts() -> void:
var report := ProgressReport.build(SaveFactory.new_save("test_report", "Lea", "CE1"), 9, 10)
assert_eq(report["stars"], 0)
assert_eq(report["missions_done"], 0)
assert_eq(report["missions_total"], 9)
assert_eq(report["items_total"], 10)
assert_eq(report["skills"].size(), 0)
func test_counts_missions_stars_and_items() -> void:
var save := SaveFactory.new_save("test_report", "Lea", "CE1")
save["mission_results"] = {"m1": {"best_stars": 3, "last_stars": 3, "completions": 1}}
save["rewards"] = {"stars": 3, "items": ["a", "b"]}
var report := ProgressReport.build(save, 9, 10)
assert_eq(report["missions_done"], 1)
assert_eq(report["stars"], 3)
assert_eq(report["items_owned"], 2)
func test_skill_with_low_recent_success_needs_work() -> void:
var save := _save_with_skill(
_state("en_acquisition", [false, false, true, false], [true, false, false, false])
)
var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0]
assert_eq(row["attempts"], 4)
assert_eq(row["successes"], 1)
assert_eq(row["hints_used"], 1)
assert_true(row["needs_work"])
func test_skill_with_good_results_does_not_need_work() -> void:
var save := _save_with_skill(_state("en_consolidation", [true, true, false, true], []))
var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0]
assert_false(row["needs_work"])
func test_too_few_attempts_is_not_a_difficulty() -> void:
var save := _save_with_skill(_state("en_acquisition", [false, false], []))
var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0]
assert_false(row["needs_work"])
func test_status_a_reviser_always_needs_work() -> void:
var save := _save_with_skill(_state("a_reviser", [true], []))
var row: Dictionary = ProgressReport.build(save, 9, 10)["skills"][0]
assert_true(row["needs_work"])
@@ -0,0 +1 @@
uid://cf77xbrvbjrf0
+33
View File
@@ -0,0 +1,33 @@
extends GutTest
## Tests d'AdultGate : le calcul est dans les bornes et la reponse est exacte.
func test_challenge_is_within_bounds_and_answer_matches() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 42
for _i in range(50):
var challenge := AdultGate.make_challenge(rng)
assert_between(challenge["a"], AdultGate.MIN_FACTOR_A, AdultGate.MAX_FACTOR_A)
assert_between(challenge["b"], AdultGate.MIN_FACTOR_B, AdultGate.MAX_FACTOR_B)
assert_eq(challenge["answer"], challenge["a"] * challenge["b"])
func test_same_seed_gives_same_challenge() -> void:
var first := RandomNumberGenerator.new()
first.seed = 7
var second := RandomNumberGenerator.new()
second.seed = 7
assert_eq(AdultGate.make_challenge(first), AdultGate.make_challenge(second))
func test_is_correct_accepts_exact_answer_with_spaces() -> void:
var challenge := {"a": 13, "b": 7, "answer": 91}
assert_true(AdultGate.is_correct(challenge, "91"))
assert_true(AdultGate.is_correct(challenge, " 91 "))
func test_is_correct_rejects_wrong_or_non_numeric() -> void:
var challenge := {"a": 13, "b": 7, "answer": 91}
assert_false(AdultGate.is_correct(challenge, "90"))
assert_false(AdultGate.is_correct(challenge, ""))
assert_false(AdultGate.is_correct(challenge, "quatre-vingt-onze"))
+1
View File
@@ -0,0 +1 @@
uid://cv8nfcak28jm8
+91
View File
@@ -0,0 +1,91 @@
extends GutTest
## Tests de SaveTransfer : export, liste, restauration non destructive et
## rejet des fichiers invalides. Le dossier d'export est un dossier de test
## sous user:// ; jamais le dossier Documents de l'utilisateur.
const EXPORT_DIR := "user://test_save_transfer_exports"
const SOURCE_ID := "test_transfer_source"
const RESTORED_ID := "test_transfer_restored"
var schema: Dictionary = {}
func before_each() -> void:
schema = DataLoader.load_schema("save")
_clean()
func after_all() -> void:
_clean()
func _clean() -> void:
SaveIO.delete(SOURCE_ID)
SaveIO.delete(RESTORED_ID)
var dir := DirAccess.open(EXPORT_DIR)
if dir != null:
for file_name in dir.get_files():
dir.remove(file_name)
DirAccess.remove_absolute(EXPORT_DIR)
func _make_source() -> void:
var save := SaveFactory.new_save(SOURCE_ID, "Léa", "CE2")
save["rewards"] = {"stars": 4, "items": ["item_graine_magique"]}
save["mission_results"] = {"m": {"best_stars": 3, "last_stars": 3, "completions": 2}}
SaveIO.write(SOURCE_ID, save)
func test_export_writes_a_readable_file_named_after_the_profile() -> void:
_make_source()
var path := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR)
assert_ne(path, "")
assert_true(path.get_file().begins_with("Léa_CE2"))
var read_back: Variant = SaveTransfer.read_export(path, schema)
assert_not_null(read_back)
assert_eq(read_back["display_name"], "Léa")
func test_second_export_does_not_overwrite_the_first() -> void:
_make_source()
var first := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR)
var second := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR)
assert_ne(first, second)
assert_eq(SaveTransfer.list_exports(EXPORT_DIR, schema).size(), 2)
func test_export_of_missing_profile_fails_cleanly() -> void:
assert_eq(SaveTransfer.export_profile("test_transfer_nobody", schema, EXPORT_DIR), "")
func test_restore_creates_a_new_profile_and_keeps_the_original() -> void:
_make_source()
var path := SaveTransfer.export_profile(SOURCE_ID, schema, EXPORT_DIR)
assert_true(SaveTransfer.import_as_new_profile(path, schema, RESTORED_ID))
var restored: Variant = SaveIO.read(RESTORED_ID, schema)
assert_not_null(restored)
assert_eq(restored["profile_id"], RESTORED_ID)
assert_eq(restored["rewards"]["stars"], 4)
assert_eq(restored["mission_results"]["m"]["completions"], 2)
var original: Variant = SaveIO.read(SOURCE_ID, schema)
assert_eq(original["profile_id"], SOURCE_ID)
func test_invalid_files_are_listed_as_invalid_and_cannot_be_restored() -> void:
DirAccess.make_dir_recursive_absolute(EXPORT_DIR)
var file := FileAccess.open(EXPORT_DIR + "/abime.json", FileAccess.WRITE)
file.store_string("{ pas du json")
file.close()
var entries := SaveTransfer.list_exports(EXPORT_DIR, schema)
assert_eq(entries.size(), 1)
assert_false(entries[0]["valid"])
assert_false(
SaveTransfer.import_as_new_profile(EXPORT_DIR + "/abime.json", schema, RESTORED_ID)
)
assert_null(SaveIO.read(RESTORED_ID, schema))
func test_list_of_missing_directory_is_empty() -> void:
assert_eq(SaveTransfer.list_exports("user://test_transfer_absent_dir", schema).size(), 0)
+1
View File
@@ -0,0 +1 @@
uid://c2qnw22eh66lv
+37
View File
@@ -0,0 +1,37 @@
extends GutTest
## Tests d'AppSettings : valeurs par defaut, nettoyage des fichiers abimes,
## conversions volume/echelle de texte.
func test_defaults_are_usable() -> void:
var settings := AppSettings.defaults()
assert_true(settings["animations_enabled"])
assert_eq(AppSettings.text_scale(settings), 1.0)
func test_sanitize_of_non_dictionary_gives_defaults() -> void:
assert_eq(AppSettings.sanitize("n'importe quoi"), AppSettings.defaults())
assert_eq(AppSettings.sanitize(null), AppSettings.defaults())
func test_sanitize_clamps_out_of_range_values() -> void:
var clean := AppSettings.sanitize(
{"volume": 7.0, "text_scale_index": 99, "animations_enabled": false}
)
assert_eq(clean["volume"], 1.0)
assert_eq(clean["text_scale_index"], AppSettings.TEXT_SCALES.size() - 1)
assert_false(clean["animations_enabled"])
assert_eq(AppSettings.sanitize({"volume": -2})["volume"], 0.0)
func test_sanitize_ignores_wrong_types() -> void:
var clean := AppSettings.sanitize(
{"volume": "fort", "text_scale_index": "grand", "animations_enabled": "oui"}
)
assert_eq(clean, AppSettings.defaults())
func test_zero_volume_is_real_silence() -> void:
assert_eq(AppSettings.volume_db(0.0), -80.0)
assert_eq(AppSettings.volume_db(1.0), 0.0)
assert_lt(AppSettings.volume_db(0.5), 0.0)
+1
View File
@@ -0,0 +1 @@
uid://bln38n5jm0aku
+31
View File
@@ -0,0 +1,31 @@
extends GutTest
## Parcours des scenes de reglages et d'espace adulte : navigation et barriere.
## Ne modifie jamais les reglages reels ni les profils de l'utilisateur.
const SETTINGS_SCENE := preload("res://scenes/ui/settings.tscn")
const ADULT_SCENE := preload("res://scenes/ui/adult_space.tscn")
func test_settings_scene_builds_one_button_per_text_size() -> void:
var scene: Control = add_child_autofree(SETTINGS_SCENE.instantiate())
var box: HBoxContainer = scene.find_child("TextSizeBox", true, false)
assert_eq(box.get_child_count(), AppSettings.TEXT_SCALES.size())
func test_adult_space_hides_content_until_gate_is_solved() -> void:
var scene: Control = add_child_autofree(ADULT_SCENE.instantiate())
var gate: Control = scene.find_child("GateBox", true, false)
var content: Control = scene.find_child("ContentBox", true, false)
assert_true(gate.visible)
assert_false(content.visible)
var input: LineEdit = scene.find_child("GateInput", true, false)
input.text = "0"
scene._on_gate_submitted()
assert_true(gate.visible, "une mauvaise reponse ne doit pas ouvrir l'espace")
assert_false(content.visible)
input.text = str(scene._challenge["answer"])
scene._on_gate_submitted()
assert_false(gate.visible)
assert_true(content.visible)
+1
View File
@@ -0,0 +1 @@
uid://bdv3h6fouqab6