diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index 193c1d2..99ed5eb 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -184,3 +184,15 @@ Ajouts à l'arborescence proposée : **Effet de bord** : `pyright` ne résolvait pas l'import `piper` en ciblant l'environnement Python par défaut plutôt que `.venv/`. Ajout de `pyrightconfig.json` (`venvPath`/`venv`) à la racine pour que `make lint` continue de fonctionner sans option supplémentaire. **Régénéré** : `ce1_accord_adjectif_dictee_01.ogg` avec la nouvelle voix. Non encore réécouté par un humain au moment d'écrire ceci — à confirmer en test manuel que l'intelligibilité est bien résolue, pas seulement supposée meilleure. + +**Confirmation manuelle** : voix jugée parfaitement intelligible par un test humain. Voir le point 21 pour le bug distinct révélé par ce même test. + +## 21. Bug signalé manuellement : dictée correcte refusée (ponctuation finale) + +**Symptôme** : en écoutant la dictée (point 20, voix maintenant intelligible) et en tapant exactement « Les petits chats dorment » tel qu'entendu, la réponse n'était pas validée comme correcte. + +**Cause** : `data/dictations/ce1_accord_adjectif_dictee_01.json` attend le texte avec un point final (`"target_text": "Les petits chats dorment."`), mais aucune voix (provisoire ou future) ne vocalise la ponctuation terminale — rien dans ce que l'enfant entend ne lui indique qu'il faut écrire ce point. `DictationAnalyzer` comparait le texte tel quel : l'absence du point faisait échouer la comparaison exacte, puis l'alignement mot à mot traitait « dorment » et « dorment. » comme deux mots différents (une substitution), signalée comme une erreur non classée. Un test reproduisant exactement l'interaction rapportée (`tests/exercises/test_exercise_host_dictation.gd`) a confirmé le problème avant toute correction. + +**Correction** : `DictationAnalyzer` ignore désormais la ponctuation terminale (`.`, `!`, `?`, `…`) à la comparaison — aussi bien dans le test d'égalité exacte (et celui des variantes acceptées) que dans l'alignement mot à mot, où elle est retirée avant la découpe en mots plutôt qu'après coup. La ponctuation interne à la phrase n'est pas concernée. Quatre tests couvrent : omission du point, point remplacé par un autre signe, et absence de faux négatif (une vraie faute sur le dernier mot reste détectée malgré la tolérance de ponctuation). + +**Leçon** : une tolérance de comparaison doit correspondre à ce que le canal d'entrée (ici l'audio) peut réellement transmettre à l'enfant — exiger une information qu'il n'a pas reçue revient à le punir d'une faute qu'il n'a pas commise (CLAUDE.md, invariants pédagogiques). À revoir si l'audio final fait un jour entendre la ponctuation (pause marquée, voix qui annonce « point »). diff --git a/docs/testing/manual-checklist.md b/docs/testing/manual-checklist.md index 4806a43..e721299 100644 --- a/docs/testing/manual-checklist.md +++ b/docs/testing/manual-checklist.md @@ -14,7 +14,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a 8. Mission « Le pré des petits mots » (choix multiple) : les réponses sont cliquables, une réponse juste affiche « Bravo ! », une réponse fausse affiche « Pas tout à fait » avec un indice. 9. Refaire une erreur sur le même exercice : after la 2e erreur, l'explication remplace l'indice ; après la 3e, la bonne réponse est montrée et l'exercice passe au suivant sans blocage. 10. Mission « Le pont des phrases » (correction de phrase) : toucher un mot le fait apparaître comme sélectionné (état visuel distinct, pas seulement une couleur) ; un champ de saisie apparaît sous le mot sélectionné ; toucher à nouveau le mot fait disparaître le champ. -11. Mission « La clairière de l'écho » (dictée) : à l'arrivée sur l'exercice, la voix de synthèse doit se faire entendre automatiquement ; le bouton « Réécouter » la rejoue sans planter ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. La voix (Piper, point 20 de `decisions.md`) doit être clairement intelligible — une première version avec eSpeak NG a été jugée inintelligible et remplacée ; à reconfirmer à l'oreille que ce n'est plus le cas. +11. Mission « La clairière de l'écho » (dictée) : à l'arrivée sur l'exercice, la voix de synthèse doit se faire entendre automatiquement ; le bouton « Réécouter » la rejoue sans planter ; le badge « Voix provisoire » est visible ; la zone de saisie accepte le texte et les accents. Taper le texte sans point final (rien dans l'audio ne l'indique) doit être accepté comme correct (point 21 de `decisions.md`). 12. Après 3 exercices, écran de récompense avec le nombre d'étoiles, puis retour à la carte. 13. Quitter le jeu puis relancer `make run` : le profil et sa progression doivent être conservés (sélectionner le même profil, vérifier que l'état affiché sur la carte a changé par rapport à l'état initial). 14. Depuis la sélection de profil, supprimer un profil : une confirmation explicite doit apparaître avant la suppression effective ; annuler doit ne rien supprimer. @@ -28,7 +28,7 @@ Tout ce qui suit ne peut pas être vérifié sans écran (rendu, mise en page, a ## Audio -- `ce1_accord_adjectif_dictee_01` a un fichier audio provisoire généré par `make generate-dictation-audio` (voix neuronale Piper, voir `assets/CREDITS.md`). À vérifier à l'oreille : la voix se déclenche à l'arrivée sur l'exercice et au clic sur « Réécouter », le volume est audible, la phrase est clairement comprise (pas seulement devinée). +- `ce1_accord_adjectif_dictee_01` a un fichier audio provisoire généré par `make generate-dictation-audio` (voix neuronale Piper, voir `assets/CREDITS.md`). Testé manuellement : voix claire et bien comprise. - Toute dictée ajoutée sans régénérer son audio se dégrade silencieusement (avertissement en console, pas de plantage) : vérifier qu'aucune dictée manquante ne passe inaperçue avant diffusion (`make generate-dictation-audio` ne régénère pas l'existant, `--force` pour forcer). ## Exports diff --git a/scripts/correction/dictation_analyzer.gd b/scripts/correction/dictation_analyzer.gd index 22ca511..5d45131 100644 --- a/scripts/correction/dictation_analyzer.gd +++ b/scripts/correction/dictation_analyzer.gd @@ -8,6 +8,13 @@ extends RefCounted ## ni hasard. L'explication complete (texte de la regle) est composee par la ## couche d'affichage a partir des rule_id retournes, pas ici. +## Ponctuation de fin de phrase ignoree a la comparaison : l'audio provisoire +## (point 19/20 de docs/specs/decisions.md) ne la fait pas entendre, donc rien +## dans ce que l'enfant ecoute ne lui indique qu'il faut l'ecrire. L'exiger +## serait sanctionner une information qu'il n'a pas recue (CLAUDE.md : une +## erreur declenche une explication, jamais une punition). +const TERMINAL_PUNCTUATION := ".!?…" + static func analyze(dictation: Dictionary, submitted_text: String) -> CorrectionResult: var normalized_submitted := TextNormalizer.normalize(submitted_text) @@ -16,8 +23,10 @@ static func analyze(dictation: Dictionary, submitted_text: String) -> Correction if _matches_target_or_variant(dictation, normalized_submitted, normalized_target): return CorrectionResult.correct(_build_explanation(0)) - var target_words := TextNormalizer.split_words(normalized_target) - var submitted_words := TextNormalizer.split_words(normalized_submitted) + var target_words := TextNormalizer.split_words(_strip_terminal_punctuation(normalized_target)) + var submitted_words := TextNormalizer.split_words( + _strip_terminal_punctuation(normalized_submitted) + ) var words_meta: Array = dictation["words"] # L'alignement se fait insensible a la casse : une majuscule manquante est @@ -74,14 +83,27 @@ static func analyze(dictation: Dictionary, submitted_text: String) -> Correction static func _matches_target_or_variant( dictation: Dictionary, normalized_submitted: String, normalized_target: String ) -> bool: - if normalized_submitted == normalized_target: + var stripped_submitted := _strip_terminal_punctuation(normalized_submitted) + if stripped_submitted == _strip_terminal_punctuation(normalized_target): return true for variant: String in dictation.get("accepted_variants", []): - if normalized_submitted == TextNormalizer.normalize(variant): + if stripped_submitted == _strip_terminal_punctuation(TextNormalizer.normalize(variant)): return true return false +## Retire les signes de ponctuation terminale (un ou plusieurs, ex. "?!") en +## fin de texte. N'affecte pas la ponctuation interne a la phrase. +static func _strip_terminal_punctuation(text: String) -> String: + var result := text + while ( + not result.is_empty() + and TERMINAL_PUNCTUATION.find(result.substr(result.length() - 1)) != -1 + ): + result = result.substr(0, result.length() - 1) + return result.strip_edges() + + static func _classify_error(words_meta: Array, target_index: int, found: String) -> Dictionary: var meta: Dictionary = words_meta[target_index] if target_index < words_meta.size() else {} var expected: String = meta.get("text", "") diff --git a/tests/correction/test_dictation_analyzer.gd b/tests/correction/test_dictation_analyzer.gd index e2d5ac5..238a720 100644 --- a/tests/correction/test_dictation_analyzer.gd +++ b/tests/correction/test_dictation_analyzer.gd @@ -65,3 +65,23 @@ func test_accepted_variant_is_treated_as_correct() -> void: func test_normalizes_before_comparing() -> void: var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorment.") assert_true(result.is_correct) + + +## L'audio provisoire ne fait entendre aucune ponctuation (voir +## docs/specs/decisions.md, point 19/20) : l'omettre n'est pas une erreur. +func test_missing_terminal_punctuation_is_still_correct() -> void: + var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorment") + assert_true(result.is_correct) + + +func test_different_terminal_punctuation_is_still_correct() -> void: + var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorment!") + assert_true(result.is_correct) + + +func test_terminal_punctuation_tolerance_does_not_hide_a_real_error_on_the_last_word() -> void: + var result := DictationAnalyzer.analyze(dictation, "Les petits chats dorme") + assert_false(result.is_correct) + assert_eq(result.errors.size(), 1) + assert_eq(result.errors[0]["found"], "dorme") + assert_eq(result.errors[0]["expected"], "dorment.") diff --git a/tests/exercises/test_exercise_host_dictation.gd b/tests/exercises/test_exercise_host_dictation.gd new file mode 100644 index 0000000..7006765 --- /dev/null +++ b/tests/exercises/test_exercise_host_dictation.gd @@ -0,0 +1,62 @@ +extends GutTest +## Reproduction d'un bug signale manuellement via exercise_host : la dictee +## "Les petits chats dorment." tapee exactement comme entendue n'etait pas +## validee comme correcte. + +var _profile_id: String = "" + + +func before_each() -> void: + _profile_id = ProfileManager.create_profile("Test", "CE1") + ProfileManager.select_profile(_profile_id) + GameManager.pending_mission_id = "mission_03_clairiere_echo" + + +func after_each() -> void: + ProfileManager.delete_profile(_profile_id) + GameManager.pending_mission_id = "" + + +func test_exact_target_text_is_validated_as_correct() -> void: + var host: Control = await _ready_host() + var exercise_scene: Control = host.get_node("%ExerciseContainer").get_child(0) + var exercise: Dictionary = host.get_current_exercise() + assert_eq(exercise["target_text"], "Les petits chats dorment.") + + (exercise_scene.get_node("%TextEdit") as LineEdit).text = exercise["target_text"] + exercise_scene.get_node("%ValidateButton").pressed.emit() + await wait_process_frames(1) + + assert_eq(_feedback_title(host), UIText.get_text("feedback_panel.correct_title")) + + +## "J'entends parfaitement mais ne valide pas" : l'audio ne fait entendre +## aucune ponctuation, donc l'enfant qui ecrit exactement ce qu'il entend +## (sans le point final) doit tout de meme etre valide. +func test_missing_final_period_is_still_validated_as_correct() -> void: + var host: Control = await _ready_host() + var exercise_scene: Control = host.get_node("%ExerciseContainer").get_child(0) + + (exercise_scene.get_node("%TextEdit") as LineEdit).text = "Les petits chats dorment" + exercise_scene.get_node("%ValidateButton").pressed.emit() + await wait_process_frames(1) + + assert_eq(_feedback_title(host), UIText.get_text("feedback_panel.correct_title")) + + +func _ready_host() -> Control: + var host: Control = load("res://scenes/exercises/exercise_host.tscn").instantiate() + add_child_autofree(host) + await wait_process_frames(1) + + var overlay: Control = host.get_node("%OverlayContainer") + overlay.get_child(0).continued.emit() + await wait_process_frames(1) + return host + + +func _feedback_title(host: Control) -> String: + var overlay: Control = host.get_node("%OverlayContainer") + var feedback: Control = overlay.get_child(overlay.get_child_count() - 1) + var title_label: Label = feedback.get_node("%TitleLabel") + return title_label.text diff --git a/tests/exercises/test_exercise_host_dictation.gd.uid b/tests/exercises/test_exercise_host_dictation.gd.uid new file mode 100644 index 0000000..84d2a9e --- /dev/null +++ b/tests/exercises/test_exercise_host_dictation.gd.uid @@ -0,0 +1 @@ +uid://dcas0r0cf6k7u