From 0c0c4bbf4d5aad1d55df98ed79f4faf586ae8643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ange-Marie=20MAURIN=20-=20A2M=20Syst=C3=A8mes?= Date: Sat, 7 Nov 2026 20:50:17 +0100 Subject: [PATCH] docs(tutoriels): tutoriel 02, construire une brique graphique (panneau Simulation) Six parties a faire soi-meme, du contrat session/step au composant Studio. Tutoriel 01 : version 0.2.0 du protocole de rendu. --- .../01-panneau-studio-de-bout-en-bout.md | 2 +- .../02-brique-graphique/01-le-contrat.md | 100 +++ .../02-brique-graphique/02-cote-cpp.md | 329 ++++++++++ .../03-cote-typescript-protocole.md | 621 ++++++++++++++++++ .../04-etat-et-reduction.md | 207 ++++++ .../02-brique-graphique/05-le-composant.md | 445 +++++++++++++ .../06-brancher-et-essayer.md | 154 +++++ docs/tutoriels/02-brique-graphique/README.md | 93 +++ docs/tutoriels/README.md | 3 +- 9 files changed, 1952 insertions(+), 2 deletions(-) create mode 100644 docs/tutoriels/02-brique-graphique/01-le-contrat.md create mode 100644 docs/tutoriels/02-brique-graphique/02-cote-cpp.md create mode 100644 docs/tutoriels/02-brique-graphique/03-cote-typescript-protocole.md create mode 100644 docs/tutoriels/02-brique-graphique/04-etat-et-reduction.md create mode 100644 docs/tutoriels/02-brique-graphique/05-le-composant.md create mode 100644 docs/tutoriels/02-brique-graphique/06-brancher-et-essayer.md create mode 100644 docs/tutoriels/02-brique-graphique/README.md diff --git a/docs/tutoriels/01-panneau-studio-de-bout-en-bout.md b/docs/tutoriels/01-panneau-studio-de-bout-en-bout.md index 82abbeb..4068a54 100644 --- a/docs/tutoriels/01-panneau-studio-de-bout-en-bout.md +++ b/docs/tutoriels/01-panneau-studio-de-bout-en-bout.md @@ -64,7 +64,7 @@ sequenceDiagram UI->>Core: render/start {adapter:"godot"} (id "7") Core->>R: lance le processus (lié au Core) Core-->>UI: render/start {state:"starting", pid…} (id "7") - R->>Core: hello {client:"sequa-render", protocolVersion:"0.1.0"} + R->>Core: hello {client:"sequa-render", protocolVersion:"0.2.0"} Core-->>R: welcome {renderId…} R->>A: sequa_create_engine_adapter(version, options) R->>A: start(config) (Godot démarre, fenêtre) diff --git a/docs/tutoriels/02-brique-graphique/01-le-contrat.md b/docs/tutoriels/02-brique-graphique/01-le-contrat.md new file mode 100644 index 0000000..1f46ce5 --- /dev/null +++ b/docs/tutoriels/02-brique-graphique/01-le-contrat.md @@ -0,0 +1,100 @@ +# Partie 1 : le contrat + +[← Sommaire](README.md) · [Partie 2 →](02-cote-cpp.md) + +**But** : écrire noir sur blanc ce que Studio et le Core vont s'échanger, **avant** toute ligne de code. À la fin de cette partie, tu as modifié un seul fichier : la spécification. + +--- + +## 1.1 Pourquoi commencer par le texte + +Deux personnes, ou une personne à deux moments différents, vont coder chacune un bout : le Core en C++, Studio en TypeScript. Si chacune « devine » la forme du message, elles ne se comprendront pas. Le texte de la spécification sert d'**arbitre** : en cas de désaccord, on corrige le texte d'abord, puis le code des deux côtés. + +Analogie : avant de construire un pont depuis les deux rives, on se met d'accord sur le point de rencontre au milieu. + +## 1.2 Ce qui existe déjà + +Ouvre [`docs/specs/protocole-studio-core-0.2.md`](../../specs/protocole-studio-core-0.2.md) et lis : + +- **§2** : l'enveloppe `{ v, type, id, payload }`, et la règle des entiers 64 bits envoyés en hexadécimal ; +- **§4** : le tableau des requêtes. Repère les lignes `session/pause`, `session/resume`, `session/status`, et la définition de l'**état de session** juste en dessous ; +- **§5** : la session en direct (60 Hz, verrou, enregistrement des entrées). + +**À toi** : réponds à ces trois questions en lisant (réponses en bas de page). + +1. Quel `type` porte la réponse à une requête `session/pause` ? +2. Pourquoi `seed` et `stateHash` sont-ils des chaînes, alors que `tick` est un nombre ? +3. Si Studio envoie une requête avec `"id": "42"` et que le payload est invalide, que reçoit-il ? + +## 1.3 Le nouveau message : `session/step` + +Voici le besoin, en langage courant : + +> Quand la simulation est en pause, je veux pouvoir la faire avancer d'un tick (ou de quelques ticks) pour observer ce qui se passe image par image. Ces ticks doivent être enregistrés comme les autres, pour que le rejeu reste exact. + +Les décisions de forme sont déjà prises pour toi (elles conditionnent les tests des parties suivantes) : + +| Question | Décision | +|---|---| +| Nom | `session/step` | +| Payload envoyé | `{ ticks?: int }` ; absent → **1** ; borné à **[1, 600]** (600 = 10 s à 60 Hz) | +| Réponse | l'**état de session** habituel (même forme que `session/status`) | +| Session en marche | refus : `error` avec le code **`session-running`**, et rien n'est exécuté | +| Payload invalide | `error` `invalid-payload` (comme toutes les requêtes, §3) | +| Entrée utilisée | l'entrée **maintenue** (§4, `session/input`) : un tick pas à pas est un tick comme un autre | +| Enregistrement | chaque tick exécuté est enregistré et a son empreinte : `session/export` et le rejeu le voient | +| Rendu | le monde est projeté vers le rendu après chaque tick, comme en marche normale | + +Pourquoi refuser en marche plutôt qu'avancer quand même ? Parce qu'en marche, le fil de simulation avance déjà tout seul 60 fois par seconde. « Avancer d'un tick » n'aurait aucun sens visible et rendrait la réponse trompeuse. + +### À toi : écrire la spécification + +Dans `docs/specs/protocole-studio-core-0.2.md` : + +1. Ajoute une ligne `session/step` au tableau du §4, après `session/status`, dans le même style que les autres lignes : payload envoyé, payload de la réponse, erreurs, bornes. +2. Ajoute une phrase sur le comportement : en pause uniquement, entrée maintenue, ticks enregistrés. +3. Mets à jour la ligne de version du §2 selon ta décision du 1.4 ci-dessous. + +
+Comparer avec une rédaction possible (après avoir écrit la tienne) + +```markdown +| `session/step` | `{ ticks?: int }` | état de session. `ticks` ∈ [1, 600], 1 par défaut. Exécute aussitôt `ticks` ticks avec l'entrée maintenue, **seulement en pause** : session en marche → `error` `session-running`, rien n'est exécuté. Les ticks sont enregistrés comme les autres (rejeu exact) et projetés vers le rendu. | +``` + +
+ +## 1.4 Décision : faut-il changer la version du protocole ? + +C'est **ta** décision : elle engage la règle de compatibilité. Voici ce qu'il faut savoir. + +La règle actuelle (§2) : deux programmes se comprennent s'ils ont la même majeure et, tant qu'on est en `0.x`, la **même mineure**. Studio en `0.3.x` et un Core en `0.3.y` se connectent ; en `0.4` contre `0.3`, la poignée de main est refusée. + +| Option | Ce qui change | Effet si Studio est plus récent que le Core | +|---|---|---| +| **A. `0.4.0`** | `PROTOCOL_VERSION` (Studio), `protocol_version` (Core **et** Studio host, `studio/host/client/include/sequa/studio/host/client.hpp`), et une dizaine de tests qui écrivent `0.3.0` en dur | Connexion refusée : « Version de protocole incompatible » | +| **B. `0.3.1`** | les mêmes constantes, mais les tests de compatibilité `0.3.x` restent vrais | Connexion acceptée ; un clic sur « Avancer » reçoit `unknown-type`, affiché dans le panneau | + +**Ma recommandation : B.** Un message en plus ne change le sens d'aucun message existant : un ancien Studio ne l'envoie jamais, et un ancien Core répond proprement `unknown-type` (§3). Mais B introduit une règle qui n'est écrite nulle part (« ajouter un message = version corrective »). Si tu la retiens, écris-la au §2 en une phrase. Si tu préfères garder la règle stricte d'aujourd'hui, prends A et fais-le dans un commit séparé, pour ne pas noyer le tutoriel sous les changements de version. + +Dans les deux cas, cette décision ne mérite pas une ADR à elle seule. Une phrase dans la spécification suffit, tant qu'aucune règle n'est inventée en silence. + +## 1.5 Vérifier + +- `git diff docs/specs` ne montre que ta ligne, ta phrase et la version. +- Relis le tableau comme si tu étais **l'autre côté** : avec ce texte seul, saurais-tu coder le Core ? Et Studio ? + +## Réponses du 1.2 + +
+Déplier + +1. `session/pause` : une réponse porte **le même type** que la requête (§4), avec l'`id` recopié. +2. JavaScript représente tous les nombres en flottant 64 bits : au-delà de 2^53, il perd des chiffres. Une graine ou une empreinte utilise les 64 bits, donc elle voyage en texte. Un tick à 60 Hz n'atteindra 2^53 qu'après des millions d'années : un nombre JSON suffit. +3. Un message `error` avec `"id": "42"` et le code `invalid-payload`, **sans fermeture** de la connexion (§3). + +
+ +--- + +[← Sommaire](README.md) · [Partie 2 : côté C++ →](02-cote-cpp.md) diff --git a/docs/tutoriels/02-brique-graphique/02-cote-cpp.md b/docs/tutoriels/02-brique-graphique/02-cote-cpp.md new file mode 100644 index 0000000..6a3e4ce --- /dev/null +++ b/docs/tutoriels/02-brique-graphique/02-cote-cpp.md @@ -0,0 +1,329 @@ +# Partie 2 : côté C++, le Core + +[← Partie 1](01-le-contrat.md) · [Sommaire](README.md) · [Partie 3 →](03-cote-typescript-protocole.md) + +**But** : le Core comprend `session/step`. À la fin, `ctest` passe avec quatre tests nouveaux, et tu sais où vit chaque responsabilité dans le Core. + +--- + +## 2.0 Le trajet d'une requête dans le Core + +Avant de coder, suis le chemin d'un `session/pause` qui existe déjà. Ouvre les fichiers dans cet ordre : + +```text +WebSocket (un fil par connexion) + │ texte JSON + ▼ +ProtocolSession::on_text core/src/server/protocol.cpp ← seul endroit qui voit du JSON + │ appel C++ typé : services_.session_pause() + ▼ +CoreServices (interface) core/include/sequa/core/server/services.hpp + │ implémentée par + ▼ +CoreRuntime::session_pause core/src/server/runtime.cpp ← porte la session, le bail, le rendu + │ + ▼ +LiveSession::pause core/src/server/live_session.cpp ← la simulation, sous verrou +``` + +Chaque couche a un seul métier : + +| Couche | Son métier | Elle ne fait jamais… | +|---|---|---| +| `protocol.cpp` | Lire le JSON, valider (types, bornes), appeler le service, écrire la réponse JSON | …de logique de simulation | +| `CoreServices` | Dire *ce que* le Core sait faire, en types C++ | …de JSON | +| `CoreRuntime` | Assembler : la session, le bail, le rendu | …de validation de format | +| `LiveSession` | Faire avancer la simulation, sans jamais se marcher dessus entre fils | …de réseau | + +Grâce à l'interface du milieu, on teste `protocol.cpp` **sans réseau ni simulation**, avec un faux `CoreServices` (`FakeServices` dans `core/tests/server/test_protocol.cpp`). Analogie : on teste le guichet d'une banque avec un faux coffre-fort. + +On va coder **de bas en haut** : d'abord la simulation, puis l'interface, puis le guichet. + +--- + +## 2.1 `LiveSession::step_paused` + +### Pourquoi + +`LiveSession` a déjà `advance(ticks)` : il exécute des ticks **même en marche** (il sert aux tests). Il nous faut une variante qui vérifie d'abord la pause. + +### Piège : « vérifier puis agir » + +La tentation : + +```cpp +// NON : ne fais pas ça +if (!session_.status().running) { + return session_.advance(ticks); +} +``` + +Entre `status()` et `advance()`, le verrou est **relâché**. Une autre connexion (un autre onglet de Studio, Studio host) peut envoyer `session/resume` pile entre les deux. Tu avancerais alors une session en marche. Ce bug n'apparaît qu'une fois sur un million, donc jamais en test. + +Analogie : tu vérifies que la porte est fermée, tu tournes le dos pour prendre la clé, quelqu'un l'ouvre. Il faut **vérifier et agir en tenant la clé**, c'est-à-dire sous le même verrou. + +### Le contrat + +Dans `core/include/sequa/core/server/live_session.hpp`, à côté de `advance` : + +```cpp + // Exécute `ticks` ticks immédiatement avec l'entrée maintenue, SEULEMENT en pause + // (session/step). La vérification et l'exécution se font sous le même verrou. + // std::nullopt si la session est en marche : rien n'est exécuté. + [[nodiscard]] std::optional step_paused(std::uint64_t ticks); +``` + +### Tests + +Colle ces deux tests à la fin de `core/tests/server/test_live_session.cpp` : + +```cpp +TEST_CASE("Session : step_paused avance en pause et refuse en marche") +{ + LiveSession session(manual()); + CHECK_FALSE(session.step_paused(1).has_value()); // en marche au départ + CHECK(session.status().tick == 0); + + (void)session.pause(); + const auto stepped = session.step_paused(3); + REQUIRE(stepped.has_value()); + CHECK(stepped->tick == 3); + CHECK_FALSE(stepped->running); + CHECK(session.export_session().hashes.size() == 3); // une empreinte par tick +} + +TEST_CASE("Session : des ticks pas a pas se rejouent a l'identique") +{ + LiveSession stepped(manual()); + (void)stepped.pause(); + (void)stepped.set_input(0, 32767, 0); + for (int i = 0; i < 30; ++i) { + REQUIRE(stepped.step_paused(1).has_value()); + } + + LiveSession straight(manual()); + (void)straight.set_input(0, 32767, 0); + straight.advance(30); + CHECK(stepped.status().state_hash == straight.status().state_hash); + + LiveSession copy(manual(999)); + const auto imported = copy.import_session(stepped.export_session().recording, false); + REQUIRE(imported.has_value()); + CHECK(imported->state.state_hash == stepped.status().state_hash); +} +``` + +Le second test est le plus important du tutoriel. Il prouve que 30 ticks joués un par un donnent **exactement** le même monde que 30 ticks d'affilée, et que l'enregistrement se rejoue. C'est la promesse du §5.3. + +Convention du dépôt : les noms de `TEST_CASE` sont **sans accents**. Ils servent de filtre à `ctest -R` et doivent rester simples à taper. + +### À toi + +Implémente `step_paused` dans `core/src/server/live_session.cpp`. + +
+Indice 1 + +Regarde `advance` : il prend le verrou, appelle `step_locked()` en boucle, renvoie `view_locked()`. Il te manque une seule condition. + +
+ +
+Indice 2 + +Le drapeau à lire est `state_->running`, **après** avoir pris `state_->mutex`. Retourne `std::nullopt` avant la boucle s'il est vrai. + +
+ +--- + +## 2.2 L'interface `CoreServices` + +### Pourquoi + +`protocol.cpp` ne connaît pas `LiveSession`, seulement l'interface `CoreServices`. Il faut donc y déclarer la nouvelle capacité. + +### Le contrat + +Dans `core/include/sequa/core/server/services.hpp`, après `session_status()` : + +```cpp + // session/step : exécute `ticks` ticks tout de suite, seulement en pause. + // std::nullopt si la session est en marche (erreur session-running). + [[nodiscard]] virtual std::optional session_step(std::uint64_t ticks) = 0; +``` + +### Choix : virtuelle pure ou avec implémentation par défaut ? + +Les méthodes du rendu (`render_start`…) ont une implémentation par défaut « pour ne pas casser les faux services existants ». Les méthodes de session sont **virtuelles pures** (`= 0`). On suit la famille : `= 0`. + +L'avantage : le compilateur te dit **qui** doit implémenter. Compile maintenant, sans rien écrire d'autre : + +```powershell +.\scripts\build.ps1 -Preset windows-msvc-debug -NoTests +``` + +Tu dois obtenir des erreurs du genre *« C2259 : 'CoreRuntime' : impossible d'instancier une classe abstraite »* et la même chose pour `FakeServices`. Ce sont tes deux prochaines étapes. Utiliser le compilateur comme une **liste de tâches** est une habitude à prendre. + +--- + +## 2.3 `CoreRuntime::session_step` + +### À toi + +1. Déclare l'`override` dans `core/include/sequa/core/server/runtime.hpp`, avec les autres méthodes de session. +2. Implémente-la dans `core/src/server/runtime.cpp`, à côté de `session_status()`. + +C'est une ligne. Les autres méthodes de session ne font que déléguer à `session_`, celle-ci aussi. + +### Et le rendu ? + +Tu n'as rien à faire. Regarde le constructeur de `CoreRuntime` : il branche un **observateur du monde** sur la session (`set_world_observer`). `step_locked()` l'appelle après chaque tick. Les ticks pas à pas partent donc vers le rendu comme les autres. C'est l'intérêt d'avoir réutilisé `step_locked()` plutôt que d'appeler la simulation directement. + +--- + +## 2.4 Le guichet : `protocol.cpp` + +### Pourquoi + +C'est ici que le JSON devient du C++, et que les règles de la spécification (bornes, défaut, erreurs) sont appliquées. **Toute** validation de format se fait ici, jamais dans le runtime. + +### Tests + +D'abord le faux coffre-fort. Dans `core/tests/server/test_protocol.cpp`, ajoute à `FakeServices`, après `session_status()` : + +```cpp + std::optional session_step(std::uint64_t ticks) override + { + last_step_ticks = ticks; + if (state.running) { + return std::nullopt; + } + state.tick += ticks; + return state; + } +``` + +et, avec les autres champs en bas de la classe : + +```cpp + std::uint64_t last_step_ticks = 0; +``` + +Puis le test, après `"Protocole : session/input et ses bornes"` : + +```cpp +TEST_CASE("Protocole : session/step en pause, bornes et refus en marche") +{ + FakeServices services; + auto session = ready_session(services); + + // Le faux démarre en marche : refus, id recopié, pas de fermeture. + const json refused = request(session, "session/step"); + CHECK(refused["type"] == "error"); + CHECK(refused["id"] == "r1"); + CHECK(refused["payload"]["code"] == "session-running"); + + services.state.running = false; + const json one = request(session, "session/step"); + CHECK(one["type"] == "session/step"); + CHECK(services.last_step_ticks == 1); // valeur par défaut + CHECK(one["payload"]["tick"] == 11); + CHECK(one["payload"]["running"] == false); + CHECK(one["payload"]["stateHash"] == "00000000000000ff"); + + CHECK(request(session, "session/step", {{"ticks", 600}})["payload"]["tick"] == 611); + CHECK(request(session, "session/step", {{"ticks", 0}})["payload"]["code"] == "invalid-payload"); + CHECK(request(session, "session/step", {{"ticks", 601}})["payload"]["code"] == "invalid-payload"); + CHECK(request(session, "session/step", {{"ticks", -1}})["payload"]["code"] == "invalid-payload"); + CHECK(request(session, "session/step", {{"ticks", 1.5}})["payload"]["code"] == "invalid-payload"); + CHECK(request(session, "session/step", {{"ticks", "3"}})["payload"]["code"] == "invalid-payload"); + CHECK(services.state.tick == 611); // les requêtes invalides n'ont rien exécuté +} +``` + +Lis les helpers en haut du fichier : `ready_session` fait la poignée de main, `request` envoie une requête avec l'id `"r1"` et vérifie qu'elle ne ferme pas la connexion. + +### À toi + +Dans `ProtocolSession::on_text`, ajoute le traitement de `session/step` dans le bloc des messages de session, après `session/status`. + +Ce que ton code doit faire, dans l'ordre : + +1. `ticks` **absent** → 1 ; **présent** → entier dans [1, 600], sinon `invalid-payload` ; +2. appeler `services_.session_step(ticks)` ; +3. `std::nullopt` → `error("session-running", "…")` avec un message en français qui dit quoi faire ; +4. sinon → `respond(session_json(*état))`. + +
+Indice 1 : les outils déjà là + +En haut du fichier, dans l'espace de noms anonyme : `integer_in(payload, name, low, high)` lit un entier borné (et refuse `1.5`, `"3"`, les nombres trop grands). Dans `parse_render_start_request`, la petite lambda `optional_uint` montre le motif « absent → défaut ; présent → bornes vérifiées ». + +
+ +
+Indice 2 : la forme + +```cpp + if (type == "session/step") { + std::uint64_t ticks = 1; + if (payload.contains("ticks")) { + const auto value = /* … integer_in … */; + if (!value) { + return bad_payload(/* … message qui rappelle la borne … */); + } + ticks = /* … */; + } + // … appel du service, session-running ou respond(…) + } +``` + +Pense à nommer la borne (`constexpr std::int64_t max_step_ticks = 600;`) plutôt que d'écrire `600` deux fois. + +
+ +### Piège : l'ordre des vérifications + +Valide le payload **entièrement**, puis seulement ensuite appelle le service. Un service appelé avec une valeur non vérifiée fait des dégâts : `-1` lu puis converti en `std::uint64_t` devient 18 446 744 073 709 551 615, et le Core se figerait à exécuter ces ticks sous verrou. La dernière ligne du test vérifie que les requêtes refusées n'ont rien exécuté. + +--- + +## 2.5 Vérifier + +```powershell +# Compiler et ne lancer que les tests concernés +.\scripts\build.ps1 -Preset windows-msvc-debug -BuildDir build\windows-msvc-debug -TestRegex "Session|Protocole" + +# Puis tout, pour être sûr de n'avoir rien cassé ailleurs +.\scripts\build.ps1 -Preset windows-msvc-debug +``` + +Attendu : tous les tests au vert, dont tes trois nouveaux `TEST_CASE`. + +### Essai à la main (facultatif, sans Studio) + +Si tu as un client WebSocket en ligne de commande (par exemple `websocat`) : + +```text +→ {"v":1,"type":"hello","payload":{"protocolVersion":"0.3.0","client":"sequa-studio"}} +← welcome … +→ {"v":1,"type":"session/step","id":"a","payload":{}} +← {"v":1,"type":"error","id":"a","payload":{"code":"session-running", …}} +→ {"v":1,"type":"session/pause","id":"b","payload":{}} +→ {"v":1,"type":"session/step","id":"c","payload":{"ticks":5}} +← {"v":1,"type":"session/step","id":"c","payload":{"tick": …+5, "running":false, …}} +``` + +Adapte `protocolVersion` à ta décision de la [partie 1, §1.4](01-le-contrat.md). + +## Ce que tu as appris + +- Le trajet JSON → `ProtocolSession` → `CoreServices` → `CoreRuntime` → `LiveSession`, et le métier de chaque couche. +- Pourquoi « vérifier puis agir » doit se faire sous un seul verrou. +- Se servir du compilateur comme liste de tâches (classe abstraite). +- Valider avant d'agir, et tester les bornes **des deux côtés** (0, 1, 600, 601, -1, 1.5, texte). + +--- + +[← Partie 1](01-le-contrat.md) · [Sommaire](README.md) · [Partie 3 : côté TypeScript →](03-cote-typescript-protocole.md) diff --git a/docs/tutoriels/02-brique-graphique/03-cote-typescript-protocole.md b/docs/tutoriels/02-brique-graphique/03-cote-typescript-protocole.md new file mode 100644 index 0000000..6754d80 --- /dev/null +++ b/docs/tutoriels/02-brique-graphique/03-cote-typescript-protocole.md @@ -0,0 +1,621 @@ +# Partie 3 : côté TypeScript, le protocole + +[← Partie 2](02-cote-cpp.md) · [Sommaire](README.md) · [Partie 4 →](04-etat-et-reduction.md) + +**But** : Studio sait envoyer les requêtes de session et comprendre les réponses, avec trois « Core » interchangeables : la connexion simulée, le faux Core (`mock-core.mjs`) et le vrai. Aucune ligne d'interface graphique dans cette partie. + +Tout se passe dans `studio/ui/`. Ouvre un terminal dans ce dossier. + +--- + +## 3.0 La carte côté Studio + +```text + ┌──────────────────────────── src/protocol/ ───────────────────────────┐ + composant ─▶ action ─▶ CoreConnection (interface) │ + │ ├─ SimulatedCoreConnection connection.ts : aucun réseau, SIMULÉ │ + │ └─ WebSocketCoreConnection websocket.ts : vrai socket │ + │ │ encode / decode messages.ts : SEUL fichier qui │ + │ ▼ connaît le JSON │ + └──────────── WebSocket ───────────────────────────────────────────────┘ + │ + faux Core (tools/mock-core.mjs) ou vrai Core (sequa-core) + + réponse ─▶ événement StudioEvent ─▶ reduce (partie 4) ─▶ composant.update (partie 5) +``` + +La règle d'or de cette couche : **le JSON ne sort jamais de `src/protocol/`**. Le reste de Studio ne voit que des types TypeScript (`SessionState`), jamais `JSON.parse`. Le jour où le format provisoire JSON sera remplacé par Protobuf ou FlatBuffers ([ADR 0005](../../adr/0005-serveur-websocket-du-core.md)), seul ce dossier changera. + +Astuce pour toute cette partie : `npm run typecheck` vérifie les types de **tout** le projet, alors que `npx vitest run tests/` lance **un** fichier de tests sans vérifier les types. Tant que la partie 3 n'est pas finie, le typecheck échouera forcément quelque part. Lance les tests fichier par fichier. + +--- + +## 3.1 Les types : `src/model/session.ts` + +### Pourquoi + +C'est le **contrat côté Studio** : la forme décodée de l'état de session. Le protocole le produit, l'état le stocke, le composant l'affiche. Il est dans `model/` et non dans `protocol/` parce que tout Studio le partage. + +### Le contrat (à recopier tel quel) + +Crée `src/model/session.ts` : + +```ts +// SPDX-License-Identifier: MPL-2.0 + +/** + * Session de simulation vue par Studio (docs/specs/protocole-studio-core-0.2.md §4, §5). + * + * Forme décodée des messages JSON provisoires session/status, session/pause, + * session/resume et session/step. + */ + +/** Borne haute de session/step (§4) : 10 s à 60 Hz. */ +export const MAX_STEP_TICKS = 600; + +/** État de session renvoyé par le Core. */ +export interface SessionState { + /** Graine, entier 64 bits en hexadécimal (16 caractères minuscules, §2). */ + readonly seed: string; + readonly tickRateHz: number; + /** Nombre de ticks exécutés depuis le début de la session. */ + readonly tick: number; + /** Empreinte de l'état après le dernier tick, en hexadécimal (16 caractères). */ + readonly stateHash: string; + readonly running: boolean; + /** Entrées non neutres enregistrées. */ + readonly inputCount: number; +} + +/** Tranche « session » de l'état de Studio. */ +export interface SessionView { + /** Dernier état connu ; null tant qu'aucune réponse n'est arrivée. */ + readonly status: SessionState | null; + /** Une requête pause, resume ou step attend sa réponse. */ + readonly pending: boolean; + /** Dernière erreur renvoyée (code et message), effacée au succès suivant. */ + readonly lastError?: { readonly code: string; readonly message: string }; +} + +/** Événements de la tranche session, réduits par `reduce` (src/model/state.ts). */ +export type SessionEvent = + | { readonly type: 'session/requested' } + | { readonly type: 'session/status'; readonly status: SessionState } + | { readonly type: 'session/error'; readonly code: string; readonly message: string }; + +export function createInitialSessionView(): SessionView { + return { status: null, pending: false }; +} +``` + +Compare-le à `src/model/render.ts` : c'est la même construction (un état reçu, une vue avec `pending` et `lastError`, trois événements). Garder la même forme d'une tranche à l'autre rend le code prévisible. + +**Question** : pourquoi `tick` est-il un `number` alors que `seed` est une `string` ? (Réponse dans la [partie 1](01-le-contrat.md#réponses-du-12).) + +### Faire connaître les événements à Studio + +Les connexions vont émettre ces événements : ils doivent faire partie de `StudioEvent`. Dans `src/model/state.ts`, importe `SessionEvent` et ajoute-le à l'union : + +```ts +export type StudioEvent = + | { readonly type: 'core/connecting' } + | { readonly type: 'core/connected'; readonly coreVersion: string } + | { readonly type: 'core/disconnected'; readonly reason?: string } + | RenderEvent + | SessionEvent; +``` + +Lance `npm run typecheck`. Une erreur apparaît aussitôt à la fin de `reduce`, du genre *« Type '{ readonly type: "session/requested"; } | …' is not assignable to type 'never' »*. C'est voulu : la ligne `const unreachable: never = event;` oblige `reduce` à traiter **chaque** type d'événement. Laisse cette erreur, tu la régleras en partie 4. Ce sera la seule erreur de typecheck attendue d'ici là. + +--- + +## 3.2 Encoder et décoder : `src/protocol/messages.ts` + +### Pourquoi + +`decode` est la **douane** : tout ce qui arrive du réseau est suspect. Un champ manquant, un `tick` à `-1`, une graine en majuscules : on refuse avec une erreur typée, **sans jamais lever d'exception**. Ensuite, le reste de Studio peut faire confiance aux types. + +### Le contrat + +Ajoute ces types dans `messages.ts`, à côté des messages du rendu, et ajoute les trois interfaces à l'union `StudioMessage` : + +```ts +/** Requêtes de session (§4) ; la réponse porte le MÊME type. */ +export type SessionRequestType = 'session/status' | 'session/pause' | 'session/resume' | 'session/step'; + +/** session/status, session/pause, session/resume : payload vide. */ +export interface SessionSimpleRequestMessage { + readonly type: Exclude; + readonly id?: string; + readonly payload: Record; +} + +/** session/step : `ticks` facultatif dans [1, MAX_STEP_TICKS] (le Core applique 1 par défaut). */ +export interface SessionStepMessage { + readonly type: 'session/step'; + readonly id?: string; + readonly payload: { readonly ticks?: number }; +} + +/** Réponse à une requête de session : même type, payload = état de session. */ +export interface SessionStateReplyMessage { + readonly type: SessionRequestType; + readonly id?: string; + readonly payload: SessionState; +} +``` + +Règles de décodage : + +| Cas | Règle | +|---|---| +| Réponse ou requête ? | Même `type` dans les deux sens (§4). Un payload qui contient `tick` est une **réponse** ; sinon c'est une **requête** | +| Réponse | `seed` et `stateHash` : 16 caractères `[0-9a-f]` ; `tickRateHz` : entier > 0 ; `tick`, `inputCount` : entiers ≥ 0 ; `running` : booléen | +| Requête `status` / `pause` / `resume` | payload vide `{}` | +| Requête `step` | `ticks` absent, ou entier dans [1, `MAX_STEP_TICKS`] | +| Tout le reste | `{ ok: false, error: { code: 'invalid-payload', … } }` | + +### Tests + +Crée `tests/session-messages.test.ts` : + +```ts +// SPDX-License-Identifier: MPL-2.0 + +import { describe, expect, it } from 'vitest'; +import type { SessionState } from '../src/model/session'; +import { decode, encode } from '../src/protocol/messages'; +import type { StudioMessage } from '../src/protocol/messages'; + +const STATE: SessionState = { + seed: '000000000000002a', + tickRateHz: 60, + tick: 10, + stateHash: '00000000000000ff', + running: false, + inputCount: 3, +}; + +function envelope(type: string, payload: unknown, id = '7'): string { + return JSON.stringify({ v: 1, type, id, payload }); +} + +describe('messages de session', () => { + it('décode une réponse session/step', () => { + expect(decode(envelope('session/step', STATE))).toEqual({ + ok: true, + message: { type: 'session/step', id: '7', payload: STATE }, + }); + }); + + it.each(['session/status', 'session/pause', 'session/resume'])('décode une réponse %s', (type) => { + expect(decode(envelope(type, STATE))).toEqual({ ok: true, message: { type, id: '7', payload: STATE } }); + }); + + it('refuse un état de session mal formé', () => { + const broken: unknown[] = [ + { ...STATE, tick: -1 }, + { ...STATE, tick: 1.5 }, + { ...STATE, seed: '2a' }, + { ...STATE, stateHash: 'FFFFFFFFFFFFFFFF' }, + { ...STATE, running: 'non' }, + { ...STATE, tickRateHz: 0 }, + { ...STATE, inputCount: undefined }, + ]; + + for (const payload of broken) { + const result = decode(envelope('session/status', payload)); + expect(result.ok, JSON.stringify(payload)).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('invalid-payload'); + } + } + }); + + it('encode puis décode les requêtes (aller-retour)', () => { + const requests: StudioMessage[] = [ + { type: 'session/status', id: '1', payload: {} }, + { type: 'session/pause', id: '2', payload: {} }, + { type: 'session/resume', id: '3', payload: {} }, + { type: 'session/step', id: '4', payload: {} }, + { type: 'session/step', id: '5', payload: { ticks: 600 } }, + ]; + + for (const message of requests) { + expect(decode(encode(message))).toEqual({ ok: true, message }); + } + }); + + it('refuse un nombre de ticks hors bornes', () => { + for (const ticks of [0, 601, 1.5, '3']) { + expect(decode(envelope('session/step', { ticks })).ok).toBe(false); + } + }); + + it('refuse une requête pause avec un payload non vide', () => { + expect(decode(envelope('session/pause', { force: true })).ok).toBe(false); + }); +}); +``` + +Lance-le : `npx vitest run tests/session-messages.test.ts`. Tout est rouge (`unknown-type`) : c'est ta cible. + +### À toi + +1. Dans `decode`, ajoute les quatre `case` qui renvoient vers une fonction `decodeSessionMessage(type, id, payload)`. +2. Écris `decodeSessionMessage`, puis deux fonctions qu'elle appelle : `decodeSessionState` et `decodeSessionRequest`. + +
+Indice 1 : où regarder + +Tout existe déjà pour le rendu : `decodeRenderMessage` (réponse ou requête ?), `decodeRenderStatus` (valider champ par champ), `decodeRenderRequest`. Les petits outils `isNonNegativeInteger`, `fail` et `WORLD_VERSION_PATTERN` (le même motif que la graine et l'empreinte) sont en haut du fichier. + +
+ +
+Indice 2 : la forme de la réponse + +```ts +function decodeSessionState(type: SessionRequestType, id: string | undefined, payload: Record): DecodeResult { + const { seed, tickRateHz, tick, stateHash, running, inputCount } = payload; + + if (typeof seed !== 'string' || !HEX64_PATTERN.test(seed)) { + return fail('invalid-payload', `${type} : « seed » doit être une chaîne hexadécimale de 16 caractères minuscules.`); + } + // … un if par champ … + + const status: SessionState = { seed, tickRateHz, tick, stateHash, running, inputCount }; + return { ok: true, message: { type, ...(id === undefined ? {} : { id }), payload: status } }; +} +``` + +
+ +### Piège : `exactOptionalPropertyTypes` + +Le `tsconfig.json` de Studio interdit d'écrire `{ id: undefined }` à la place d'un champ absent. D'où le motif répété partout : `...(id === undefined ? {} : { id })`. Même chose pour `ticks` dans une requête `session/step` décodée. + +--- + +## 3.3 L'interface `CoreConnection` et la connexion simulée + +### Pourquoi + +Le reste de Studio ne sait pas s'il parle à un vrai Core. Il appelle `connection.sessionPause()` et reçoit des **événements**. La réponse n'est jamais une valeur de retour : elle arrive plus tard, comme un courrier. + +### Le contrat + +Dans `src/protocol/connection.ts`, ajoute à l'interface `CoreConnection` : + +```ts + /** Demande l'état de la session (session/status). Interrogation : pas de « session/requested ». */ + sessionStatus(): void; + /** Met la session en pause (session/pause). */ + sessionPause(): void; + /** Reprend la session (session/resume). */ + sessionResume(): void; + /** Avance de `ticks` ticks, en pause seulement (session/step) ; le Core applique 1 si absent. */ + sessionStep(ticks?: number): void; +``` + +Événements attendus, pour **toutes** les implémentations : + +| Appel | Événements émis | +|---|---| +| `sessionStatus()` | `session/status`, ou `session/error` | +| `sessionPause()`, `sessionResume()`, `sessionStep()` | `session/requested`, **puis** `session/status` ou `session/error` | +| n'importe lequel, hors connexion | `session/error` avec le code `not-connected` | + +Pourquoi `sessionStatus` n'annonce pas `session/requested` : Studio interroge l'état une fois par seconde. Si chaque interrogation passait le panneau en « attente », les boutons clignoteraient toutes les secondes. + +Dès que tu ajoutes ces quatre méthodes, `npm run typecheck` signale que `SimulatedCoreConnection` et `WebSocketCoreConnection` ne respectent plus l'interface. Comme en C++, le compilateur te donne la liste des tâches. + +### Comportement attendu de la connexion simulée + +Elle imite le Core **sans réseau**, de façon synchrone, et doit rester marquée **SIMULÉ** (commentaire en tête de méthode, comme pour le rendu) : + +- à la connexion : graine `0000000000000000`, 60 Hz, tick 0, **en marche**, aucune entrée ; +- `sessionStatus()` en marche : le tick avance de `tickRateHz` (on fait comme si une seconde s'était écoulée depuis la dernière interrogation) ; +- `sessionStep()` en marche : `session-running` ; `ticks` hors [1, 600] ou non entier : `invalid-payload` ; +- l'empreinte : une valeur fictive qui change avec le tick (par exemple le tick en hexadécimal sur 16 caractères) ; +- tout est remis à zéro à la déconnexion. + +### Tests + +Crée `tests/session-connection.test.ts` : + +```ts +// SPDX-License-Identifier: MPL-2.0 + +import { describe, expect, it } from 'vitest'; +import type { SessionState } from '../src/model/session'; +import type { StudioEvent } from '../src/model/state'; +import { SimulatedCoreConnection } from '../src/protocol/connection'; + +function listen(connection: SimulatedCoreConnection): StudioEvent[] { + const events: StudioEvent[] = []; + connection.onEvent((event) => { + events.push(event); + }); + return events; +} + +function connected() { + const connection = new SimulatedCoreConnection(); + const events = listen(connection); + connection.connect(); + events.length = 0; + return { connection, events }; +} + +function lastStatus(events: readonly StudioEvent[]): SessionState { + const last = events.at(-1); + if (last?.type !== 'session/status') { + throw new Error(`attendu session/status, reçu ${last?.type ?? 'rien'}`); + } + return last.status; +} + +describe('SimulatedCoreConnection : session (SIMULÉ)', () => { + it('hors connexion : session/error not-connected', () => { + const connection = new SimulatedCoreConnection(); + const events = listen(connection); + connection.sessionStatus(); + expect(events.at(-1)).toMatchObject({ type: 'session/error', code: 'not-connected' }); + }); + + it('en marche, chaque interrogation avance d’une seconde simulée', () => { + const { connection, events } = connected(); + connection.sessionStatus(); + const first = lastStatus(events); + connection.sessionStatus(); + const second = lastStatus(events); + expect(first.running).toBe(true); + expect(first.tickRateHz).toBe(60); + expect(second.tick - first.tick).toBe(60); + expect(second.stateHash).not.toBe(first.stateHash); + }); + + it('pause fige le tick, step avance en pause, refus en marche', () => { + const { connection, events } = connected(); + connection.sessionStep(); + expect(events.at(-1)).toMatchObject({ type: 'session/error', code: 'session-running' }); + + connection.sessionPause(); + const paused = lastStatus(events); + expect(paused.running).toBe(false); + connection.sessionStatus(); + expect(lastStatus(events).tick).toBe(paused.tick); + + connection.sessionStep(); + expect(lastStatus(events).tick).toBe(paused.tick + 1); + connection.sessionStep(10); + expect(lastStatus(events).tick).toBe(paused.tick + 11); + + connection.sessionResume(); + expect(lastStatus(events).running).toBe(true); + }); + + it('refuse un nombre de ticks hors [1, 600]', () => { + const { connection, events } = connected(); + connection.sessionPause(); + for (const ticks of [0, 601, 1.5]) { + connection.sessionStep(ticks); + expect(events.at(-1)).toMatchObject({ type: 'session/error', code: 'invalid-payload' }); + } + }); + + it('pause, resume et step annoncent la requête ; status non', () => { + const { connection, events } = connected(); + connection.sessionPause(); + expect(events.map((event) => event.type)).toEqual(['session/requested', 'session/status']); + events.length = 0; + connection.sessionStatus(); + expect(events.map((event) => event.type)).toEqual(['session/status']); + }); + + it('la déconnexion remet la simulation à zéro', () => { + const { connection, events } = connected(); + connection.sessionPause(); + connection.sessionStep(5); + connection.disconnect(); + connection.connect(); + connection.sessionStatus(); + expect(lastStatus(events)).toMatchObject({ running: true, tick: 60 }); + }); +}); +``` + +### À toi + +Implémente les quatre méthodes dans `SimulatedCoreConnection`, avec des champs privés pour la session simulée, et une méthode privée `resetSessionSimulation()` appelée dans `disconnect()`. + +
+Indice + +Calque-toi sur le rendu simulé dans la même classe : `renderRunState` et ses voisins (l'état simulé), `emitStatus()` (fabriquer et émettre la réponse), `emitRenderError()`, `resetRenderSimulation()`. Pour valider `ticks` : `Number.isInteger(ticks) && ticks >= 1 && ticks <= MAX_STEP_TICKS`. + +
+ +--- + +## 3.4 La vraie connexion : `src/protocol/websocket.ts` + +### Pourquoi + +Le WebSocket est **asynchrone** : on envoie, et la réponse arrive plus tard, mélangée aux autres. Pour savoir à quelle question répond un message, chaque requête porte un `id`. La connexion garde une table « id envoyé → qui attend la réponse ». + +Analogie : au pressing, tu laisses ta veste et tu repars avec un ticket numéroté. Le ticket dit à quel comptoir revenir. + +### Lis d'abord le code du rendu + +Dans `WebSocketCoreConnection` : + +- `sendRenderRequest` : fabrique l'`id`, le range dans `pendingRender`, encode, envoie ; en cas d'échec, émet `render/error` ; +- `handlePostHandshakeMessage` : une réponse dont l'`id` est dans `pendingRender` devient `render/status` ; un `error` dont l'`id` y est devient `render/error`. + +Aujourd'hui, un `error` avec l'`id` d'une requête de session serait **ignoré** : il n'est pas dans `pendingRender`. C'est ce que tu vas corriger. + +### À toi + +1. Ajoute une table `pendingSession` (même forme que `pendingRender`), vidée aux **trois** endroits où `pendingRender` est vidée (`disconnect`, `handleClose`, `fail`). +2. Écris `sendSessionRequest(type, payload)` sur le modèle de `sendRenderRequest`, qui émet en plus `session/requested` pour `pause`, `resume` et `step`. +3. Implémente les quatre méthodes publiques. `sessionStep(ticks)` envoie `{}` si `ticks` est absent, `{ ticks }` sinon. La connexion réelle **ne valide pas** `ticks` : c'est le Core qui fait foi. +4. Dans `handlePostHandshakeMessage` : les réponses de session deviennent `session/status`, et un `error` dont l'`id` est dans `pendingSession` devient `session/error`. + +### Tests + +À la fin de `tests/websocket.test.ts` (le fichier contient déjà le faux socket `FakeSocket` et les helpers `setup` et `connectAndWelcome`), ajoute en haut l'import : + +```ts +import type { SessionState } from '../src/model/session'; +``` + +puis ce bloc, **après** le dernier `});` du fichier : + +```ts +describe('WebSocketCoreConnection : session', () => { + const PAUSED: SessionState = { + seed: '000000000000002a', + tickRateHz: 60, + tick: 10, + stateHash: '00000000000000ff', + running: false, + inputCount: 0, + }; + + function lastSent(socket: FakeSocket): StudioMessage { + const text = socket.sent.at(-1); + if (text === undefined) { + throw new Error('Aucun message envoyé.'); + } + const result = decode(text); + if (!result.ok) { + throw new Error(result.error.message); + } + return result.message; + } + + function lastId(socket: FakeSocket): string { + const id = lastSent(socket).id; + if (id === undefined) { + throw new Error('Requête envoyée sans id.'); + } + return id; + } + + it('sessionStep(5) envoie session/step avec un id et { ticks: 5 }', () => { + const { connection, socket } = connectAndWelcome(); + connection.sessionStep(5); + const sent = lastSent(socket); + expect(sent.type).toBe('session/step'); + expect(sent.id).toBeDefined(); + expect(sent.payload).toEqual({ ticks: 5 }); + }); + + it('sessionStep() sans argument envoie un payload vide', () => { + const { connection, socket } = connectAndWelcome(); + connection.sessionStep(); + expect(lastSent(socket).payload).toEqual({}); + }); + + it('sessionPause annonce la requête, sessionStatus non', () => { + const { connection, listener } = connectAndWelcome(); + listener.mockClear(); + connection.sessionStatus(); + expect(listener).not.toHaveBeenCalled(); + connection.sessionPause(); + expect(listener).toHaveBeenCalledWith({ type: 'session/requested' }); + }); + + it('la réponse avec un id connu devient session/status', () => { + const { connection, listener, socket } = connectAndWelcome(); + connection.sessionPause(); + const id = lastId(socket); + listener.mockClear(); + socket.receive(encode({ type: 'session/pause', id, payload: PAUSED })); + expect(listener).toHaveBeenCalledWith({ type: 'session/status', status: PAUSED }); + }); + + it('une erreur avec cet id devient session/error, pas render/error', () => { + const { connection, listener, socket } = connectAndWelcome(); + connection.sessionStep(); + const id = lastId(socket); + listener.mockClear(); + socket.receive(encode({ type: 'error', id, payload: { code: 'session-running', message: 'En marche.' } })); + expect(listener).toHaveBeenCalledWith({ type: 'session/error', code: 'session-running', message: 'En marche.' }); + expect(listener).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'render/error' })); + }); + + it('une réponse avec un id inconnu est ignorée', () => { + const { listener, socket } = connectAndWelcome(); + listener.mockClear(); + socket.receive(encode({ type: 'session/status', id: '999', payload: PAUSED })); + expect(listener).not.toHaveBeenCalled(); + }); + + it('hors connexion : session/error not-connected', () => { + const { connection, listener } = setup(); + connection.sessionStatus(); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'session/error', code: 'not-connected' })); + }); +}); +``` + +### Au passage : un écart dans le code existant + +En lisant `sendRenderRequest`, tu remarqueras qu'il n'émet **jamais** `render/requested`. Seule la connexion simulée le fait. Avec le vrai Core, le panneau Rendu ne passe donc jamais en « attente » entre le clic et la réponse. Ce n'est pas l'objet de ce tutoriel, mais ton `sendSessionRequest` doit, lui, respecter le tableau du 3.3. Note l'écart quelque part (ticket, ou petit commit séparé plus tard). + +--- + +## 3.5 Le faux Core : `tools/mock-core.mjs` + +### Pourquoi + +Le faux Core est un vrai serveur WebSocket, écrit en JavaScript sans dépendance, qui **imite** `sequa-core`. Il sert à tester le chemin réseau de Studio sans compiler de C++. Aujourd'hui, il répond `not-implemented` à tout ce qui n'est pas `render/*`. + +### À toi + +1. Dans `onText`, à l'endroit où la session passe à `ready`, initialise une session simulée : graine, 60 Hz, tick 0, en marche, 0 entrée. +2. Dans `handleRenderMessage` (renomme-la `handleReadyMessage` si tu veux, ce n'est plus seulement le rendu), ajoute les quatre `case` de session, avec les **mêmes** réponses et erreurs que le Core (`session-running`, `invalid-payload` pour `ticks`). +3. Mets à jour le `@typedef Session` en tête de fichier (champs de la session simulée). + +Ici, le tick peut avancer avec **le temps réel**, puisque c'est un vrai serveur qui tourne. C'est plus parlant à l'écran qu'un tick qui saute de 60 à chaque interrogation. + +
+Indice : un tick qui suit l'horloge + +Garde deux nombres : `sessionTickBase` (tick au dernier changement d'état) et `sessionSince` (`Date.now()` à ce moment). En marche, le tick courant vaut `sessionTickBase + Math.floor((Date.now() - sessionSince) * 60 / 1000)`. À la pause, fige `sessionTickBase` au tick courant. Au pas à pas, ajoute `ticks` à `sessionTickBase`. À la reprise, remets `sessionSince = Date.now()`. + +
+ +Les réponses du faux Core doivent rester **identifiables** comme simulées : une empreinte fictive suffit, mais garde le bandeau « SIMULÉ » et la version `0.0.0-mock`. + +--- + +## 3.6 Vérifier + +```powershell +npx vitest run tests/session-messages.test.ts tests/session-connection.test.ts tests/websocket.test.ts +npm run typecheck +``` + +Attendu : les trois fichiers au vert. Le **typecheck** ne doit plus signaler qu'**une** erreur : le `never` à la fin de `reduce` (voir 3.1). C'est le début de la partie 4. S'il en signale d'autres, elles sont à toi. + +Essai rapide du faux Core : + +```powershell +npm run mock-core +``` + +Il doit démarrer sans erreur et afficher son bandeau SIMULÉ. Tu le brancheras à l'interface en partie 6. + +## Ce que tu as appris + +- Le JSON est confiné dans `src/protocol/`, et `decode` est une douane qui ne lève jamais d'exception. +- Une requête asynchrone se retrouve par son `id`, rangé dans une table d'attente. +- Une même interface (`CoreConnection`), trois implémentations : simulée, faux Core, vrai Core. Chacune doit produire **la même suite d'événements**. + +--- + +[← Partie 2](02-cote-cpp.md) · [Sommaire](README.md) · [Partie 4 : l'état →](04-etat-et-reduction.md) diff --git a/docs/tutoriels/02-brique-graphique/04-etat-et-reduction.md b/docs/tutoriels/02-brique-graphique/04-etat-et-reduction.md new file mode 100644 index 0000000..35aaf73 --- /dev/null +++ b/docs/tutoriels/02-brique-graphique/04-etat-et-reduction.md @@ -0,0 +1,207 @@ +# Partie 4 : l'état et la réduction + +[← Partie 3](03-cote-typescript-protocole.md) · [Sommaire](README.md) · [Partie 5 →](05-le-composant.md) + +**But** : l'état de Studio contient une tranche `session`, et `reduce` la fait évoluer à partir des événements de la partie 3. À la fin, `npm run typecheck` est entièrement vert. + +--- + +## 4.0 Le principe, en une image + +```text + événement nouvel état + (« la pause est faite ») (tranche session à jour) + │ ▲ + ▼ │ + état ──▶ reduce(état, événement) ──────────────────┘ + fonction PURE : ne modifie rien, ne parle à personne, renvoie un nouvel objet +``` + +`reduce` est le **seul** chemin pour changer l'état de Studio ([ADR 0003](../../adr/0003-studio-web-conteneur-remplacable.md)). Elle ne fait ni réseau, ni DOM, ni horloge. Trois conséquences très concrètes : + +- on la teste comme une calculatrice : un état et un événement en entrée, un état en sortie ; +- l'état reste **sérialisable** (du JSON pur), donc on peut l'afficher, le sauvegarder, comparer deux états ; +- si l'écran est faux, il n'y a que deux suspects : l'événement reçu, ou `reduce`. + +Analogie : un livre de comptes. On n'efface jamais une ligne : on écrit le nouveau solde à partir de l'ancien et de l'opération. + +--- + +## 4.1 Ajouter la tranche + +### À toi + +Dans `src/model/state.ts` : + +1. ajoute `readonly session: SessionView;` à `StudioState`, avec un commentaire d'une ligne comme pour `render` ; +2. initialise-la dans `createInitialState()` avec `createInitialSessionView()`. + +Lance `npm run typecheck` : de **nouvelles** erreurs apparaissent dans `tests/render-panel.test.ts` et `tests/render-panel.dom.test.ts`. Leurs helpers `createBaseState` construisent un `StudioState` à la main, et il leur manque désormais `session`. Ajoute dans les deux : + +```ts + session: { status: null, pending: false }, +``` + +C'est le prix (faible) d'une bonne pratique du tutoriel 1 : construire l'état **à la main** dans les tests de composant. Le jour où l'état grandit, le compilateur montre chaque endroit à compléter. + +--- + +## 4.2 Piège : les fonctions qui recopient l'état champ par champ + +Regarde `withoutConnectionDetails` dans `state.ts` : + +```ts +function withoutConnectionDetails(state: StudioState): StudioState { + return { + connection: state.connection, + protocolVersion: state.protocolVersion, + intentions: state.intentions, + render: state.render, + }; +} +``` + +Elle **liste** les champs à garder, pour écarter `coreVersion` et `lastDisconnectReason`. Si tu n'y ajoutes pas `session`, le compilateur te le signalera (le champ est obligatoire). Mais retiens le mécanisme : chaque fois qu'on recopie un objet champ par champ, un champ ajouté plus tard risque de disparaître sans bruit si le type le déclare facultatif. + +**À toi** : ajoute `session` dans cette fonction. + +--- + +## 4.3 Remettre à zéro à la (re)connexion + +`withFreshRender` remet la tranche rendu à zéro sur `core/connecting` et `core/disconnected`. Un état de session venu d'un **ancien** Core ne doit pas plus survivre que le rendu : après une reconnexion, peut-être à un autre Core, le tick affiché serait faux. + +**À toi** : fais en sorte que les deux tranches soient remises à zéro aux mêmes moments. Le plus propre est de renommer la fonction (`withFreshSlices`, par exemple) et de lui faire remettre `render` **et** `session`. + +--- + +## 4.4 Les trois cas de `reduce` + +Règles, identiques à celles du rendu : + +| Événement | `pending` | `status` | `lastError` | +|---|---|---|---| +| `session/requested` | `true` | inchangé (on garde l'affichage) | inchangé | +| `session/status` | `false` | le nouvel état | **effacée** | +| `session/error` | `false` | inchangé | la nouvelle erreur | + +Pourquoi garder `status` pendant l'attente et après une erreur : l'utilisateur voit toujours le dernier état connu, plutôt qu'un panneau vide qui clignote. + +### Tests + +Crée `tests/session-state.test.ts` : + +```ts +// SPDX-License-Identifier: MPL-2.0 + +import { describe, expect, it } from 'vitest'; +import type { SessionState } from '../src/model/session'; +import { createInitialState, reduce } from '../src/model/state'; +import type { StudioState } from '../src/model/state'; + +const PAUSED: SessionState = { + seed: '000000000000002a', + tickRateHz: 60, + tick: 150, + stateHash: '00000000000000ff', + running: false, + inputCount: 3, +}; + +function connectedState(): StudioState { + return reduce(createInitialState(), { type: 'core/connected', coreVersion: '1.2.3' }); +} + +describe('tranche session', () => { + it('état initial : rien de connu, rien en attente', () => { + expect(createInitialState().session).toEqual({ status: null, pending: false }); + }); + + it('requested → en attente ; error → erreur gardée ; status → erreur effacée', () => { + let state = reduce(connectedState(), { type: 'session/requested' }); + expect(state.session.pending).toBe(true); + + state = reduce(state, { type: 'session/error', code: 'session-running', message: 'En marche.' }); + expect(state.session.pending).toBe(false); + expect(state.session.lastError).toEqual({ code: 'session-running', message: 'En marche.' }); + + state = reduce(state, { type: 'session/status', status: PAUSED }); + expect(state.session).toEqual({ status: PAUSED, pending: false }); + }); + + it('une erreur garde le dernier état connu', () => { + let state = reduce(connectedState(), { type: 'session/status', status: PAUSED }); + state = reduce(state, { type: 'session/error', code: 'session-running', message: 'En marche.' }); + expect(state.session.status).toEqual(PAUSED); + }); + + it('la session survit à core/connected', () => { + let state = reduce(createInitialState(), { type: 'session/status', status: PAUSED }); + state = reduce(state, { type: 'core/connected', coreVersion: '1.2.3' }); + expect(state.session.status).toEqual(PAUSED); + }); + + it('connexion et déconnexion remettent la session à zéro', () => { + const events = [{ type: 'core/connecting' }, { type: 'core/disconnected', reason: 'fin' }] as const; + for (const event of events) { + const before = reduce(connectedState(), { type: 'session/status', status: PAUSED }); + expect(reduce(before, event).session).toEqual({ status: null, pending: false }); + } + }); + + it('les tranches session et rendu sont indépendantes', () => { + let state = reduce(connectedState(), { type: 'session/requested' }); + state = reduce(state, { type: 'render/error', code: 'render-running', message: 'Déjà lancé' }); + expect(state.session.pending).toBe(true); + expect(state.session.lastError).toBeUndefined(); + + state = reduce(state, { type: 'session/error', code: 'session-running', message: 'En marche.' }); + expect(state.render.lastError).toEqual({ code: 'render-running', message: 'Déjà lancé' }); + }); + + it('ne modifie jamais l’état reçu et reste sérialisable', () => { + const before = reduce(connectedState(), { type: 'session/status', status: PAUSED }); + const snapshot = JSON.stringify(before); + const after = reduce(before, { type: 'session/requested' }); + expect(JSON.stringify(before)).toBe(snapshot); + expect(JSON.parse(JSON.stringify(after))).toEqual(after); + }); +}); +``` + +### À toi + +Ajoute les trois `case` dans `reduce`, juste avant la ligne `const unreachable: never = event;`. + +
+Indice + +Les trois `case` du rendu, juste au-dessus, sont exactement ce qu'il faut, en remplaçant `render` par `session`. Attention au `case 'session/status'` : il doit **reconstruire** la vue (`{ status: event.status, pending: false }`) pour que `lastError` disparaisse, et non l'étendre avec `...state.session`. + +
+ +### Limite connue, à garder en tête + +Studio interroge `session/status` une fois par seconde (partie 6). Si une réponse d'interrogation arrive **pendant** qu'un `session/pause` attend la sienne, elle lève `pending` un peu trop tôt : l'événement `session/status` ne dit pas à quelle requête il répond. Le rendu a la même limite. Ce n'est pas grave ici (au pire, un bouton redevient actif quelques millisecondes trop tôt), mais si un jour une action doit être strictement exclusive, il faudra que l'événement porte l'origine de la réponse. + +--- + +## 4.5 Vérifier + +```powershell +npm run typecheck +npm run test +``` + +Attendu : **zéro** erreur de typecheck (le `never` est satisfait), tous les tests au vert, anciens et nouveaux. + +## Ce que tu as appris + +- L'état ne change que par `reduce`, fonction pure et testable sans rien autour. +- Le `never` final transforme « j'ai oublié un événement » en erreur de compilation. +- Les recopies champ par champ (`withoutConnectionDetails`) sont un endroit où un champ ajouté peut se perdre. +- Chaque tranche (rendu, session) vit sa vie : une erreur de l'une ne touche pas l'autre. + +--- + +[← Partie 3](03-cote-typescript-protocole.md) · [Sommaire](README.md) · [Partie 5 : le composant →](05-le-composant.md) diff --git a/docs/tutoriels/02-brique-graphique/05-le-composant.md b/docs/tutoriels/02-brique-graphique/05-le-composant.md new file mode 100644 index 0000000..786bbea --- /dev/null +++ b/docs/tutoriels/02-brique-graphique/05-le-composant.md @@ -0,0 +1,445 @@ +# Partie 5 : le composant graphique + +[← Partie 4](04-etat-et-reduction.md) · [Sommaire](README.md) · [Partie 6 →](06-brancher-et-essayer.md) + +**But** : écrire `src/ui/session-panel.ts`, le panneau « Simulation ». Il lit l'état et appelle des actions, rien de plus. À la fin, deux fichiers de tests passent : l'un sans navigateur, l'autre dans un faux navigateur. + +--- + +## 5.0 Un composant coupé en deux + +Le panneau Rendu (tutoriel 1, §6) est construit en deux moitiés. Tu fais pareil : + +```text + ┌───────────────────────────────┐ + StudioState ────▶ │ sessionPanelModel(state) │ fonction PURE, aucun DOM + │ « que faut-il afficher ? » │ → libellés, boutons actifs, valeurs formatées + └──────────────┬────────────────┘ + │ SessionPanelModel + ┌──────────────▼────────────────┐ + │ mountSessionPanel(parent, …) │ DOM : crée les nœuds UNE fois + │ .update(state) │ puis ne change que textes et attributs + └──────────────┬────────────────┘ + │ clic + ▼ + actions.pause() / resume() / step(1) +``` + +Pourquoi couper : + +- **toute la logique** (quel bouton est actif, comment on écrit « 2,5 s ») se teste comme une fonction ordinaire : rapide, sans navigateur, sans piège ; +- la partie DOM devient **bête** : « tel champ du modèle va dans tel nœud », « tel clic appelle telle action ». Il reste peu à tester. + +Analogie : le modèle est la fiche de cuisine (ce qu'il faut mettre dans l'assiette), le DOM est le dressage. On vérifie la fiche sans allumer le four. + +Deux interdits, qui font qu'un composant reste un composant : + +1. il ne **modifie jamais** l'état : il reçoit `state` et le lit ; +2. il ne **parle jamais** à la connexion : il reçoit des `actions`, qu'il appelle sans savoir ce qu'elles font. + +--- + +## 5.1 Le contrat + +Crée `src/ui/session-panel.ts` avec ces exports. Les noms, classes CSS et attributs sont fixés, car les tests les utilisent : + +```ts +// SPDX-License-Identifier: MPL-2.0 + +import type { StudioState } from '../model/state'; + +/** « unknown » tant qu'aucun état de session n'est arrivé. */ +export type SessionPanelStateKind = 'unknown' | 'running' | 'paused'; + +/** Une ligne de faits du panneau. */ +export interface SessionFact { + readonly term: string; + readonly value: string; +} + +/** Modèle pur calculé à partir de l'état, sans aucune référence au DOM. */ +export interface SessionPanelModel { + /** « Inconnu », « En marche » ou « En pause ». */ + readonly stateLabel: string; + readonly stateKind: SessionPanelStateKind; + readonly canPause: boolean; + readonly canResume: boolean; + readonly canStep: boolean; + /** Toujours six lignes, dans l'ordre de SESSION_FACT_TERMS. */ + readonly facts: readonly SessionFact[]; + /** « code : message » ; absent lorsque tout va bien. */ + readonly errorText?: string; +} + +/** Actions du panneau, branchées par l'application sur la connexion au Core. */ +export interface SessionPanelActions { + pause(): void; + resume(): void; + step(ticks: number): void; +} + +export interface SessionPanel { + update(state: StudioState): void; +} + +export function sessionPanelModel(state: StudioState): SessionPanelModel { + // À toi +} + +export function mountSessionPanel(parent: HTMLElement, actions: SessionPanelActions): SessionPanel { + // À toi +} +``` + +### Règles du modèle + +| Élément | Règle | +|---|---| +| `stateKind` | `status` nul → `unknown` ; `running` vrai → `running` ; sinon `paused` | +| « interactif » | connecté **et** aucune requête en attente | +| `canPause` | interactif **et** `running` | +| `canResume`, `canStep` | interactif **et** `paused` | +| Faits, dans l'ordre | `Tick`, `Temps simulé`, `Fréquence`, `Graine`, `Empreinte`, `Entrées enregistrées` | +| Valeur absente | `—` (tiret cadratin, comme le panneau Rendu) | +| `Tick` | le nombre tel quel : `150` | +| `Temps simulé` | `tick / tickRateHz`, au format français, 2 décimales au plus : `2,5 s` | +| `Fréquence` | `60 Hz` | +| `Graine`, `Empreinte` | la chaîne hexadécimale telle quelle | +| `errorText` | `` `${code} : ${message}` `` si `lastError`, sinon **absent** | + +### Règles du DOM + +| Nœud | Classe | Détails | +|---|---|---| +| Conteneur | `section.session-panel` | | +| Titre | `h2.session-panel__title` | « Simulation » | +| Boutons | dans `div.session-panel__controls` | `type="button"` ; `data-action="pause"` « Pause », `data-action="resume"` « Reprendre », `data-action="step"` « Avancer d'un tick » | +| Ligne d'état | `p.session-panel__status` | `role="status"`, `aria-live="polite"`, `data-state` = `stateKind` | +| Faits | `dl.facts`, valeurs en `dd.facts__value` | les classes du panneau Rendu, réutilisées | +| Erreur | `p.session-panel__error` | `role="alert"`, `hidden` quand il n'y a pas d'erreur | + +Le clic sur « Avancer d'un tick » appelle `actions.step(1)`. + +Pourquoi `data-action` sur les boutons : les tests du panneau Rendu cherchent les boutons par leur classe (`button--ghost`), qui est un choix de **style**. Si demain le style change, les tests cassent sans raison. Un attribut dédié décrit le **rôle** du bouton : il ne bouge pas quand on change l'apparence. + +--- + +## 5.2 Le modèle pur + +### Tests + +Crée `tests/session-panel.test.ts` : + +```ts +// SPDX-License-Identifier: MPL-2.0 + +import { describe, expect, it } from 'vitest'; +import type { SessionState } from '../src/model/session'; +import type { StudioState } from '../src/model/state'; +import { sessionPanelModel } from '../src/ui/session-panel'; +import type { SessionPanelModel } from '../src/ui/session-panel'; + +const PAUSED: SessionState = { + seed: '000000000000002a', + tickRateHz: 60, + tick: 150, + stateHash: '00000000000000ff', + running: false, + inputCount: 3, +}; +const RUNNING: SessionState = { ...PAUSED, running: true }; + +function stateWith(overrides: Partial = {}): StudioState { + return { + connection: 'connected', + protocolVersion: '0.3.0', + intentions: [], + render: { status: null, pending: false }, + session: { status: null, pending: false }, + ...overrides, + }; +} + +function value(model: SessionPanelModel, term: string): string | undefined { + return model.facts.find((fact) => fact.term === term)?.value; +} + +describe('sessionPanelModel', () => { + it('déconnecté : Inconnu, tout bloqué', () => { + expect(sessionPanelModel(stateWith({ connection: 'disconnected' }))).toMatchObject({ + stateLabel: 'Inconnu', + stateKind: 'unknown', + canPause: false, + canResume: false, + canStep: false, + }); + }); + + it('connecté sans réponse : Inconnu, tout bloqué, valeurs absentes', () => { + const model = sessionPanelModel(stateWith()); + expect(model.stateKind).toBe('unknown'); + expect([model.canPause, model.canResume, model.canStep]).toEqual([false, false, false]); + expect(model.facts.map((fact) => fact.value)).toEqual(['—', '—', '—', '—', '—', '—']); + }); + + it('en marche : seul Pause est possible', () => { + const model = sessionPanelModel(stateWith({ session: { status: RUNNING, pending: false } })); + expect(model).toMatchObject({ stateLabel: 'En marche', stateKind: 'running', canPause: true, canResume: false, canStep: false }); + }); + + it('en pause : Reprendre et Avancer, pas Pause', () => { + const model = sessionPanelModel(stateWith({ session: { status: PAUSED, pending: false } })); + expect(model).toMatchObject({ stateLabel: 'En pause', stateKind: 'paused', canPause: false, canResume: true, canStep: true }); + }); + + it('requête en attente : tout bloqué, le dernier état reste affiché', () => { + const model = sessionPanelModel(stateWith({ session: { status: PAUSED, pending: true } })); + expect(model.stateLabel).toBe('En pause'); + expect([model.canPause, model.canResume, model.canStep]).toEqual([false, false, false]); + }); + + it('formate les faits en français, dans l’ordre', () => { + const model = sessionPanelModel(stateWith({ session: { status: PAUSED, pending: false } })); + expect(model.facts.map((fact) => fact.term)).toEqual([ + 'Tick', + 'Temps simulé', + 'Fréquence', + 'Graine', + 'Empreinte', + 'Entrées enregistrées', + ]); + expect(value(model, 'Tick')).toBe('150'); + expect(value(model, 'Temps simulé')).toBe('2,5 s'); + expect(value(model, 'Fréquence')).toBe('60 Hz'); + expect(value(model, 'Graine')).toBe('000000000000002a'); + expect(value(model, 'Empreinte')).toBe('00000000000000ff'); + expect(value(model, 'Entrées enregistrées')).toBe('3'); + }); + + it('temps simulé : 0 s au départ, deux décimales au plus', () => { + const at = (tick: number) => + value(sessionPanelModel(stateWith({ session: { status: { ...PAUSED, tick }, pending: false } })), 'Temps simulé'); + expect(at(0)).toBe('0 s'); + expect(at(1)).toBe('0,02 s'); + expect(at(61)).toBe('1,02 s'); + }); + + it('erreur : « code : message », absente sinon', () => { + const withError = sessionPanelModel( + stateWith({ session: { status: RUNNING, pending: false, lastError: { code: 'session-running', message: 'En marche.' } } }), + ); + expect(withError.errorText).toBe('session-running : En marche.'); + expect(sessionPanelModel(stateWith()).errorText).toBeUndefined(); + }); +}); +``` + +### À toi + +Écris `sessionPanelModel`. Constantes utiles en haut du fichier : `NO_VALUE`, une table `STATE_LABELS`, la liste `SESSION_FACT_TERMS` (exportée ou non, au choix), un `Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 })`. + +
+Indice + +Ouvre `renderPanelModel` dans `src/ui/render-panel.ts` : même découpage (drapeau `interactive`, `kind`, tableau `facts`, `errorText` ajouté seulement s'il existe avec `...(errorText === undefined ? {} : { errorText })`). Calcule les six valeurs dans un seul `if (status === null)` plutôt que de répéter `status === null ? NO_VALUE : …` six fois. + +
+ +Vérifie : `npx vitest run tests/session-panel.test.ts`. + +--- + +## 5.3 Le montage DOM + +### Tests + +Crée `tests/session-panel.dom.test.ts`. La deuxième ligne demande à Vitest un faux navigateur (happy-dom) pour ce fichier seulement : + +```ts +// SPDX-License-Identifier: MPL-2.0 +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionState } from '../src/model/session'; +import type { StudioState } from '../src/model/state'; +import { mountSessionPanel, type SessionPanel, type SessionPanelActions } from '../src/ui/session-panel'; + +const PAUSED: SessionState = { + seed: '000000000000002a', + tickRateHz: 60, + tick: 150, + stateHash: '00000000000000ff', + running: false, + inputCount: 3, +}; + +function stateWith(overrides: Partial = {}): StudioState { + return { + connection: 'connected', + protocolVersion: '0.3.0', + intentions: [], + render: { status: null, pending: false }, + session: { status: null, pending: false }, + ...overrides, + }; +} + +function button(parent: HTMLElement, action: string): HTMLButtonElement { + const found = parent.querySelector(`button[data-action="${action}"]`); + if (found === null) { + throw new Error(`Bouton « ${action} » introuvable.`); + } + return found; +} + +describe('mountSessionPanel : DOM', () => { + let parent: HTMLElement; + let actions: SessionPanelActions; + let panel: SessionPanel; + + beforeEach(() => { + parent = document.createElement('div'); + document.body.append(parent); + actions = { pause: vi.fn(), resume: vi.fn(), step: vi.fn() }; + panel = mountSessionPanel(parent, actions); + }); + + afterEach(() => { + parent.remove(); + }); + + it('monte le titre, trois boutons et une ligne d’état accessible', () => { + expect(parent.querySelector('.session-panel__title')?.textContent).toBe('Simulation'); + + const labels = [ + ['pause', 'Pause'], + ['resume', 'Reprendre'], + ['step', "Avancer d'un tick"], + ] as const; + for (const [action, label] of labels) { + expect(button(parent, action).textContent).toBe(label); + expect(button(parent, action).type).toBe('button'); + } + + const status = parent.querySelector('.session-panel__status'); + expect(status?.getAttribute('role')).toBe('status'); + expect(status?.getAttribute('aria-live')).toBe('polite'); + }); + + it('en pause : Reprendre et Avancer appellent resume() et step(1)', () => { + panel.update(stateWith({ session: { status: PAUSED, pending: false } })); + expect(button(parent, 'pause').disabled).toBe(true); + + button(parent, 'resume').click(); + button(parent, 'step').click(); + expect(actions.resume).toHaveBeenCalledTimes(1); + expect(actions.step).toHaveBeenCalledWith(1); + }); + + it('en marche : seul Pause est actif, et il appelle pause()', () => { + panel.update(stateWith({ session: { status: { ...PAUSED, running: true }, pending: false } })); + expect(button(parent, 'resume').disabled).toBe(true); + expect(button(parent, 'step').disabled).toBe(true); + + button(parent, 'pause').click(); + expect(actions.pause).toHaveBeenCalledTimes(1); + }); + + it('affiche l’état et les faits', () => { + panel.update(stateWith({ session: { status: PAUSED, pending: false } })); + const status = parent.querySelector('.session-panel__status'); + expect(status?.textContent).toBe('En pause'); + expect(status?.dataset['state']).toBe('paused'); + + const values = [...parent.querySelectorAll('.facts__value')].map((node) => node.textContent); + expect(values).toEqual(['150', '2,5 s', '60 Hz', '000000000000002a', '00000000000000ff', '3']); + }); + + it('update ne recrée jamais les nœuds', () => { + const before = button(parent, 'step'); + panel.update(stateWith({ session: { status: PAUSED, pending: false } })); + panel.update(stateWith({ session: { status: { ...PAUSED, tick: 151 }, pending: false } })); + expect(button(parent, 'step')).toBe(before); + expect(parent.querySelectorAll('.session-panel')).toHaveLength(1); + }); + + it('montre puis cache l’erreur', () => { + const error = parent.querySelector('.session-panel__error'); + expect(error?.getAttribute('role')).toBe('alert'); + expect(error?.hidden).toBe(true); + + panel.update( + stateWith({ session: { status: PAUSED, pending: false, lastError: { code: 'session-running', message: 'En marche.' } } }), + ); + expect(error?.hidden).toBe(false); + expect(error?.textContent).toBe('session-running : En marche.'); + + panel.update(stateWith({ session: { status: PAUSED, pending: false } })); + expect(error?.hidden).toBe(true); + }); +}); +``` + +### À toi + +Écris `mountSessionPanel` : + +1. crée tous les nœuds, branche les trois `click`, ajoute la `section` au `parent` ; +2. garde des références aux nœuds qui changent (ligne d'état, boutons, les six `dd`, l'erreur) ; +3. retourne `{ update(state) { … } }` qui calcule `sessionPanelModel(state)` et recopie le modèle dans les nœuds. + +
+Indice 1 : créer un élément + +`render-panel.ts` et `app.ts` ont chacun une petite fonction `element(tag, className?, text?)`. Recopie-la. Si tu veux la partager, ce serait un troisième fichier (`src/ui/dom.ts`) : c'est un bon réflexe, mais fais-le dans un commit à part pour ne pas mélanger « nouveau panneau » et « remaniement ». + +
+ +
+Indice 2 : pourquoi ne jamais recréer les nœuds + +Si `update` refaisait `section.innerHTML = …` à chaque appel, le bouton sous ton doigt serait remplacé une fois par seconde (à chaque interrogation de l'état). Conséquences : le focus clavier se perd, un lecteur d'écran relit tout, et un clic peut tomber sur un bouton déjà détruit. Ici, `update` ne touche qu'à `textContent`, `disabled`, `hidden` et `dataset`. + +
+ +### Accessibilité, dès le départ + +- `role="status"` et `aria-live="polite"` : un lecteur d'écran annonce « En pause » quand l'état change, sans interrompre l'utilisateur. +- `role="alert"` sur l'erreur : annoncée tout de suite. +- `type="button"` : un bouton sans type, placé un jour dans un formulaire, enverrait le formulaire. +- Libellés en français, complets (« Avancer d'un tick », pas « +1 »). + +--- + +## 5.4 Le style + +Dans `src/style.css`, le panneau Rendu a son bloc (`.render-panel`, `__title`, `__controls`, `__status`, `__error`). Le panneau Simulation doit avoir **la même apparence**. + +**À toi**, au choix : + +- **simple** : ajoute `.session-panel…` à côté de chaque sélecteur `.render-panel…` correspondant (`.render-panel, .session-panel { … }`) ; +- **mieux, plus tard** : une classe commune `.panel` pour les deux. C'est un remaniement qui touche aussi le panneau Rendu et ses tests : commit séparé. + +Couleurs de la ligne d'état, avec les variables existantes : `unknown` → `var(--muted)`, `running` → `var(--ok)`, `paused` → `var(--warn)`. + +--- + +## 5.5 Vérifier + +```powershell +npx vitest run tests/session-panel.test.ts tests/session-panel.dom.test.ts +npm run check +``` + +Attendu : tout est vert, y compris le build Vite. Le panneau n'apparaît pas encore à l'écran : il n'est monté nulle part. C'est la partie 6. + +## Ce que tu as appris + +- Le découpage modèle pur / montage DOM, et pourquoi presque tout se teste sans navigateur. +- Un composant ne modifie pas l'état et ne parle pas à la connexion. +- `update` ne recrée jamais les nœuds. +- Sélectionner par **rôle** (`data-action`) plutôt que par **style** dans les tests. + +--- + +[← Partie 4](04-etat-et-reduction.md) · [Sommaire](README.md) · [Partie 6 : brancher et essayer →](06-brancher-et-essayer.md) diff --git a/docs/tutoriels/02-brique-graphique/06-brancher-et-essayer.md b/docs/tutoriels/02-brique-graphique/06-brancher-et-essayer.md new file mode 100644 index 0000000..d12d3ff --- /dev/null +++ b/docs/tutoriels/02-brique-graphique/06-brancher-et-essayer.md @@ -0,0 +1,154 @@ +# Partie 6 : brancher et essayer + +[← Partie 5](05-le-composant.md) · [Sommaire](README.md) + +**But** : le panneau apparaît dans Studio, fonctionne avec les trois « Core », et tu prouves que les ticks joués pas à pas se rejouent à l'identique. + +--- + +## 6.1 Brancher le panneau : `src/ui/app.ts` + +### Pourquoi + +`app.ts` est le **tableau électrique** : il monte les panneaux, leur passe les actions et appelle leur `update` à chaque changement d'état. Il ne contient aucune logique de session. + +### À toi + +1. Ajoute à `AppActions` : + + ```ts + readonly sessionPause: () => void; + readonly sessionResume: () => void; + readonly sessionStep: (ticks: number) => void; + ``` + +2. Monte le panneau avec `mountSessionPanel(shell, { … })`. Place-le **avant** le panneau Rendu : on règle la simulation avant de regarder son image. +3. Appelle `sessionPanel.update(state)` dans la fonction locale `render()`, à côté de `renderPanel.update(state)`. + +`npm run typecheck` te signalera alors que `main.ts` ne fournit pas les nouvelles actions. C'est l'étape suivante. + +## 6.2 Les actions et l'interrogation : `src/main.ts` + +### À toi + +1. Fournis les trois actions à `mountApp`, en appelant `connection.sessionPause()`, `connection.sessionResume()` et `connection.sessionStep(ticks)`. +2. Dans `startStatusPolling`, interroge **aussi** `connection.sessionStatus()`, au démarrage puis chaque seconde, avec le même minuteur que le rendu. + +Remarque le chemin complet d'un clic, que tu as écrit morceau par morceau : + +```text +clic « Avancer d'un tick » session-panel.ts (partie 5) + → actions.step(1) app.ts (6.1) + → connection.sessionStep(1) main.ts (6.2) + → session/requested websocket.ts (partie 3) → reduce : pending (partie 4) + → {"type":"session/step","id":"7",…} messages.ts (partie 3) + → ProtocolSession::on_text protocol.cpp (partie 2) + → CoreRuntime → LiveSession::step_paused (partie 2) + ← {"type":"session/step","id":"7","payload":{"tick":151,…}} + → session/status websocket.ts + → reduce state.ts (partie 4) + → sessionPanel.update(state) app.ts + → « Tick 151 » à l'écran session-panel.ts +``` + +--- + +## 6.3 Essai 1 : la connexion simulée (sans réseau) + +```powershell +cd studio/ui +npm run check +npm run dev # puis ouvrir http://localhost:5173/ +``` + +1. Le badge indique **Core simulé**. Clique **Connecter**. +2. Le panneau Simulation passe à « En marche » ; le tick avance de 60 par seconde. +3. **Pause** : le tick se fige. **Avancer d'un tick** : +1. **Reprendre** : il repart. +4. Pendant la marche, « Avancer » est grisé. +5. **Déconnecter** : le panneau revient à « Inconnu » et à des tirets. + +## 6.4 Essai 2 : le faux Core (vrai réseau, faux Core) + +```powershell +npm run mock-core # terminal 1 : ws://127.0.0.1:9101 +npm run dev # terminal 2 : http://localhost:5173/?core=ws://127.0.0.1:9101 +``` + +Mêmes gestes. Cette fois, le terminal du faux Core affiche chaque message reçu et envoyé : retrouve ton `session/step` avec son `id`, et la réponse qui recopie cet `id`. + +## 6.5 Essai 3 : le vrai Core + +Si ce n'est pas fait depuis la partie 2 : + +```powershell +.\scripts\build-all.ps1 # C++, interface, installation dans bin\ (Godot : seulement si absent) +``` + +Puis : + +```powershell +.\bin\windows-x64\debug\core\sequa-core.exe # terminal 1 +cd studio/ui; npm run dev # terminal 2 : http://localhost:5173/?core=ws://127.0.0.1:9100 +``` + +Mêmes gestes. Puis l'essai qui justifie le verrou de la partie 2 : + +1. ouvre **deux onglets** sur la même adresse et connecte les deux ; +2. onglet A : **Pause**. Moins d'une seconde plus tard, l'onglet B affiche aussi « En pause » (il interroge l'état chaque seconde) ; +3. onglet A : **Reprendre**, puis aussitôt, onglet B, encore en « En pause » à l'écran : **Avancer d'un tick** ; +4. l'onglet B affiche l'erreur `session-running : …`. Le bouton était actif, car l'écran était en retard d'une seconde. Mais c'est le **Core** qui décide, sous verrou, et il a refusé. + +Moralité : l'interface peut griser un bouton pour aider l'utilisateur, mais la règle vit dans le Core. C'est le sens de « l'interface ne porte aucune logique produit » ([ADR 0003](../../adr/0003-studio-web-conteneur-remplacable.md)). + +### Avec le rendu + +Démarre aussi le rendu Godot depuis le panneau Rendu, mets en pause et avance pas à pas. Si rien ne bouge dans le monde, la fenêtre Godot ne change pas et la latence transport affiche 0 : c'est normal, le Core n'envoie un delta que si quelque chose a changé ([protocole Core - rendu §8](../../specs/protocole-core-rendu-0.1.md)). Or Studio ne sait pas encore envoyer d'entrée (`session/input`) : une fois le joueur posé au sol, le monde reste immobile. L'exercice 2 ci-dessous (« Nouvelle session ») te donnera un monde qui bouge au début. + +## 6.6 Essai 4 : la preuve du rejeu + +C'est le moment de vérifier la promesse du README : chaque tick joué pas à pas est enregistré et se rejoue **à l'identique**. + +Studio host sait recharger le Core par rejeu et comparer les empreintes tick par tick ([spécification](../../specs/protocole-studio-core-0.2.md), §7.6). Arrête d'abord le `sequa-core` de l'essai 3 : Studio host lance le sien sur le même port. + +```powershell +cd studio/ui; npm run dev # terminal 1 : Vite +.\bin\windows-x64\debug\studio\sequa-studio.exe --dev # terminal 2 : lance le Core et ouvre Studio sur Vite +``` + +1. Dans Studio : **Pause**, puis **Avancer d'un tick** une dizaine de fois, puis **Reprendre**, puis encore quelques pas. +2. Dans la console de `sequa-studio`, tape `reload`. +3. Attendu : une ligne qui dit **identique**, avec la durée du rejeu (`replayMs`). Le tick affiché dans Studio continue depuis le même point. + +Si elle dit « divergence au tick N », un tick pas à pas n'a pas été enregistré comme les autres : reviens à `step_paused` (il doit passer par `step_locked()`, qui enregistre l'entrée et l'empreinte). + +Si tu as choisi l'option A de la [partie 1, §1.4](01-le-contrat.md) (version `0.4.0`), Studio host doit avoir été mis à jour lui aussi, sinon sa poignée de main avec le Core échoue. + +--- + +## Liste de contrôle + +- [ ] Spécification : ligne `session/step` (payload, bornes, erreurs, comportement), version décidée et écrite. +- [ ] C++ : `step_paused` sous un seul verrou ; `session_step` dans `CoreServices`, `CoreRuntime`, `FakeServices` ; validation dans `protocol.cpp` **avant** l'appel du service. +- [ ] Tests C++ : les quatre nouveaux `TEST_CASE` passent, et tout le reste aussi. +- [ ] TS : `src/model/session.ts` ; JSON confiné à `src/protocol/` ; `decode` refuse les payloads mal formés sans exception. +- [ ] Trois implémentations cohérentes : connexion simulée (marquée **SIMULÉ**), faux Core, connexion WebSocket ; mêmes événements pour les mêmes cas. +- [ ] État : tranche `session`, remise à zéro à la (re)connexion, recopiée par `withoutConnectionDetails`, `reduce` sans `never` en souffrance. +- [ ] Composant : modèle pur + montage DOM, `update` sans recréer de nœud, `data-action`, accessibilité. +- [ ] `npm run check` vert ; build C++ vert. +- [ ] Essais : simulé, faux Core, vrai Core (deux onglets), rejeu « identique ». +- [ ] Écart noté : `render/requested` jamais émis par la connexion WebSocket (partie 3.4). + +## Pour aller plus loin (exercices libres) + +Du plus simple au plus ambitieux. Aucun ne demande de toucher au C++, sauf le dernier. + +1. **« Avancer d'une seconde »** : un quatrième bouton qui appelle `step(tickRateHz)`. Que se passe-t-il si un jour la fréquence dépasse 600 Hz ? Où mettre la protection : dans le modèle, dans le composant, dans le Core ? +2. **« Nouvelle session »** : un champ « graine » et un bouton, avec le message `session/start` qui existe déjà dans le Core (§4). Valide la graine (1 à 16 caractères hexadécimaux) dans le modèle pur, pour griser le bouton tant qu'elle est invalide. +3. **Corriger l'écart `render/requested`** dans `WebSocketCoreConnection`, avec son test. +4. **Mettre en commun** : `src/ui/dom.ts` pour `element()`, une classe CSS `.panel` pour les deux panneaux. Remaniement pur : les tests existants doivent passer sans être modifiés, sauf les sélecteurs de style. +5. **Raccourcis clavier** : Espace pour Pause/Reprendre, flèche droite pour Avancer. Où écouter le clavier sans que le composant devienne le propriétaire de toute la page ? +6. **(C++)** : `session/step` refuse aujourd'hui en marche. Écris l'ADR d'une variante « pause puis avance » en une seule requête : avantages, inconvénients, effet sur le rejeu. Ne code qu'une fois l'ADR décidée. + +--- + +[← Partie 5](05-le-composant.md) · [Sommaire](README.md) diff --git a/docs/tutoriels/02-brique-graphique/README.md b/docs/tutoriels/02-brique-graphique/README.md new file mode 100644 index 0000000..7a09fb7 --- /dev/null +++ b/docs/tutoriels/02-brique-graphique/README.md @@ -0,0 +1,93 @@ +# Tutoriel 2 : construire une brique graphique de Studio, du C++ au TypeScript + +Le [tutoriel 1](../01-panneau-studio-de-bout-en-bout.md) **montre** un panneau qui existe déjà (Rendu). Celui-ci te fait **construire** un nouveau panneau, étape par étape, en traversant toutes les couches. C'est toi qui écris le code : chaque étape donne le contrat, les tests attendus, des explications et des indices, mais pas l'implémentation. + +## Ce que tu vas construire + +Un panneau **« Simulation »** dans Studio : + +```text +┌ Simulation ─────────────────────────────────────────┐ +│ [ Pause ] [ Reprendre ] [ Avancer d'un tick ] │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ En pause │ │ ← role="status" +│ └─────────────────────────────────────────────────┘ │ +│ Tick 150 │ +│ Temps simulé 2,5 s │ +│ Fréquence 60 Hz │ +│ Graine 000000000000002a │ +│ Empreinte 9c1f… │ +│ Entrées enregistrées 3 │ +└─────────────────────────────────────────────────────┘ +``` + +- **Pause / Reprendre** utilisent des messages qui existent déjà dans le Core (`session/pause`, `session/resume`, `session/status`) mais que Studio n'utilise pas encore. +- **Avancer d'un tick** demande un message **nouveau**, `session/step`, que tu ajouteras dans le Core en C++. + +Pourquoi cet exemple : il passe le *test d'identité* du projet ([docs de référence §2](../../SEQUA_Engine_resume_complet_v2.md#test-didentité)). Avancer image par image existe partout. Ici, chaque tick joué pas à pas est **enregistré et se rejoue à l'identique** (§5.3), et c'est la base du futur « voyage dans le temps » (§4.3). Tu le vérifieras à la fin avec le rechargement par rejeu de Studio host. + +## Les parties + +| Partie | Fichier | Couche | Durée indicative | +|---|---|---|---| +| 1 | [Le contrat](01-le-contrat.md) | Spécification | 30 min | +| 2 | [Côté C++ : le Core](02-cote-cpp.md) | `core/` | 1 h 30 | +| 3 | [Côté TypeScript : le protocole](03-cote-typescript-protocole.md) | `studio/ui/src/protocol/`, faux Core | 2 h | +| 4 | [L'état et la réduction](04-etat-et-reduction.md) | `studio/ui/src/model/` | 45 min | +| 5 | [Le composant graphique](05-le-composant.md) | `studio/ui/src/ui/`, CSS | 1 h 30 | +| 6 | [Brancher et essayer](06-brancher-et-essayer.md) | `app.ts`, `main.ts`, vrai Core, rejeu | 1 h | + +Les parties 3 à 5 n'ont pas besoin du C++ compilé : le faux Core et la connexion simulée suffisent. Tu peux donc faire la partie 2 plus tard, ou en parallèle. + +## Comment lire chaque partie + +Chaque étape suit le même déroulé : + +| Repère | Signification | +|---|---| +| **Pourquoi** | Le rôle de l'étape dans l'ensemble, en langage courant | +| **Le contrat** | Ce qui est fixé : signatures, noms, formats. Tu le recopies tel quel, car les tests en dépendent | +| **À toi** | Ce que tu écris | +| **Tests** | Tests fournis, à coller **avant** d'écrire le code. Ils échouent d'abord : c'est normal, c'est ta cible | +| **Indices** | À déplier seulement si tu bloques, du plus vague au plus précis | +| **Vérifier** | La commande à lancer et le résultat attendu | +| **Piège** | Une erreur classique de ce dépôt | + +Cette façon de faire porte un nom : **développement piloté par les tests**. On écrit d'abord le test (la cible), on le voit échouer, puis on écrit juste assez de code pour qu'il passe. Analogie : on pose la serrure avant de tailler la clé. + +## Prérequis + +- Avoir lu le [tutoriel 1](../01-panneau-studio-de-bout-en-bout.md), au moins §1 (carte des processus) et §3 (méthode « le contrat d'abord »). +- `studio/ui` installé : `cd studio/ui; npm ci`, puis `npm run check` au vert **avant de commencer** (sinon tu ne sauras pas si c'est toi qui as cassé quelque chose). +- Pour la partie 2 : un build C++ qui passe (`.\scripts\build.ps1 -Preset windows-msvc-debug`). +- Une branche à toi : `git switch -c tuto/panneau-simulation`. + +## La carte des fichiers que tu vas toucher + +```text +docs/specs/protocole-studio-core-0.2.md 1 la ligne session/step dans le tableau §4 +core/ + include/sequa/core/server/live_session.hpp 2 step_paused() + src/server/live_session.cpp 2 + include/sequa/core/server/services.hpp 2 CoreServices::session_step() + include/sequa/core/server/runtime.hpp 2 CoreRuntime::session_step() + src/server/runtime.cpp 2 + src/server/protocol.cpp 2 décodage et validation de session/step + tests/server/test_live_session.cpp 2 tests fournis + tests/server/test_protocol.cpp 2 tests fournis +studio/ui/ + src/model/session.ts (nouveau) 3 types de la session (le contrat côté Studio) + src/protocol/messages.ts 3 encode / decode des messages de session + src/protocol/connection.ts 3 interface CoreConnection + connexion SIMULÉE + src/protocol/websocket.ts 3 connexion réelle + tools/mock-core.mjs 3 faux Core + src/model/state.ts 4 tranche « session » de l'état, reduce + src/ui/session-panel.ts (nouveau) 5 le composant + src/style.css 5 + src/ui/app.ts, src/main.ts 6 branchement + tests/*.test.ts 3, 4, 5 tests fournis +``` + +## Quand tu as fini + +Fais relire ta branche (ou demande-moi une relecture), puis coche la [liste de contrôle](06-brancher-et-essayer.md#liste-de-contrôle). Les solutions ne sont pas dans ce dossier, exprès : si tu veux comparer avec une solution de référence, demande-la une fois ta version terminée. diff --git a/docs/tutoriels/README.md b/docs/tutoriels/README.md index 3a0ab8d..07ea4b2 100644 --- a/docs/tutoriels/README.md +++ b/docs/tutoriels/README.md @@ -4,4 +4,5 @@ Guides pas à pas pour contribuer à SEQUA. Chacun s'appuie sur du code réel du | N° | Tutoriel | Couches traversées | |---|---|---| -| [1](01-panneau-studio-de-bout-en-bout.md) | Un panneau Studio de bout en bout (panneau « Rendu ») | Studio (TypeScript), protocole WebSocket, Core (C++), rendu et adaptateurs | +| [1](01-panneau-studio-de-bout-en-bout.md) | Un panneau Studio de bout en bout (panneau « Rendu ») : visite commentée d'un exemple existant | Studio (TypeScript), protocole WebSocket, Core (C++), rendu et adaptateurs | +| [2](02-brique-graphique/README.md) | Construire une brique graphique, du C++ au TypeScript (panneau « Simulation », message `session/step`) : à faire soi-même, en six parties | Spécification, Core (C++), protocole et état (TypeScript), composant, essais et rejeu |