Skip to content

Vers la 2.1 : suiveur audio, grille de presets, code d'accès, replis — et la suite de tests qui mesure enfin ce qu'elle promet - #1

Draft
pymenvert wants to merge 27 commits into
mainfrom
claude/taches-a-avancer-awaput
Draft

Vers la 2.1 : suiveur audio, grille de presets, code d'accès, replis — et la suite de tests qui mesure enfin ce qu'elle promet#1
pymenvert wants to merge 27 commits into
mainfrom
claude/taches-a-avancer-awaput

Conversation

@pymenvert

@pymenvert pymenvert commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Travail hors-ligne, sans MadMapper ni matériel. Tout ce qui suit est mesuré, pas supposé.

328 tests · 0 échec · 0 ignoré · 36 mutants sur 36 détectés · CI verte, sur Ubuntu ET sur Windows.


Ce qui est ajouté

Suiveur audio — le micro devient une source de modulateur

Un modulateur (par couche ou global) accepte maintenant src: 'lfo' | 'audio'. L'analyse Web Audio se fait dans la page et l'énergie remonte en query sur le poll.

⚠ Deux limites assumées, écrites et décidées avec Pym — ne pas les « corriger » :

  • il lit une énergie, pas un tempo (la détection de battement attend un essai en salle) ;
  • il n'est lisible que sur la machine hôte (le micro exige une origine sûre).

Quand le flux se périme, le modulateur ne retombe pas vers min — il fond vers le réglage de l'opérateur (doserMod). La première version fondait vers min : un master à zéro en plein show. Le test de sécurité d'alors ne mesurait qu'après la péremption et ratait le creux.

Les 16 presets deviennent une grille

Chaque case dit ce qu'elle pilote : une empreinte de couverture (quelles barres, pas une photo du motif), servie à la demande par GET /api/presets-info. Volontairement hors de /api/state, qui part 8 fois par seconde.

Code d'accès facultatif à 4 chiffres

Haché salé, comparé en temps constant, cookie HttpOnly + SameSite=Strict. Cinq essais ratés bloquent l'appareil une minute, plus un plafond général contre l'essaimage sur plusieurs adresses.

La machine hôte en est exemptée exprès — on ne peut pas s'enfermer dehors. L'OSC et le MIDI ne sont pas couverts : autre domaine de confiance, les fermer casserait les installations existantes. Décidé avec Pym.

Le panneau Couches se replie

advMiroirs et advMelange, avec pastilles de réglages actifs, comme advGroove.

Icône de zone de notification (Windows)

PowerShell + NotifyIcon : zéro installation, zéro dépendance npm. Vert = ça tourne, rouge = à l'arrêt ; clic droit pour piloter.

Windows seulement, et c'est un choix — macOS n'a pas d'équivalent scriptable sans application compilée. ⚠ Éteinte par défaut et jamais exécutée : écrite depuis Linux.

Ce que la CI en dit maintenant, et ce qu'elle ne dira jamais. Un passage windows-latest a été ajouté : il analyse SYSTRAY_PS1 avec [Parser]::ParseInput — aucune session graphique requise — et lance la suite entière. Une parenthèse manquante ne partira donc plus en régie. Mais aucun runner ne posera jamais l'icône : NotifyIcon demande un bureau. tests/systray.test.js lit le source en plus : c'est mieux que rien, et ce n'est que ça. L'essai en vrai reste à faire.

Avant ce premier essai : Windows range toute nouvelle icône dans le tiroir caché (chevron ^). Sans cette note — désormais dans les Réglages et le manuel — l'essai n'aurait rien prouvé : le script tourne, l'icône existe, personne ne la voit.


Ce qui est corrigé

L'icône de notification désactivait l'arrêt automatique

Trouvé en relecture adversariale du systray. La trouvaille n'est pas dans le PowerShell : elle est dans ce qu'il sondait.

Le script interrogeait /api/state toutes les 1,5 s. Or /api/state remet lastUiPollAt à jour — c'est comme ça que Cascade sait qu'une interface est ouverte. L'arrêt automatique exige 8 s de silence : la condition n'était plus jamais vraie. Case cochée, on ferme son navigateur, Cascade ne se ferme plus tout seul. Le blocage était mutuel : le script ne sort que quand le serveur meurt, le serveur ne mourait plus parce que le script sondait. Avec le mode app (pas de terminal) et une icône rangée dans le tiroir caché, il ne restait que le Gestionnaire des tâches.

Une fonction d'agrément cassait une fonction livrée, en silence.

Le sondage passe par /api/ping, qui n'y touche pas — et qui ne renvoie pas fixtures, couches, scène, vues et niveaux pour lire un booléen. running et systray n'en sortent que pour la machine hôte : la route est hors du portillon, donc publique.

Mesuré, pas déduit : instance à part, veilleur armé, grâce à 400 ms (CASCADE_UI_GONE_MS), sondée uniquement par ping — et on regarde si le processus meurt. Avec un témoin qui poll et doit survivre. Deux mutants rejouent le défaut.

Sept autres correctifs de la même relecture : trois échecs avant de partir (un pic de charge faisait disparaître l'icône pour de bon) · -ErrorAction Stop (sans lui rien ne garantissait que le catch parte, et powershell.exe survivait à Cascade) · un seul chemin de sortie qui efface l'icône (kill() est un TerminateProcess : pas de NIM_DELETE, pastille morte à l'écran) · journalisation du code de sortie (trois bâillons superposés rendaient tout diagnostic impossible) · course sur la référence au processus · BOM sur le .ps1 · la case se grisait d'après navigator.platform, donc d'après la machine qui regarde, alors que l'icône se pose sur l'hôte.

Cascade ne répond plus qu'à son propre nom (DNS rebinding)

Sans ce garde, tous les autres tombaient. Une page sur mechant.com qui se ré-résout vers 127.0.0.1 est « chez elle » pour le navigateur : elle pose le Content-Type qu'elle veut, SameSite ne protège plus, et Cascade l'exempte du code puisque ça vient de localhost. Contrôle complet de la lumière depuis n'importe quelle page web.

Seuls localhost, un littéral IP et *.local sont désormais acceptés en Host. Conséquence assumée : on se connecte par IP ou par le QR code.

Le garde CSRF était contournable — mesuré

.includes('application/json') se contourne en une ligne : multipart/form-data; boundary=application/json contient la sous-chaîne et la règle CORS ne regarde que l'essence, donc ce type est safelisté et passe sans pré-vol. Mesuré contre un vrai serveur : /api/quit tuait le processus. On coupe au premier ;.

Le dialogue Réglages retirait le code en silence

Le champ était vidé à l'ouverture et « vide = retrait ». Enregistrer un réglage sans rapport désarmait la protection. Maintenant un champ vide ne change rien, et le retrait a son bouton confirmé.

Le compteur d'essais ne comptait pas

e.jusqua <= Date.now() est aussi vrai pour jusqua = 0 : le blocage se levait à chaque tentative. « restants : 4 » indéfiniment.

Le haché salé partait dans /api/state

sansCode() le retire de /api/state et /api/settings.

Le manuel refusait de se générer faute d'italique

fonts-dejavu-extra manquant faisait tout échouer. Le romain substitue et le script le dit. Le manuel passe de 15 à 18 pages.


Ce qui est réparé dans la mesure elle-même

C'est le fil rouge : sept fois, un test a mesuré moins — ou autre chose — que ce qu'il promettait. À chaque fois c'est la mesure qui l'a révélé, jamais la relecture du test.

  1. 32 tests d'interface s'annonçaient ignorés et la suite restait verte — findBrowser() ne regardait que les chemins système. La CI échoue désormais sans navigateur, et sans adresse réseau : le trou « ignoré = vert » est fermé côté CI aussi.
  2. Quatre assertions audio passaient sur des tableaux VIDES — le cache d'envoi OSC ne réémet pas une valeur inchangée. Corrigé en lisant runtime.levels, avec un helper qui exige un tableau non vide, et un témoin positif ajouté au test STOP.
  3. Le portillon était testé sans adresse réseau — donc jamais franchi.
  4. Le test de péremption ne regardait qu'après le fondu — il ratait le creux à min.
  5. Un anti-mutant a SURVÉCU — aucun test n'envoyait sec-fetch-dest. Le test manquant est écrit.
  6. Six boucles et .every() pouvaient passer à vide.every() est vrai sur un tableau vide, une boucle sur rien n'assure rien. Le pire : « une seule fixture : pas de division par zéro » serait passé au vert avec un moteur totalement muet, c'est-à-dire exactement la panne cherchée.
  7. Les tests du micro se passaient l'état sans le dire — trouvé par le nouveau passage Windows, quelques minutes après sa création. Le faux périphérique a mis plus de 900 ms à s'ouvrir sur un runner chargé (attente fixe → attente d'une condition), et le test suivant, qui supposait le micro fermé, a vu son clic l'arrêter au lieu de le démarrer. Il a alors rapporté « un seul getUserMedia doit partir, vu 0 » : une panne qui n'existait pas. Un test qui se trompe de diagnostic coûte plus cher qu'un test qui tombe.

Deux garde-fous qui ne gardaient plus rien, aussi : le plancher de la liste de mutations était resté à 27 pour 34 mutants — on pouvait en supprimer sept sans que rien ne le dise (remonté à 34) ; et ma propre étape d'analyse PowerShell pouvait passer sans rien analyser, parce qu'en PowerShell $null.Count vaut 0. Elle exige maintenant d'avoir obtenu des jetons, et vérifie qu'un script volontairement cassé est bien refusé.

Quatre relectures adversariales ont trouvé dix-huit défauts réels, dont cinq bloquants — dont la moitié dans du code écrit pour corriger d'autres défauts.


Documentation

CHANGELOG.md, README.md, CLAUDE.md, docs/ETAT-DU-PROJET.md, docs/PLAN-V2.md, docs/V2-AXES-PISTES.md, docs/V2-TESTS-MADMAPPER.md, et le manuel PDF (racine et dist/). docs/V1-V2.md devient une pierre tombale : il décrivait une migration terminée.

Une limite reste ouverte et signalée à Pym, non corrigée : resolveBars() se replie sur toutes les barres actives quand le groupe d'une couche est vide. Un test de caractérisation la fige — elle ne changera plus par accident.


Vérifications

  • npm test328 / 328, aucun ignoré, sur Ubuntu et sur Windows
  • Campagne de mutation complète → 36 / 36 détectées, zéro aveugle, server.js restauré à l'identique
  • node sync-dist.js --checkdist/ synchrone (règle n°1)
  • Zéro dépendance npm ajoutée (règle n°2), vérifié par la CI
  • Sémantiques non négociables intactes : STOP relâche le contrôle, seul BLACKOUT envoie des zéros, phase préservée au changement de tempo, l'arrêt auto épargne un show

🤖 Generated with Claude Code

https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH

claude added 5 commits August 4, 2026 15:13
…ait verte

`findBrowser()` ne regardait que les chemins système. Les conteneurs installent
Chromium sous `PLAYWRIGHT_BROWSERS_PATH`, jamais dans `/usr/bin` : la suite
d'interface s'y annonçait « ignorée », la sortie restait verte, et personne ne
voyait que 32 tests sur 262 ne tournaient pas. C'est le même mode de panne que
la palette spatiale morte en silence le 28/07.

Deux voies ajoutées avant les chemins système : `CASCADE_NAVIGATEUR` pour
imposer un binaire à la main, puis un balayage de l'installation Playwright
(le sous-dossier porte la révision, donc on cherche plutôt que de deviner ;
le Chromium complet est préféré au `headless_shell`).

Chromium refuse ensuite de démarrer en root sans `--no-sandbox`, et les
conteneurs tournent en root. Le drapeau n'est ajouté que dans ce cas : sur une
machine de développement le bac à sable reste en place.

En CI, l'absence de navigateur n'était qu'un `echo`. Elle échoue maintenant :
en local le repli gracieux se défend, en CI c'est un angle mort.

Les 32 tests passent — aucune régression ne se cachait derrière le skip.
262 tests, zéro échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
`ETAT-DU-PROJET.md` est le fichier que le projet impose de lire en premier. Il
annonçait la version 1.6.0 en tête et 1.2.0 six lignes plus bas, « `main` est la
1.6.0 ; le front de développement est `v2` », 111 tests, un server.js de 1400
lignes, et un schéma d'état sans rien de la v2 — ni `p3`, ni `field`, ni
palette, ni crossfader, ni vues. Il se termine pourtant par : « le laisser
mentir coûte une demi-journée à celui qui reprend ».

Le plus coûteux était ailleurs. `CLAUDE.md` présentait deux défauts comme
ouverts et « déjà en v1 » : la couche couleur qui allume les barres qu'elle ne
pilote pas, et la phase non préservée au changement de tempo. **Les deux sont
corrigés depuis fin juillet** — vérifié dans le code, pas dans les docs :
`mixLevel()` décide barre par barre, `phaseContinue()` intègre `dt / period`.
Tous deux listés « Corrigé » au CHANGELOG et couverts par des tests. Ils
venaient d'une relecture arrêtée avant sa phase de vérification ; la consigne
envoyait donc rechasser du gibier mort.

Reste une piste, elle, bien ouverte et non couverte, notée à la place :
`resolveBars()` replie sur TOUTES les barres actives quand le groupe d'une
couche est vide — seule voie vivante qui reproduit le symptôme d'origine.

Le reste, par fichier :

- comptes remis d'aplomb partout : 262 tests (pas 248 ni 111), 27 mutants
  (pas 21), tailles réelles des sources, badges du README ;
- le pivot de `output/rot` est mesuré depuis le 29/07, mais quatre documents le
  disaient encore « jamais fait » : propagé, et il ne reste que DEUX mesures
  MadMapper, pas trois ;
- `V2-TESTS-MADMAPPER.md` : le haut du fichier contredisait le bas. T11 à T16,
  T18 et T21 étaient mesurés dans les sections de résultat mais leurs cases
  restaient vides — cochées et reliées à leur résultat. On a failli refaire T14 ;
- `PLAN-V2.md` : une « Étape 4 — à faire » traînait juste au-dessus d'une
  « Étape 4 ✅ » ; la boîte du champ y était encore décrite en coques de
  Tchebychev alors que c'est un pavé mobile ; trois items « non faits » sont
  livrés depuis la 2.0. Le fichier est marqué historique ;
- `V1-V2.md` organisait la cohabitation de `main` et d'une branche `v2` qui
  n'existe plus, et disait de committer dessus. Remplacé par une pierre tombale ;
- trois pièges ajoutés : une suite verte ne prouve pas que tout a tourné, les
  tests d'interface ne sont pas Playwright, et les fichiers hors dépôt sont
  absents des clones frais — donc des sessions distantes et de la CI.

Les tests d'interface étaient d'ailleurs listés « à faire » alors qu'ils
existent depuis la 2.0. Troisième piste faite qui traînait dans cette liste,
après la synchro de phase Link et le `dist/` généré : la purger fait partie du
travail de livraison.

262 tests, zéro échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Six commits attendaient depuis la 2.0.1, dont un correctif bloquant : le
crossfader posé en butée — la position de repos de tout fader — rallumait le
plateau entier. Ils sortent.

Version passée à 2.0.2 dans package.json, server.js et dist/ (via
`node sync-dist.js`, qui aligne dist/package.json tout seul). Le manuel PDF
n'est PAS régénéré, et c'est volontaire : `build-manuel.py` porte une version
majeure.mineure, comme le pied de page de l'interface, et cette version
n'apporte ni réglage ni fonction. Vérifié en interrogeant un vrai serveur :
`/api/ping` répond 2.0.2, le pied de page affiche « CASCADE v2.0 ».

La section `[Non publié]` du CHANGELOG est datée, et complétée par ce qui
n'y figurait pas encore :

- le lot « tests » — 32 tests d'interface qui ne tournaient pas, un test qui ne
  mesurait rien, un test de redémarrage qui ne redémarrait rien, et le filet
  d'`afterEach` du champ 3D (garantie structurelle, pas correction : la cascade
  d'échecs a été cherchée sans être reproduite, c'est dit tel quel) ;
- le lot « documentation » — les fichiers de reprise annonçaient la 1.6.0, et
  deux défauts présentés comme ouverts étaient corrigés depuis la 2.0.1 ;
- les deux mesures MadMapper consignées depuis : le pivot de `output/rot`, et
  les motifs OSC que MadMapper ne développe pas.

`tools/notes-version.js 2.0.2` extrait bien la section : la description de la
release GitHub viendra donc du CHANGELOG, pas d'un texte recopié à côté.

Au passage, le tableau de téléchargement du README annonçait encore les
exécutables **1.6.0** — deux versions majeures en retard, sur la seule page que
lit quelqu'un qui vient télécharger. Il est rendu insensible à la version
(`Cascade-<version>-…`) plutôt que corrigé une fois de plus.

⚠ Le tag `v2.0.2` n'est PAS posé : il déclenche la construction des quatre
exécutables et la publication de la release, et il attend que cette branche
soit fusionnée et que Pym le décide.

262 tests, zéro échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Une rangée de boutons qui se replie selon la largeur de la fenêtre : en régie,
dans le noir, on vise une case dont on croit connaître la place et elle a bougé.
C'est une grille — 8 colonnes sur poste, 4 sur tablette — et chaque pavé porte
trois choses qu'il fallait deviner jusqu'ici.

**L'empreinte** : une case par barre que le preset pilote, teintée comme la
couche qui la pilote. C'est une COUVERTURE, pas une image du motif — un chase
n'allume qu'une barre à la fois, une photo serait presque vide et changerait à
chaque image. Elle passe par `resolveBars`, donc elle ment exactement comme
mentirait le moteur : un groupe vidé la ramène sur toutes les barres, ici comme
en scène. Une vignette qui corrigerait le moteur serait pire qu'inutile.

**Le pavé qui joue**, et celui d'où l'on vient pendant un fondu. `presetActif`
est posé dans `recallPreset` et pas dans la route : le rappel arrive aussi par
OSC et par MIDI, et le poser au seul endroit que tous traversent évite d'en
oublier un.

**« Ce preset ne pilote plus aucune barre »**, en bord pointillé. C'est
l'information la plus utile avant de tirer un pavé ; la masquer laisserait
croire à une panne au moment du rappel.

Le coût du poll était le vrai sujet. `/api/state` part HUIT FOIS PAR SECONDE :
y loger 16 empreintes aurait coûté une demi-kilo-octet à chaque tour pour une
donnée qui ne change qu'à l'enregistrement. Elles sont donc servies par une
route dédiée, tirée seulement quand un compteur de révision bouge, et
`/api/state` ne gagne que trois entiers. Un rappel n'incrémente PAS ce compteur :
la banque n'a pas changé, et recharger les empreintes à ce moment-là ferait une
requête de plus au pire moment. Un test vérifie qu'aucune empreinte n'est
réapparue dans `/api/state`.

Le calcul n'instancie aucun moteur, ne touche à aucun état d'exécution et
n'émet aucun OSC : appelable en plein spectacle. Si la route échoue, les pavés
s'affichent sans bande et sans un bruit — c'est testé.

Corrigé au passage, trouvé en lisant ce chemin : `sanitizePresets` coupait les
noms à 12 là où l'enregistrement et le renommage coupent à 16. Le rabotage
n'arrivait ni à la saisie ni au rappel, mais au RECHARGEMENT du fichier, quand
plus personne ne regarde — « Refrain final 2 » revenait « Refrain fin ».

Une piste de la conception n'a PAS été suivie : elle signalait que la signature
de rendu confondait ['AB', null] et ['A', 'B']. Vérifié dans le source — le
`join` utilise un séparateur \x01 invisible, il ne confond rien. Le code
existant est resté tel quel.

Neuf tests ajoutés (271 au total), dont cinq dans un vrai navigateur : la
grille et son empreinte, le pavé qui joue et le pavé sortant, le preset muet,
un nom hostile qui n'injecte rien, et la dégradation quand la route échoue.
Deux anti-mutants gardent un chemin qui n'en avait aucun ; les deux ont été
vérifiés tués, pas seulement ajoutés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Deux lignes, mais c'est précisément le fichier qui vient d'être remis d'aplomb
pour avoir menti sur sa version. Le laisser diverger dès le commit suivant
aurait été une leçon mal retenue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
@pymenvert pymenvert changed the title Réparer les 32 tests d'interface qui ne tournaient pas, et remettre les docs d'accord avec le code Tests d'interface qui ne tournaient pas, docs remises d'aplomb, 2.0.2 et grille de presets Aug 4, 2026
Le micro se branche là où l'oscillateur l'était déjà. Un modulateur — de couche
ou global — gagne un réglage Source : « Oscillateur (boucle) » ou « Micro ». Rien
d'autre ne change, donc le micro pilote tout ce qu'un modulateur pilotait :
niveau, largeur, netteté, profondeur, crossfader, master.

C'est le choix qui rend le reste possible. Router l'enveloppe par `midiApply`
aurait ÉCRIT le niveau dans `state.layers[].level` seize fois par seconde : il
aurait été sauvegardé, exporté, figé dans les presets — mot pour mot le défaut
que le commentaire de `appliquerLFO` dit avoir évité. Ici la valeur vit le temps
d'une image et hérite gratuitement du non-écrasement de l'état, du voyage dans
les presets, et du relâchement à STOP (`tick` sort avant les modulateurs).

DEUX LIMITES, dites dans l'aide, dans le panneau et au CHANGELOG :

- il lit une ÉNERGIE, pas un tempo. Décision de Pym : on livre l'énergie, il
  l'essaie en salle, et on décide ensuite de la détection de battement — ~150
  lignes de DSP dont la justesse dépend du répertoire, avec l'erreur d'octave
  comme échec structurel, invalidable hors d'une vraie salle ;
- le micro n'est lisible que sur la machine hôte : les navigateurs l'interdisent
  hors origine sûre. Assumé et écrit, comme pour Web MIDI. Pas de HTTPS
  auto-signé (certificat à générer, openssl absent de Windows, écran
  d'avertissement à chaque connexion — impossible à demander à un régisseur).
  Sans micro la page ne pousse rien : un iPad ne peut pas écraser l'hôte.

LA PROPRIÉTÉ DE SÉCURITÉ, et elle a son anti-mutant : la péremption REND LA
MAIN. Sans niveau frais depuis 700 ms — onglet fermé, micro débranché, machine
en veille — le réglage du régisseur reprend, avec un fondu. Retomber sur `min`
aurait été le noir en plein show sur un master à min 0 ; rester figé aurait
cloué le spectacle sur ce qu'entendait un onglet fermé.

Aucun conflit avec Ableton Link, et c'est mieux que de bien l'arbitrer : le
suiveur n'écrit jamais `stepMs`, donc `applyLinkBpm` n'a rien à écraser.

La calibration vit dans `state.settings` et NON dans le localStorage — écart
assumé avec le plan de conception : la promesse du projet est que la régie tienne
sur une clé USB, et un réglage laissé dans le navigateur resterait sur la machine.

Vérifiable sans micro, ce qui a décidé du choix : la moitié serveur se teste en
poussant un niveau en query, et la DSP est isolée en fonction PURE nourrie d'un
spectre fabriqué à la main. Le chemin nominal s'exécute quand même pour de vrai —
Chromium est lancé avec un faux périphérique audio, donc getUserMedia,
AudioContext et la boucle d'analyse tournent réellement en test.

Deux pièges rencontrés en écrivant les tests, tous deux du genre que ce dépôt a
déjà payé : le cache d'envoi ne réémet pas une valeur inchangée, donc quatre
assertions passaient sur un tableau VIDE sans rien mesurer — elles lisent
maintenant les niveaux calculés, avec une garde qui refuse un relevé vide ; et le
test STOP pouvait passer même si le moteur n'émettait jamais rien, il a désormais
un témoin positif.

19 tests ajoutés (290 au total), dont 6 dans un vrai navigateur. Le nouvel
anti-mutant a été vérifié tué, pas seulement ajouté.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
@pymenvert pymenvert changed the title Tests d'interface qui ne tournaient pas, docs remises d'aplomb, 2.0.2 et grille de presets Tests d'interface réparés, docs remises d'aplomb, 2.0.2, grille de presets et suiveur audio Aug 4, 2026
claude added 2 commits August 4, 2026 21:07
Une trentaine de réglages à plat, dont beaucoup ne se touchent qu'au montage.
Deux replis les rangent — « Symétries » (les deux miroirs et leurs axes) et
« Mélange & espace » (fusion, profondeur, décalage réparti, jeu du crossfader).

Rien d'inventé : c'est le motif de « Groove & découpe », déjà en place depuis la
1.3. La CSS des replis était d'ailleurs écrite au PLURIEL et non préfixée, donc
prévue pour être réutilisée : les deux nouveaux `details` héritent du style sans
une ligne de CSS de plus.

Chacun porte une pastille qui compte ET nomme ce qui est hors de sa valeur
neutre. Sans elle, un miroir ou une fusion oubliés derrière un repli fermé
deviennent un mystère — c'est exactement ce que la pastille de Groove évite
déjà.

Le repli s'ouvre tout seul en ARRIVANT sur une couche qui a des réglages
actifs, et pas à chaque rendu. C'est le piège documenté de Groove : rouvrir en
continu empêche l'utilisateur de refermer quoi que ce soit. Un test le
verrouille — il simule une vraie arrivée en effaçant le marqueur de couche, puis
referme et vérifie que ça reste fermé sur cinq cycles de rendu.

Écrire ce test a d'ailleurs confirmé le mécanisme : ma première version le
croyait cassé, alors qu'elle réutilisait une couche déjà visitée par le test
précédent. Le comportement était juste, c'est le test qui mesurait mal.

292 tests, dont 45 dans un vrai navigateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Cascade s'ouvre depuis un iPad, donc depuis le Wi-Fi de la salle. Un code
facultatif empêche un curieux de prendre la main sur la lumière en plein show.

QUATRE CHIFFRES, C'EST 10 000 COMBINAISONS. Sans limitation de tentatives, ça
se casse en quelques secondes et le code ne protège RIEN : la limitation n'est
donc pas un raffinement, c'est la fonction elle-même. Cinq essais ratés bloquent
l'appareil une minute, et pendant ce blocage même le bon code est refusé.

Ma première version avait exactement ce défaut, et la mesure l'a montrée :
« encore 4 essais » indéfiniment. `jusqua <= maintenant` est vrai aussi pour
`jusqua = 0`, c'est-à-dire quand il n'y a jamais eu de blocage, donc le compteur
repartait de zéro à chaque tentative. Corrigé, et un anti-mutant reproduit la
forme fautive à l'identique — vérifié tué.

Trois choix de périmètre, assumés et écrits dans l'aide :

- le code n'est JAMAIS demandé sur la machine hôte. C'est ce qui garantit qu'on
  ne peut pas s'enfermer dehors, et c'est le bon périmètre : la menace est le
  Wi-Fi de la salle, pas la machine qu'on a physiquement sous la main ;
- la PAGE reste servie sans le code — sinon il serait impossible d'afficher la
  demande. C'est l'API qui est fermée, pas l'interface ;
- l'OSC et le MIDI ne sont pas concernés : câble ou console sur le réseau de
  production, autre domaine de confiance, et les fermer casserait les
  installations existantes.

Le secret ne fuit pas. On stocke un haché salé, et ce haché ne sort JAMAIS du
serveur — trouvé en le testant : il partait dans `/api/state` avec le reste des
réglages, et un haché de 4 chiffres se casse hors ligne instantanément, donc
l'envoyer revenait à envoyer le code. Retiré aussi de `/api/settings`. L'export
de projet, lui, ne portait déjà pas `settings`. Le jeton de session voyage en
cookie HttpOnly + SameSite=Strict : la page ne le lit jamais, donc un nom de
fixture piégé ne peut pas le faire fuir.

Détail qui compte en régie : une page qui affiche la demande de code compte
quand même comme une interface ouverte. Sans ça, l'arrêt automatique pouvait
couper Cascade pendant que le régisseur tape son code. Le pire qu'un curieux
puisse faire est donc de garder le serveur allumé, sans rien piloter.

Le portillon ne se voit que depuis une adresse NON locale, donc ses tests
cherchent une vraie interface réseau — et, comme pour le navigateur, la CI
exige désormais qu'il y en ait une : un « ignoré » ne doit jamais passer pour un
« réussi ». C'est la troisième fois que ce mode de panne se présente dans ce
dépôt.

Corrigé en passant : le fichier de configuration écrivait la clé `scene` deux
fois dans le même objet.

303 tests, 31 mutations sur 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
@pymenvert pymenvert changed the title Tests d'interface réparés, docs remises d'aplomb, 2.0.2, grille de presets et suiveur audio Tests réparés, docs remises d'aplomb, 2.0.2, grille de presets, suiveur audio, replis et code d'accès Aug 4, 2026
claude added 12 commits August 5, 2026 05:19
…ait pas assez

Les fonctions des commits précédents ont été relues avec une seule consigne :
essayer de les casser. Cinq trous réels, tous corrigés, tous couverts par un test
vérifié en échec sans son correctif.

⚠ LE PLUS SÉRIEUX — LE SUIVEUR AUDIO PLONGEAIT VERS LE NOIR EN DÉCROCHANT, et
ma propre documentation affirmait le contraire.

Le fondu de péremption faisait décroître le NIVEAU vers 0. Or
`min + (max−min) × niveau` envoie un niveau nul sur `min` : le paramètre visait
donc sa borne basse pendant 450 ms avant de rendre la main d'un coup. Sur le
master avec min à 0, c'est le noir en plein spectacle — exactement ce que le
commentaire de la fonction, le CHANGELOG et l'anti-mutant promettaient d'éviter.
La fraîcheur dose maintenant le RETOUR À LA MAIN et non le niveau : la valeur
glisse du micro vers le réglage posé à la main, sans jamais viser `min`.

Et le test de sécurité qui gardait cette propriété ne la gardait qu'à moitié : il
ne mesurait qu'APRÈS la péremption complète, donc il ratait le creux au milieu.
Vérifié : en réintroduisant le défaut, l'ancien test passe, le nouveau tombe.
C'est la quatrième fois dans cette branche qu'un test mesure moins qu'il ne
promet — le motif est constant, et c'est toujours la mesure qui le révèle.

LA LIMITATION DU CODE D'ACCÈS S'EFFONDRAIT FACE À PLUSIEURS ADRESSES. Le blocage
par IP arrête un curieux, mais un attaquant qui fait varier sa source — trivial
en IPv6, où il tient tout un /64 — retrouvait les 10 000 combinaisons. Le commit
précédent affirmait « sans limitation, le code ne protège RIEN » ; la limitation
existait mais se contournait, donc l'affirmation était fausse. Un plafond GLOBAL,
indépendant de l'IP, s'ajoute : 30 échecs par minute tous clients confondus et
tout le réseau est refusé. La machine hôte n'est jamais prise dedans — vérifié.
Au passage `accesEssais` grandissait sans borne : oubli des adresses inactives,
balayage des jetons expirés, plafond dur.

CSRF SUR LA MACHINE HÔTE. L'exemption locale laisse passer localhost sans jeton,
c'est voulu. Mais rien n'empêchait une page piégée ouverte dans le navigateur de
l'hôte de poster un formulaire vers `/api/new` (efface le projet), `/api/quit` ou
`/api/blackout`. Les POST exigent désormais `Content-Type: application/json` :
un formulaire ne peut pas poser ce type. Vérifié d'abord que toute l'UI l'envoie
déjà et que les lanceurs ne font que du GET — le garde ne casse rien.

L'EMPREINTE D'UN PRESET POUVAIT MENTIR. Elle passe par `resolveBars`, qui résout
les groupes VIVANTS — un preset n'en mémorise pas. Modifier le contenu d'un
groupe changeait donc ce qu'un preset piloterait au rappel, sans que la vignette
bouge. Deux routes rafraîchissent le compteur de révision. J'ai vérifié que la
géométrie, elle, n'entre pas dans l'empreinte : trois autres routes soupçonnées
sont hors de cause, elles ne sont pas touchées.

DOUBLE CLIC SUR « ÉCOUTER » : DEUX MICROS OUVERTS. `suiveur.actif` n'était posé
qu'après `getUserMedia` ET `resume()` — or la demande d'autorisation est le
moment le plus long, et celui où l'on reclique. Le second démarrage écrasait le
flux et le contexte du premier, qui restaient orphelins : micro jamais relâché,
AudioContext fuité, deux boucles d'analyse. Garde synchrone avant tout `await`.

Ajouté aussi : un test de caractérisation sur la SEULE voie encore vivante du
défaut v1 — vider un groupe (au lieu de le supprimer) rend la couche à tout le
plateau. Il ne corrige rien, il verrouille un choix documenté pour qu'un mutant
ne le change pas en silence. C'était la piste laissée ouverte dans `CLAUDE.md`.

Le garde-fou anti-mutant a fait son travail pendant ce commit : mon refactor a
changé `Date.now()` en `now` dans la ligne surveillée, la forme saine a disparu,
la suite est tombée. C'est précisément ce pour quoi il existe.

README complété (suiveur audio, grille de presets) et la note « il n'y a pas de
mot de passe » corrigée — elle était devenue fausse.

309 tests, 31 mutations sur 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Le PDF en était resté à la 2.0 : il ne parlait ni du suiveur audio, ni de la
grille de presets, ni des replis, ni du code d'accès. Pire, il affirmait encore
« il n'y a pas de mot de passe : toute personne sur le même réseau peut piloter
le show » — devenu faux, sur la page même qui explique comment connecter un iPad.

Quatre sections ajoutées, écrites pour un régisseur, pas pour un développeur :

- 3.3 Le code d'accès — ce qu'il protège, pourquoi il n'est jamais demandé sur
  la machine hôte, pourquoi l'OSC et le MIDI n'en dépendent pas, et la limite
  assumée : il ne protège pas contre quelqu'un qui a le fichier de configuration.
- 4.7 Les replis — et surtout la règle qui compte : la pastille garantit qu'un
  réglage caché ne l'est jamais silencieusement.
- 8.8 Le suiveur audio — avec les deux limites en note, énergie et pas tempo,
  micro seulement sur la machine hôte, plus le comportement de sécurité quand le
  son décroche.
- 10 La grille de presets — en insistant sur ce que la bande de couleur EST :
  une couverture, pas une image du motif. C'est la question qu'un régisseur se
  pose devant un pavé (« qu'est-ce que ça va allumer ? »), et une vignette prise
  pour une photo tromperait.

Vérifié plutôt que supposé : le manuel passe de 15 à 17 pages, et le contrôle de
glyphes du générateur reste vert (29 caractères non-ASCII, tous dessinables) —
c'est lui qui empêche les carrés vides du piège n°4. PDF recopié à la racine ET
dans dist/, comme l'impose la règle n°1.

⚠ Note ajoutée à CLAUDE.md : la prochaine version est une MINEURE (2.1.0, pas
2.0.3). La section non publiée contient des fonctions nouvelles, donc le
versionnage sémantique l'impose — et il faudra passer `VERSION = "2.1"` dans
`build-manuel.py`, qui porte majeure.mineure.

Au passage, une dépendance système manquait pour générer le manuel sur une
machine nue : `fonts-dejavu-extra` (l'italique). Le générateur le disait
correctement — « aucune police utilisable » — mais sans nommer le paquet.

309 tests, zéro échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Sur une machine nue, `build-manuel.py` s'arrêtait sur « Aucune police utilisable
trouvée » alors que DejaVu était installé. La cause : il exigeait les QUATRE
fichiers d'une famille, or l'oblique vit dans un paquet séparé
(`fonts-dejavu-extra`) rarement présent, et le repli reportlab échouait de la
même façon (`VeraMono.ttf` absent). Condamner tout le manuel pour une italique
manquante est disproportionné : une note en romain reste lisible, pas de manuel
du tout ne l'est pas.

Le régulier suffit désormais à s'en sortir : chaque fichier manquant est
remplacé par lui, et le script DIT lequel et quel paquet l'apporte. Le contrôle
de glyphes, lui, reste strict — c'est le garde-fou du piège n°4 (carrés vides),
et il n'a rien à voir avec la disponibilité d'un fichier de police.

Le message d'échec ultime nomme maintenant les commandes d'installation par
plateforme, au lieu d'une liste de noms de familles qui n'aide personne.

Vérifié dans les deux sens : avec toutes les polices, et en retirant réellement
l'oblique du système — le manuel se génère dans les deux cas, avec l'avertissement
dans le second.

Consigne d'installation ajoutée à CLAUDE.md, y compris le piège où j'ai perdu du
temps : le dossier de sortie doit exister, sinon reportlab échoue à la toute fin,
après avoir tout mis en page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Bloquer tout le réseau après 30 échecs arrête un attaquant multi-adresses, mais
laisse quelqu'un empêcher volontairement les AUTRES appareils d'entrer pendant
une minute. C'est un déni de service, et il ne faut pas le découvrir en salle.

Le compromis est assumé et noté à côté du code : c'est le moins cher des deux.
Le régisseur garde sa machine, jamais bloquée ; l'alternative — laisser passer —
rendrait le code cassable. Une gêne d'une minute contre la prise de contrôle du
spectacle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Le « 31 mutations sur 31 détectées » de la documentation n'avait jamais été
vérifié d'un bloc — le chiffre était additionné à la main, mutant par mutant, au
fil des commits. Il est maintenant mesuré : campagne complète, 31 cassages
volontaires, 31 détectés, zéro aveugle, et `server.js` restauré identique à la
fin (le contrôle que fait l'outil lui-même).

Elle a tourné dans une COPIE du dépôt : `tests/mutation.js` modifie `server.js`
en place, donc la lancer dans l'arbre de travail interdit d'y toucher pendant une
heure — et un commit parti à ce moment-là emporterait un mutant. C'est exactement
l'accident du 28/07.

Le fichier de reprise gagne ce qui lui manquait sur les fonctions récentes :

- les deux routes ajoutées (`/api/presets-info` et `/api/acces`) et POURQUOI la
  première est hors de `/api/state` ;
- les DEUX gardes qui pèsent désormais sur toutes les routes `/api/` — le
  portillon du code d'accès, et l'exigence de `Content-Type: application/json`
  qui ferme la CSRF locale. Quelqu'un qui ajoute une route doit savoir qu'elles
  existent avant de se demander pourquoi son `curl` reçoit un 415 ;
- le haché du code d'accès, avec l'avertissement qu'il ne sort JAMAIS du serveur
  mais qu'il est en clair dans `cascade-config.json` — limite assumée ;
- la calibration audio dans `settings` et non dans le `localStorage`, avec sa
  raison (la régie tient sur une clé USB) ;
- `src` sur les deux modulateurs, et son repli sur `'lfo'` qui garantit qu'une
  vieille configuration se comporte comme avant.

L'en-tête dit maintenant qu'il y a quatre fonctions non publiées et que la
prochaine version est une mineure — pour qu'on ne tague pas une 2.0.3 par
réflexe.

309 tests, 31 mutations sur 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Le risque de cette mécanique était le blocage perpétuel : si le compteur d'échecs
ne repartait pas de zéro à la levée, le plafond se redéclencherait aussitôt et
plus aucun iPad n'entrerait jamais. Raisonner ne suffisait pas.

Mesuré, fenêtre ramenée à 2 s pour l'observer : six échecs sur six adresses
distinctes bloquent une adresse jamais vue ; après la fenêtre, un échec rend à
nouveau 401 et le bon code passe. Le blocage se lève.

La raison est notée avec la mesure, parce qu'elle est fragile : la durée du
blocage vaut celle de la fenêtre, donc au moment où il se lève la fenêtre est
forcément expirée et le compteur repart. Raccourcir le blocage sous la fenêtre
casserait cette propriété — et personne ne le devinerait en relisant.

Ce n'est pas testable dans la suite sans l'immobiliser une minute, d'où la
mesure à la main et sa trace ici.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Faire relire les correctifs de la relecture précédente était le bon réflexe :
deux d'entre eux étaient contournables ou nuisibles, et les deux ont été mesurés
contre un vrai serveur, pas déduits.

⛔ LE GARDE CSRF SE CONTOURNAIT EN UNE LIGNE. Il testait une SOUS-CHAÎNE.
`Content-Type: multipart/form-data; boundary=application/json` la contient — et
la règle CORS ne regarde que l'ESSENCE du type, en ignorant les paramètres. Ce
type est donc safelisté : un `fetch` en `no-cors` le pose sans pré-vol. Mesuré :
`/api/quit` tuait le serveur, `/api/acces` posait le code de l'installation. Le
commentaire que j'avais écrit affirmait « un formulaire ne peut pas poser ce
type » — c'était vrai, et à côté de la question : la brèche était un fetch.
On compare maintenant l'essence.

⛔ LE DIALOGUE RÉGLAGES RETIRAIT LE CODE D'ACCÈS À CHAQUE ENREGISTREMENT. Le
champ est vidé à chaque ouverture, puisque le serveur ne renvoie jamais le code —
et un champ vide déclenchait un retrait. Ouvrir Réglages pour changer le port
MadMapper et cliquer Enregistrer supprimait donc le code en silence, sans même un
message, et déconnectait toutes les tablettes. Mon propre commentaire disait
« laisser vide veut dire ne rien changer » : le code faisait l'inverse.
Un champ vide ne touche plus à rien, et le retrait est passé sur un bouton dédié
avec confirmation — un geste destructeur doit être un geste, pas un effet de bord.

Trois autres, moins graves. Une balise `<img src="…/api/state?a=1">` sur une page
piégée pouvait clouer un modulateur audio en butée (le niveau n'est plus accepté
que d'une requête de script). Un rappel de preset qui remplace le plateau change
l'empreinte des presets sans disposition propre — les projets importés en ont.
Et changer d'entrée son pile pendant le démarrage du micro pouvait avaler le
redémarrage.

⚠ UN ANTI-MUTANT AJOUTÉ AU PREMIER JET SURVIVAIT : aucun test n'envoyait
l'en-tête qu'il surveillait. Un garde-fou que rien ne tue est un faux garde-fou,
et il est pire qu'aucun puisqu'il rassure. Vérifié un par un cette fois : les 33
mutants sont tueurs.

Le compte de la session : trois passes de relecture, dix défauts réels, dont
quatre bloquants — et la moitié dans du code écrit pour en corriger d'autres.

313 tests, 33 mutations sur 33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
…te pour Pym

Campagne complète repassée après les derniers correctifs : 33 cassages
volontaires, 33 détectés, zéro aveugle, aucun motif absent, et `server.js`
restauré identique. Les deux anti-mutants ajoutés hier — le garde CSRF et le
niveau audio poussé par une balise image — sont bien tueurs dans la campagne
entière, pas seulement quand on les vérifie isolément.

La consigne d'usage est notée avec le chiffre : lancer la campagne dans une COPIE
du dépôt. Elle modifie `server.js` en place pendant une heure, et un commit parti
à ce moment-là emporterait un mutant — c'est exactement l'accident du 28/07.

⚠ UNE LIMITE RESTE OUVERTE, ET JE NE LA CORRIGE PAS TOUT SEUL. Rien ne vérifie
l'en-tête `Host` : une page hébergée sur un domaine qui résout vers 127.0.0.1
devient même origine aux yeux du navigateur, et tous les gardes CSRF tombent avec
elle (« DNS rebinding »). Le correctif tient en cinq lignes — refuser un `Host`
qui n'est ni localhost ni une adresse du réseau local.

Mais il casserait l'accès par nom d'hôte, `http://mac-de-pym.local:3333`, qui
fonctionne aujourd'hui. C'est un échange : une attaque sophistiquée contre une
façon de se connecter qui marche. Ça se décide avec celui qui s'en sert, pas
depuis une session. La limite est donc écrite à côté du code, avec le correctif
possible et ce qu'il coûte.

313 tests, 33 mutations sur 33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Décidé avec Pym : il accède à la régie par le QR code ou par l'adresse IP, donc
on peut fermer la faille sans rien lui casser.

Sans ce garde, tous les autres tombaient. Une page sur `mechant.com` dont le DNS
se ré-résout vers 127.0.0.1 devient « même origine » pour le navigateur : elle
pose le Content-Type qu'elle veut, le cookie SameSite ne protège plus, et Cascade
l'exempte du code d'accès puisque la requête vient de localhost. Contrôle complet
de la lumière et lecture du projet, depuis n'importe quelle page web, sans même
être sur le réseau. Le garde CSRF et le code d'accès ne servaient à rien contre
ça — c'est la raison d'être de ce commit.

Une requête rebindée porte un `Host` étranger, et c'est là qu'on la coupe. Sont
acceptés : `localhost`, les adresses IP littérales (ce que donne le QR code), et
les noms en `.local`.

Ce dernier point mérite d'être compris avant d'être touché : `.local` est réservé
au mDNS par la RFC 6762. Personne ne peut en posséder un sur Internet, et le
système ne le résout que sur le réseau local — un attaquant ne peut donc pas s'en
servir pour un rebinding. C'est ce qui permet de garder `mac-de-pym.local:3333`
sans rouvrir la faille. Un domaine qui s'en approche, lui, est bien refusé :
`evil.local.attaquant.com` ne se termine pas par `.local`.

Une requête sans en-tête `Host` passe : un navigateur en envoie toujours un, donc
son absence n'est jamais une attaque par rebinding — seulement du HTTP/1.0 ou un
`curl` nu.

La PAGE est refusée elle aussi, pas seulement l'API. La servir puis laisser
chacun de ses appels échouer ressemblerait à une panne ; le refus, lui, dit quoi
utiliser à la place.

Restent refusés, assumé : les noms de machine Windows sans suffixe et les alias
du fichier `hosts`.

Vérifié contre un vrai serveur, dans les deux sens, et l'anti-mutant est tueur.

314 tests, 34 mutations sur 34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
…ws seulement

La demande n°1 de Pym, arbitrée avec lui : point vert quand le show tourne,
rouge à l'arrêt, clic droit pour ouvrir l'interface, démarrer, arrêter, quitter.

Node n'a AUCUNE API de zone de notification — cette icône demande du code natif,
et c'est pour ça que la demande dormait. Sous Windows, PowerShell donne accès à
`System.Windows.Forms.NotifyIcon`, présent d'origine : zéro installation, zéro
dépendance npm. La règle n°2 tient, et c'est ce qui a décidé du choix.

⚠ WINDOWS SEULEMENT, et c'est un choix, pas un oubli. macOS n'a pas d'équivalent
scriptable — un élément de barre de menus demande une application compilée ou un
outil tiers. Plutôt qu'une fonction bancale partout, une fonction franche là où
se trouve la régie. Hors Windows, la case est estompée et l'écran le dit.

⚠ ÉTEINTE PAR DÉFAUT, ET JAMAIS EXÉCUTÉE. Ce code PowerShell a été écrit depuis
une machine Linux : je n'ai pas pu le lancer une seule fois. Le prétendre
fonctionnel serait malhonnête. Tout est donc enveloppé — plateforme, réglage,
try/catch, gestionnaires d'erreur sur le processus — de sorte qu'un échec soit
silencieux et ne touche jamais le serveur. Un test le vérifie : on active le
réglage sous Linux, où PowerShell n'existe pas, et Cascade doit continuer de
répondre. Le premier essai sur une vraie machine Windows reste à faire.

Le script est EMBARQUÉ dans server.js, pas posé à côté : le distribuable est un
exécutable unique, et un `.ps1` externe n'existerait pas près de lui. Il est
écrit dans le dossier temporaire au démarrage. Le dialogue passe par l'API HTTP
de Cascade plutôt que par un tuyau maison — le script sait déjà parler à
localhost, et ça évite d'inventer un protocole. Il respecte au passage les deux
gardes ajoutés hier : `Content-Type: application/json` et un `Host` en 127.0.0.1.

Limite honnête du montage, écrite dans le code : quand le serveur s'arrête,
l'icône DISPARAÎT au lieu de passer au rouge. Une fois Cascade parti, il ne reste
rien pour la dessiner. Faire survivre l'icône au serveur demanderait que le
lanceur en soit le propriétaire — un autre montage, plus lourd.

Note de méthode : la suite a échoué une fois pendant ce travail, sur les tests
d'interface. Trois passes complètes ensuite, toutes vertes. C'était donc l'aléa
de charge que le projet documente — pas une régression. Je le note plutôt que de
le taire : la tentation, dans ces cas-là, est d'élargir une fenêtre au hasard, et
c'est précisément ce qui a déjà fait passer une suite de 1 à 19 échecs.

315 tests, 34 mutations sur 34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Le manuel passe à 18 pages. La section dit ce qui compte vraiment :
Windows uniquement (macOS n'a pas d'équivalent scriptable sans dépendance),
et l'icône DISPARAÎT à l'arrêt au lieu de rougir — une fois le programme
parti, plus rien ne la dessine. Icône présente = Cascade tourne.

Rappelle aussi que fermer la fenêtre pendant un show ne coupe déjà rien :
l'icône sert à le voir, pas à le garantir.

PDF regénéré à la racine et dans dist/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
…tion

docs/ETAT-DU-PROJET.md est le fichier qu'on relit avant de toucher au code.
Il n'y avait ni le contrôle du Host ni le systray — donc deux choses qu'une
prochaine session aurait redécouvertes, ou pire, « réparées ».

Le garde CSRF gagne aussi la raison pour laquelle il compare l'essence du
type et pas une sous-chaîne : c'est un contournement mesuré, pas une
précaution théorique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
@pymenvert pymenvert changed the title Tests réparés, docs remises d'aplomb, 2.0.2, grille de presets, suiveur audio, replis et code d'accès Vers la 2.1 : suiveur audio, grille de presets, code d'accès, replis — et la suite de tests qui mesure enfin ce qu'elle promet Aug 5, 2026
claude added 6 commits August 5, 2026 07:35
Relecture adversariale du systray — code jamais exécuté, écrit depuis Linux.
La trouvaille principale n'est pas dans le PowerShell : elle est dans ce
qu'il sondait.

Le script interrogeait /api/state toutes les 1,5 s. Or /api/state remet
lastUiPollAt à jour — c'est comme ça que Cascade sait qu'une interface est
ouverte. L'arrêt automatique exige 8 s de silence : la condition n'était
donc PLUS JAMAIS vraie. Case cochée, on ferme son navigateur, Cascade ne se
ferme plus tout seul. Le blocage était mutuel : le script ne sort que quand
le serveur meurt, le serveur ne mourait plus parce que le script sondait.
Avec le mode app (pas de terminal) et une icône que Windows range d'office
dans le tiroir caché, il ne restait que le Gestionnaire des tâches.

Une fonction d'agrément cassait une fonction livrée, en silence.

Le sondage passe par /api/ping, qui n'y touche pas — et qui ne renvoie pas
fixtures, couches, scène, vues et niveaux pour lire un booléen. running et
systray n'en sortent que pour la machine hôte : la route est hors du
portillon, donc publique.

Mesuré pour de vrai, pas déduit : instance à part, veilleur armé, délai de
grâce à 400 ms (CASCADE_UI_GONE_MS), sondée uniquement par ping — et on
regarde si le processus est mort. Avec témoin qui poll et doit survivre.
Deux mutants rejouent le défaut, tous deux détectés.

Sept autres correctifs de la même relecture : trois échecs avant de partir
(un pic de charge faisait disparaître l'icône pour de bon) · -ErrorAction
Stop (sans lui, rien ne garantissait que le catch parte, et powershell.exe
survivait à Cascade) · un seul chemin de sortie qui efface l'icône (kill()
est un TerminateProcess : pas de NIM_DELETE, pastille morte à l'écran) ·
journalisation du code de sortie (trois bâillons superposés rendaient tout
diagnostic impossible) · course sur la référence au processus · BOM sur le
.ps1 · la case se grisait d'après navigator.platform, donc d'après la
machine qui REGARDE, alors que l'icône se pose sur l'hôte.

Et six assertions qui pouvaient passer à vide : .every() est vrai sur un
tableau vide, une boucle sur rien n'assure rien. Le plancher de la liste de
mutations, resté à 27 pour 34 mutants, remonte à 34.

328 tests, aucun ignoré. Manuel à 18 pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Il observait 1,2 s pour un délai de grâce de 400 ms, en sondant toutes les
100 ms : une seule requête locale qui traîne au-delà de 400 ms sur un runner
chargé, et le serveur s'arrête alors que le test attend qu'il vive. Une
flakiness attendait son runner.

Grâce à 600 ms, fenêtre à 2 s, pas de sondage à 50 ms — 12× de marge, et une
fenêtre 3× plus longue que la grâce, sans quoi le témoin ne prouverait rien :
un serveur qui survit moins longtemps que sa propre grâce survivrait même
sans être sollicité.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
La relecture du systray a nommé l'angle mort : la CI ne tourne que sur
Ubuntu, donc AUCUNE ligne de ce PowerShell n'est jamais vérifiée par autre
chose qu'une lecture de source. Une parenthèse manquante survivrait jusqu'à
la machine de régie.

L'analyseur de PowerShell est une classe .NET ordinaire : il ne demande pas
de session graphique. On ne prétend donc pas tester l'icône — on ferme le
seul angle mort qu'un runner peut fermer, et le commentaire le dit.

Le passage Windows lance aussi la suite : le commentaire en tête du fichier
promettait déjà d'attraper « un chemin qui n'existe que sous Windows », ce
qu'un runner Ubuntu ne peut pas faire. La promesse devient vraie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
En PowerShell, $null.Count vaut 0. Si l'analyseur ne rendait rien du tout,
« zéro erreur d'analyse » aurait été vrai et l'étape serait passée au vert
sans avoir lu une ligne — exactement le défaut que cette session passe son
temps à débusquer ailleurs.

Deux témoins : on exige d'abord d'avoir vraiment obtenu des jetons, puis on
redonne le même script avec une accolade en trop et on vérifie que
l'analyseur le REFUSE. Une étape incapable de dire non ne mesure rien.

Le premier passage Windows a lancé la suite entière : 328 tests, 0 échec,
0 ignoré — les 46 tests de navigateur compris. La promesse du commentaire en
tête du fichier (« un chemin qui n'existe que sous Windows ») est tenue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Il promettait déjà d'attraper « un chemin qui n'existe que sous Windows »,
ce qu'un runner Ubuntu ne peut pas faire. C'est vrai depuis aujourd'hui :
la suite tourne sur les deux, et le premier passage Windows est vert —
328 tests, 0 ignoré.

Et la note sur l'icône disait « la CI ne dira jamais rien de cette
fonction ». À corriger dans les deux sens : elle en dit maintenant quelque
chose (le script s'analyse, et la suite tourne sous Windows), mais elle ne
dira jamais si l'icône se pose — NotifyIcon demande un bureau. L'essai en
vrai reste à faire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Zéro aveugle, zéro motif absent, server.js restauré à l'identique dans la
copie. Les deux mutants de l'icône de notification n'étaient jusqu'ici
vérifiés qu'à part : le chiffre n'est plus additionné à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
…ne durée

Le passage Windows a rendu rouge : 2 tests sur 328. Deux vrais défauts de
test, pas un incident de runner.

1. Le faux périphérique de Chromium a mis plus de 900 ms à s'ouvrir sur un
   runner chargé. L'attente était FIXE. Elle attend maintenant la CONDITION
   (suiveur.actif), avec une échéance large. Ce n'est pas élargir une fenêtre
   pour faire passer un test : la condition mesure exactement la même chose,
   et échoue toujours si le micro ne s'ouvre jamais — le test « micro refusé »
   le prouve, il emprunte le même chemin.

2. Le second échec était COLLATÉRAL, et c'est le plus intéressant. Ces tests
   se passaient l'état du suiveur les uns aux autres sans jamais le dire.
   Le premier ayant laissé le micro ouvert, le clic du suivant l'a ARRÊTÉ au
   lieu de le démarrer — et ce test-là a rapporté « un seul getUserMedia doit
   partir, vu 0 », c'est-à-dire une panne qui n'existait pas. Un test qui se
   trompe de diagnostic coûte plus cher qu'un test qui tombe : on part
   chercher un défaut ailleurs.

Les quatre tests du micro partent désormais d'un suiveur explicitement fermé.

46 tests d'interface verts deux fois de suite, 328 sur la suite complète.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014AygLFhdJYfSvYLNR2Y2kH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants