diff --git a/docs/agentic/phase3-baseline-2026-08-20.md b/docs/agentic/phase3-baseline-2026-08-20.md new file mode 100644 index 00000000..b0c0a3a3 --- /dev/null +++ b/docs/agentic/phase3-baseline-2026-08-20.md @@ -0,0 +1,483 @@ +# Phase 3 — Baseline (arrêt sur divergence) — 2026-08-20 + +## Cadre + +- Autorisation : Phase 3 uniquement, autonomie Level 1. +- Commit de base gelé : `89b47ec3f7b4bb90a5c01793be893d172fb1ce17` + (`main` et `origin/main` au démarrage). +- Aucun commit, push, PR, changement de stratégie Git/GitHub, nettoyage BMAD ou + Phase 4. Les seules modifications de code autorisées ensuite sont les corrections + ESLint locales décrites ci-dessous. +- Le corpus local non suivi sous `WvW/` a été préservé et n'a pas été ouvert, + parsé, copié ni inclus dans ce rapport. + +## Inventaire et prérequis + +- État Git initial : aucun diff suivi ; corpus `WvW/` non suivi et préexistant ; + `docs/agentic/` non suivi et préexistant. +- Toolchains restaurées conformément aux voies documentées : + - `uv 0.12.5`, installateur officiel Astral ; CPython `3.12.14` géré par uv. + - `pnpm 11.22.0`, majeur 11 conforme au composite CI. +- Synchronisations verrouillées réussies : + - `uv sync --frozen --all-packages` — 148 paquets, 5.5 s environ. + - `pnpm install --frozen-lockfile` — lockfile validé, 5.3 s environ. +- Docker est installé (`29.7.2`) mais le socket local est inaccessible à cette + session : propriétaire/groupe `nobody:nobody`, alors que l'utilisateur courant + n'appartient pas à ce groupe. C'est un blocage d'environnement pour les services, + pas un diagnostic applicatif. + +## GitHub et PR + +- PR [#248](https://github.com/Roddygithub/Gw2Analytics/pull/248) : ouverte, + non brouillon, base `89b47ec`, tête `6f88500cd4d29c2e0943d6877b55af08a68cf5b5`, + état GitHub `clean` et fusionnable au moment du relevé. +- Aucun changement distant effectué. + +## Validations exécutées + +| Palier | Commande | Résultat | Durée approximative | +|---|---|---|---:| +| Lint Python | `uv run ruff check --output-format=concise` | succès | 5.2 s (avec format) | +| Format Python | `uv run ruff format --check` | succès, 354 fichiers | 5.2 s (avec lint) | +| Garde SQLAlchemy | absence de `db.query(` dans `apps/api/src`, `libs` | succès | 0.1 s | +| Type-check Python | `uv run mypy libs --no-incremental` puis API | succès, 96 + 68 sources | 21.4 s | +| Audit Python | `uv run pip-audit --strict ...` (exceptions CI) | succès, aucune sortie de vulnérabilité | ~32 s | +| Tests ciblés scripts | `uv run pytest tests/scripts/ --tb=line -q` | succès, 64 tests | 5.6 s | +| Dérive OpenAPI | génération dans `/tmp`, puis `diff` avec le client suivi | succès, aucune dérive | 4.3 s | +| Type-check web (initial) | `pnpm typecheck` | succès | inclus ci-dessous | +| Lint web (initial) | `pnpm lint` | échec : 23 erreurs, 18 avertissements | 12.8 s (avec type-check) | + +La première tentative de génération OpenAPI a été exécutée depuis la racine et +`pnpm exec` l'a refusée car le package se trouve sous `web/`. Elle a été relancée +depuis le répertoire explicitement imposé par la CI et a réussi ; il s'agit d'une +erreur de contexte de commande, pas d'une divergence du dépôt. + +## Correction ciblée autorisée — Level 1 + +Le mainteneur a ensuite autorisé uniquement la correction des erreurs ESLint +purement mécaniques, sans changement fonctionnel, refactoring ni modification de +CI. Les fichiers corrigés sont tous suivis par Git ; `WvW/` n'a pas été ouvert, +lu, modifié, déplacé, indexé ou ajouté. Aucun autre chemin non suivi n'a été +modifié, à l'exception de la présente mise à jour de checkpoint autorisée. + +### Corrections mécaniques appliquées (11 erreurs) + +- `web/src/app/fights/[id]/page.tsx` : retrait de cinq imports/types/constantes + inutilisés. +- `web/src/components/CreateWebhookPanel.tsx` : retrait du type `Phase` inutilisé. +- `web/src/components/FightSummaryCards.tsx` : retrait de la prop inutilisée + `valueFormatter` de `SummaryPlayerRow`; le formatage reste effectué par + `SummaryCard` avant l'appel du composant. +- `web/src/components/PlayerPositionHeatmap.tsx` : retrait de l'import + `FALLBACK_COLOR` inutilisé. +- `web/src/components/TimelineChart.tsx` : retrait de la constante inutilisée + `BUTTON_STYLE`. +- `web/src/lib/fetchCached.ts` : commentaire dans le `catch` de parsing JSON ; le + fallback sur le texte de réponse est inchangé. +- `web/tests/setup.ts` : renommage du paramètre de shim inutilisé en `_obj`. + +### Erreurs React initialement à décision fonctionnelle/architecturale (12) + +Aucune de ces erreurs n'a été modifiée : les corriger impose de restructurer les +hooks ou le flux d'état, avec un risque de changement de comportement. + +- `web/src/app/fights/compare/page.tsx` : 2 mises à jour synchrones de l'état + dans les effets de chargement A/B. +- `web/src/components/LazyTabbedTimelineSection.tsx` : 1 mise à jour synchrones + de l'état dans un effet. +- `web/src/components/PlayerPositionGrid.tsx` : 1 appel de chargement depuis un + effet qui met l'état à jour de façon synchrone. +- `web/src/components/PlayerPositionHeatmap.tsx` : 3 mises à jour synchrones de + l'état dans des effets et 1 callback `draw` auto-référencée avant déclaration. +- `web/src/components/ReadoutTabClient.tsx` : 3 `useMemo` conditionnels (ordre + des hooks) et 1 chargement depuis un effet avec mise à jour synchrone. + +Le mainteneur a ensuite autorisé leur traitement local après analyse de chaque +composant. Les corrections appliquées préservent les états de chargement, d'erreur, +l'annulation des requêtes périmées et l'affichage attendu : + +- `fights/compare/page.tsx` : chaque colonne de combat est isolée dans un enfant + cléé par son identifiant ; son état initial remplace les remises à zéro + synchrones dans l'effet. +- `LazyTabbedTimelineSection.tsx`, `PlayerPositionGrid.tsx` et + `ReadoutTabClient.tsx` : même principe, avec un enfant cléé par les paramètres + de chargement et un effet réservé aux résolutions/rejets asynchrones. +- `PlayerPositionHeatmap.tsx` : chargement, autoplay et arrêt de l'animation sont + déclenchés depuis les callbacks asynchrones ou `requestAnimationFrame`; la + référence de dessin est stabilisée par un ref. L'autoplay immédiat de deux + secondes est le comportement voulu. +- `ReadoutTabClient.tsx` : les trois `useMemo` sont désormais toujours appelés, + avec un tableau vide stable lorsque les données sont absentes. + +Le test `player-position-heatmap` a été adapté uniquement de `Lecture` vers +`Pause` après le chargement, conformément à cet autoplay déjà documenté et au +test voisin qui le vérifie. + +### Validation de la correction ciblée + +| Commande | Résultat | +|---|---| +| `git diff --check` | succès | +| `pnpm lint` | **échec contrôlé : 12 erreurs restantes, 18 avertissements** ; les 11 erreurs mécaniques ne réapparaissent pas | +| `pnpm typecheck` | succès | +| `pnpm vitest run tests/lib/fetchCached.test.ts tests/lib/fetchCached-isolation.test.ts tests/app/fights-page.test.tsx tests/components/CreateWebhookPanel.test.tsx` | succès : 4 fichiers, 27 tests | +| `pnpm vitest run tests/components/player-position-heatmap.test.tsx tests/components/ReadoutTabClient.test.tsx` | succès : 15 tests | +| `pnpm lint` après toutes corrections | succès : 0 erreur, 16 avertissements préexistants/non bloquants | +| `pnpm typecheck` après toutes corrections | succès | +| `pnpm test:unit` | succès : 54 fichiers, 379 tests, 3 ignorés | +| `pnpm audit --audit-level=high` | succès : aucune vulnérabilité connue | +| `pnpm build` sans configuration de production | échec attendu : `API_BASE_URL` requise | +| `API_BASE_URL=http://api:8000 NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 pnpm build` en exécution isolée | **NOT RUN / limite d'environnement** : l'isolation interdit à Node de créer le sous-processus `tsc`, puis Next reçoit une sortie vide | +| même commande avec l'autorisation d'exécution nécessaire | succès : build Next.js complet, génération des 7 pages statiques terminée | +| `git diff --check` final | succès | + +La suite web émet des avertissements non bloquants de test concernant `act(...)` +et le contexte canvas non implémenté dans jsdom. Les assertions passent ; ces +avertissements ne sont pas traités pendant la baseline car ils ne constituent ni +une erreur ESLint ni une régression démontrée. + +ESLint est désormais vert. La divergence de couverture CI reste cohérente avec le +drift déjà relevé : le workflow +`.github/workflows/ci.yml` exécute le type-check, les tests et l'audit web, mais +pas `pnpm lint`, alors que `README.md` prescrit ce lint. Cela explique qu'une CI +verte ne prouvait pas auparavant le lint local. La couverture ESLint de la CI doit +être traitée dans sa phase dédiée ; la CI n'a pas été modifiée pendant cette +Phase 3. + +## Diagnostic ciblé — build Next.js / TypeScript + +Le premier échec de build reste une précondition de production documentée : +`API_BASE_URL` doit être définie. La seconde erreur observée est désormais +expliquée et ne provient ni du code applicatif ni de `tsconfig.json`. + +- Versions effectives : Node `v26.7.0`, pnpm `11.22.0`, Next.js `16.3.0` et + TypeScript `5.9.3` (verrouillé par `web/pnpm-lock.yaml`). Le package Next + déclare Node `>=20.9.0`; aucune incompatibilité de versions n'a été observée. +- `web/package.json` lance exactement `next build`; `web/tsconfig.json` et + `web/next.config.ts` étaient inchangés. La sortie directe de + `pnpm exec tsc --showConfig --project tsconfig.json --pretty false` fait + 9 247 octets, est du JSON valide et se termine avec le code `0`. +- Le traceur de processus confirme que Next exécute + `node /bin/tsc --showConfig --project /tsconfig.json --pretty false`. + Dans l'exécution isolée, la création d'un sous-processus par Node est refusée + (`EPERM`). Une sonde minimale `child_process.spawnSync(node, ['-e', ...])` + retourne ce même `EPERM`; le processus enfant asynchrone ne fournit alors ni + stdout ni stderr. Next tente donc de parser une chaîne vide et son erreur a pour + cause `SyntaxError: Unexpected end of JSON input`. +- Avec les valeurs non sensibles documentées + `API_BASE_URL=http://api:8000` et + `NEXT_PUBLIC_API_BASE_URL=http://localhost:8000`, la même commande exécutée + hors de cette restriction termine avec succès (compilation, type-check, + collecte des données et génération statique). Elle démontre que le build est + vert dans un environnement autorisant les sous-processus Node. +- La CI web configure Node 22 et pnpm 11; le Dockerfile de production construit + également avec Node 22. Le job `lint-web` de CI n'exécute cependant pas + `pnpm build` : il vérifie seulement le type-check, les tests et l'audit. Cette + différence ne permet donc pas d'affirmer ici le résultat d'un build Next sur + GitHub Actions. + +Correction minimale recommandée : **aucun patch du dépôt**. Exécuter le build +dans un environnement qui autorise les sous-processus Node (CI, Docker lorsque le +socket est disponible, ou une exécution locale non isolée) est la seule action +requise. Risque fonctionnel : nul; aucun code, configuration, lockfile ou CI n'a +été modifié. Le build dans l'isolation restrictive est classé `NOT RUN` plutôt +que `FAIL` applicatif. + +## Non exécuté après arrêt + +- build API ; +- suite Python complète ; +- démarrage des services Docker, migrations Alembic et test de migration ; +- Playwright, builds Docker, parité Elite Insights et tout accès au corpus privé. + +Les validations dépendantes de Docker restent impossibles : Docker est installé, +mais son socket est inaccessible à cette session (propriétaire/groupe +`nobody:nobody`, utilisateur courant hors de ce groupe). Aucune tentative de +contournement ni modification d'environnement n'a été faite. + +## État de reprise + +- Modifications suivies non commitées : 12 fichiers web (les sept fichiers + mécaniques, les cinq composants/pages React corrigés et le test Heatmap). + Aucun commit, push, PR ou ajout à l'index. +- Revue du diff final : les changements se limitent au retrait des symboles + inutilisés, à l'isolation cléée des états de chargement, à l'ordre inconditionnel + des hooks et à l'assertion d'autoplay autorisée ; aucun changement métier, + d'API, de données ou de CI n'a été identifié. +- Fichier de checkpoint non suivi mis à jour : + `docs/agentic/phase3-baseline-2026-08-20.md`. +- Les artefacts locaux de dépendances (`.venv`, `web/node_modules`) et sorties + temporaires `/tmp` ne sont pas des modifications suivies du projet. +- Phase 3 reste en attente de la décision du mainteneur : les validations + applicatives et ESLint sont vertes et le build web est validé hors de + l'isolation restrictive. Aucun correctif de build ni Phase 4 ne doit être + engagé sans autorisation explicite. Aucune consultation Sol n'est nécessaire : + le diagnostic confirme une limite d'exécution, non une question architecturale + ou métier. + +## Bilan final consolidé — validation du mainteneur + +Le mainteneur valide le diagnostic Next.js/TypeScript et classe le build isolé +comme une limitation d'environnement. La partie web de la baseline est donc +validée pour les contrôles exécutables dans cette session. Cette section est le +checkpoint de clôture de la Phase 3; elle n'autorise pas la Phase 4. + +### PASS + +- synchronisations verrouillées Python et web; +- Ruff, format Python, garde SQLAlchemy, mypy, audit Python, tests de scripts et + absence de dérive OpenAPI; +- ESLint web : 0 erreur (16 avertissements non bloquants); +- typecheck web; +- tests web ciblés : 15 tests, puis suite complète : 54 fichiers, 379 tests, + 3 ignorés; +- audit pnpm : aucune vulnérabilité connue; +- build Next.js complet avec les variables de production documentées, dans une + exécution autorisant les sous-processus Node; +- `git diff --check`. + +### FAIL réel + +Aucun échec applicatif ou de validation reproductible ne reste ouvert dans le +périmètre exécuté. Le premier `pnpm lint` historique (23 erreurs) et l'assertion +Heatmap incohérente ont été résolus pendant cette Phase 3 et ne constituent pas +des échecs finaux. + +### NOT RUN — limitation d'environnement + +- build Next.js dans l'isolation restrictive : Node ne peut pas créer le + sous-processus TypeScript (`EPERM`); le build est validé hors de cette limite; +- services Docker, migrations Alembic, test de migration, builds Docker : socket + Docker inaccessible à cette session; +- Playwright et parité Elite Insights : non lancés, car ils dépendent de services + ou du corpus privé que la Phase 3 ne doit pas ouvrir; +- build API et suite Python complète : non lancés après les arrêts Level 1 + précédents; ils restent des validations à programmer dans un environnement + approprié, pas des échecs connus. + +### Modifications applicatives de Phase 3 + +- retrait de 11 violations ESLint mécaniques (symboles/props/styles inutilisés, + paramètre de mock, commentaire de fallback); +- correction locale de 12 violations React : états de chargement initialisés via + composants cléés, effets limités aux retours asynchrones, hooks inconditionnels, + animation Heatmap stabilisée; +- mise à jour ciblée du test Heatmap pour l'autoplay immédiat de deux secondes + explicitement validé par le mainteneur. + +Ces modifications sont non commitées et ont été couvertes par lint, typecheck, +tests ciblés et suite web complète. + +### Gouvernance et CI à traiter ultérieurement + +- le workflow CI web ne lance pas ESLint alors que le README le prescrit; +- le CI ne lance pas `pnpm build`, donc le build Next doit être couvert par une + étape dédiée dans une phase de gouvernance distincte; +- les avertissements de test jsdom (`act(...)` et canvas) restent non bloquants + et hors du périmètre de correction Phase 3. + +### Confidentialité + +`WvW/` est toujours non suivi, n'a été ni ouvert, ni lu, ni indexé, ni modifié. +Le checkpoint est le seul fichier non suivi modifié dans `docs/agentic/`. +Les validations EI et tout corpus privé restent explicitement non exécutés. + +### État Git de clôture + +- base auditée : `89b47ec3f7b4bb90a5c01793be893d172fb1ce17`; +- 12 fichiers web suivis modifiés, non indexés et non commités; +- chemins non suivis préservés : `WvW/` et `docs/agentic/`; +- aucun commit, push, PR, ajout à l'index ou changement CI/GitHub. + +### Recommandation Phase 4 + +Après autorisation explicite, commencer par le nettoyage BMAD approuvé dans le +plan de migration, puis installer l'infrastructure agentique de façon +progressive. Conserver les contrôles web validés comme prérequis et planifier +séparément la couverture CI ESLint/build ainsi que les validations Docker, +migrations et parité EI dans un environnement disposant des services et du +corpus autorisés. Ne pas intégrer les modifications web de Phase 3 avant une +revue indépendante et une décision de commit/PR du mainteneur. + +## Revue indépendante read-only — en attente de résolution + +Deux Reviewers Terra High indépendants des Implementers ont relu le diff des +12 fichiers web sans modifier le dépôt. La couche `verification-gap` BMAD n'a +pas pu être lancée : la limite de threads du harness était atteinte. Cette +limite réduit la couverture de revue, mais n'est pas un finding de code. + +### Findings triés + +- **Moyen — réinitialisation de l'onglet Readout lors d'un changement de combat** + (`web/src/components/ReadoutTabClient.tsx:504-505`). Le nouveau `key={fightId}` + remonte tout le composant et remet `activeTableTab`, filtres et états de tri à + leurs valeurs initiales. L'ancien composant conservait ces interactions pendant + le rechargement. Le diff Phase 3 visait uniquement ESLint : préserver cet état + est la correction attendue avant intégration. +- **Moyen — réinitialisation de l'onglet Timeline lors d'un changement de fenêtre** + (`web/src/components/LazyTabbedTimelineSection.tsx:103-110`). + `key={fightId}:${windowS}` remonte `PerFightTimelineSection`; un analyste peut + donc perdre son onglet actif quand il change la fenêtre temporelle. L'ancien + effet ne réinitialisait que la requête; préserver l'interaction est requis. +- **Faible — effet de bord dans un updater d'état Heatmap** + (`web/src/components/PlayerPositionHeatmap.tsx:146-153`). + `setPlaying(false)` est appelé depuis l'updater de `setCurrentTime`. Les + updaters devraient rester purs, même si l'appel est idempotent. Correction + locale et non ambiguë à prévoir avec les deux findings moyens. + +### Points non bloquants / différés + +- La minuterie d'autoplay peut aussi arrêter une lecture reprise manuellement + avant son expiration. Ce comportement existait avant le diff Phase 3 : il est + réel mais non causé par cette baseline. +- La capture initiale de `positionsData`, les requêtes non annulées et les retries + canvas non annulés existaient également avant les corrections; ils ne sont pas + des régressions introduites ici. +- Les tests passent, mais il manque des tests explicites de conservation des + interactions lors des remounts cléés; ils devront accompagner les corrections + des deux findings moyens. + +Verdict de revue : **non approuvée pour intégration telle quelle**. Aucun risque +haut, aucune modification API/données/CI, mais les deux réinitialisations UX +contredisent l'objectif de correction ESLint minimale. Aucun finding n'a été +corrigé; commit, PR et Phase 4 restent interdits jusqu'à décision du mainteneur. + +### Corrections autorisées de la première revue + +Le mainteneur a autorisé les trois corrections ci-dessus. Elles ont été +appliquées sans traiter les dettes préexistantes : `ReadoutTabClient` conserve +désormais ses contrôles pendant le chargement, `LazyTabbedTimelineSection` +conserve son onglet, et l'updater Heatmap a été rendu pur. Tests ciblés : 25 +succès; ESLint : 0 erreur; typecheck : succès; suite web : 54 fichiers, 381 +tests, 3 ignorés; `git diff --check` : succès. Aucun audit/build n'a été +relancé, leurs résultats validés antérieurement restent inchangés. + +### Seconde revue indépendante — nouveaux findings bloquants + +Deux Reviewers read-only, toujours distincts de l'Implementer, ont relu le diff +après ces corrections. Elle révèle trois régressions introduites par la solution +de conservation d'état : + +- **Moyen — curseur Heatmap et reprise de lecture incohérents** + (`web/src/components/PlayerPositionHeatmap.tsx:148-150,472-479`). Le handler + du curseur met à jour l'état affiché mais pas `currentTimeRef`; à la reprise, + l'animation repart donc de l'ancienne valeur. La synchronisation du ref dans + le handler est une correction locale non ambiguë, mais elle n'a pas été + appliquée sans nouvelle autorisation. +- **Moyen — retour A → B → A peut afficher le résultat A périmé** + (`web/src/components/ReadoutTabClient.tsx:552-558`). Le tag par `fightId` + masque A pendant B, mais redevient valide lors d'un retour à A avant la + résolution de la nouvelle requête A. Il faut distinguer les générations de + requêtes tout en préservant les contrôles de table. +- **Moyen — retour 10 s → 20 s → 10 s peut afficher la timeline 10 s périmée** + (`web/src/components/LazyTabbedTimelineSection.tsx:129-134`). Même défaut de + tag : `fightId/windowS` ne distingue pas deux requêtes successives pour la + même clé. Il faut un identifiant de génération, sans remonter l'onglet. + +Points mineurs : la dernière frame d'animation est planifiée même après la fin; +les dettes préexistantes d'autoplay, `positionsData`, abort de requête et retries +canvas restent exclues. Les tests nouveaux couvrent les chemins directs mais pas +les retours rapides ni le curseur suivi d'une reprise. + +Verdict actuel : **non approuvée pour intégration**. Les trois corrections +autorisées sont validées par les tests, mais ces trois nouveaux findings moyens +doivent être corrigés et retestés avant commit/PR ou Phase 4. Aucun fichier n'a +été modifié par la seconde revue. + +### Clôture — revue indépendante finale + +Les trois findings de la seconde revue ont été corrigés localement et couverts +par les tests de régression. Validation finale : 28 tests ciblés, ESLint sans +erreur, typecheck, suite web complète (54 fichiers, 384 succès, 3 ignorés) et +`git diff --check` sont au vert. Le Reviewer indépendant `review_edge`, distinct +de l'Implementer et en lecture seule, a relu le diff final avec un focus sur les +états conservés, les réponses asynchrones périmées, les changements rapides de +paramètres, l'autoplay et les dépendances d'effets; verdict : **clean**. + +Verdict Phase 3 : **prête à intégrer sous réserve de la décision du mainteneur**. +Les validations environnementales déjà classées `NOT RUN` restent inchangées; +aucun finding bloquant ne subsiste dans le diff web. Recommandation : effectuer +une revue humaine finale du diff, puis créer une branche/PR dédiée et un commit +signé avec trailer DCO lorsque le mainteneur l'autorisera. Ne pas commencer la +Phase 4 avant cette décision explicite. + +## Résolution autorisée des trois findings de revue — Phase 3 / Level 1 + +Le mainteneur a autorisé exclusivement les trois corrections locales ci-dessous. +Elles ferment les findings de revue sans toucher à la minuterie d'autoplay, +la capture initiale de `positionsData`, l'annulation de requêtes, les retries +canvas, la CI ou une autre dette préexistante. + +- `ReadoutTabClient.tsx` : suppression du remount cléé. L'état de chargement est + désormais étiqueté par `fightId`; lors du changement de combat, les anciennes + données et erreurs sont masquées jusqu'à la réponse correspondante, tandis que + l'onglet de table, le filtre de rôle et les tris restent montés. Le test ciblé + vérifie le chargement sans donnée périmée, puis la conservation des trois + contrôles. +- `LazyTabbedTimelineSection.tsx` : même étiquetage par `fightId` et `windowS`, + sans remount de `PerFightTimelineSection`. Une ancienne réponse ne s'affiche + jamais; l'onglet Per-player demeure sélectionné à travers le chargement d'une + nouvelle fenêtre. Le test ciblé couvre cette transition. +- `PlayerPositionHeatmap.tsx` : l'updater de temps est pur. Un `ref` porte le + temps courant dans la boucle RAF; l'arrêt de lecture se produit après le calcul + de la valeur bornée, hors de tout updater, sans changer l'autoplay immédiat de + deux secondes ni l'arrêt en fin de timeline. + +### Validations de la résolution + +| Commande | Résultat | +|---|---| +| `pnpm test:unit tests/components/ReadoutTabClient.test.tsx tests/components/per-fight-timeline-chart.test.tsx tests/components/player-position-heatmap.test.tsx` | succès : 3 fichiers, 25 tests | +| `pnpm lint` | succès : 0 erreur, 16 avertissements non bloquants | +| `pnpm typecheck` | succès | +| `pnpm test:unit` | succès : 54 fichiers, 381 tests, 3 ignorés | +| `git diff --check` | succès | + +Les avertissements jsdom/canvas, `act(...)` et de style de test restent sans +échec d'assertion et hors périmètre. L'audit pnpm et le build Next configuré +étaient déjà PASS dans la baseline précédente et n'ont pas été relancés pour ces +trois corrections locales. + +### État final après résolution de revue + +Les trois findings ci-dessus sont résolus et leurs tests sont présents. Les +points explicitement différés demeurent préexistants et non modifiés. La +divergence CI/ESLint reste documentée séparément : la CI n'exécute toujours pas +`pnpm lint`, alors que le README le prescrit; aucune modification CI n'a été +faite. `WvW/` n'a jamais été ouvert, lu, indexé ni modifié. Le checkpoint reste +le seul chemin non suivi modifié. Les 14 fichiers web suivis sont non indexés et +non commités; aucun commit, push, PR ou passage en Phase 4 n'est autorisé. + +## Corrections locales supplémentaires autorisées — concurrence et seek + +Trois regressions de concurrence ont été corrigées dans le même périmètre Phase +3 / Level 1, sans modifier la stratégie de cache, les retries canvas, +`positionsData`, l'autoplay, l'annulation réseau, la CI ou les autres dettes +différées. + +- `PlayerPositionHeatmap.tsx` : le changement du curseur met désormais à jour + `currentTimeRef` et l'état affiché ensemble. Une reprise de lecture part donc + de la position choisie et non d'un temps précédemment mémorisé. +- `ReadoutTabClient.tsx` : l'état de requête porte une génération augmentée de + façon conditionnelle lors d'un changement de `fightId`. Le cycle A → B → A ne + peut plus afficher l'ancienne réponse A avant la résolution de la nouvelle + requête A; onglet, rôle et tri restent conservés. +- `LazyTabbedTimelineSection.tsx` : la même génération est appliquée à la paire + `fightId` / `windowS`. Le cycle 10 → 20 → 10 masque l'ancienne fenêtre 10 + jusqu'à la réponse actuelle, sans remonter l'onglet Per-player. + +### Validations de cette itération + +| Commande | Résultat | +|---|---| +| `pnpm test:unit tests/components/player-position-heatmap.test.tsx tests/components/ReadoutTabClient.test.tsx tests/components/per-fight-timeline-chart.test.tsx` | succès : 3 fichiers, 28 tests | +| `pnpm lint` | succès : 0 erreur, 16 avertissements non bloquants | +| `pnpm typecheck` | succès | +| `pnpm test:unit` | succès : 54 fichiers, 384 tests, 3 ignorés | +| `git diff --check` | succès | + +Les mêmes avertissements jsdom/canvas, `act(...)` et de style de test restent +non bloquants; aucune assertion n'échoue. Les audits, builds et validations +Docker ne sont pas relancés pour ces corrections locales; leurs états antérieurs +restent inchangés. `WvW/` demeure strictement non ouvert et le checkpoint reste +le seul fichier non suivi modifié. diff --git a/web/src/app/fights/[id]/page.tsx b/web/src/app/fights/[id]/page.tsx index 10f7546a..51e5402f 100644 --- a/web/src/app/fights/[id]/page.tsx +++ b/web/src/app/fights/[id]/page.tsx @@ -66,8 +66,6 @@ import { type TargetHealingRow, type TargetBuffRemovalRow, type FightEventsSummaryRow, - type FightPlayerTimeline, - type FightReadoutOut, type FightTimeline, type SquadRollupRow, } from "@/lib/api"; @@ -85,7 +83,6 @@ import { SkillUsageTable } from "@/components/SkillUsageTable"; import { PlayerSkillUsageTable } from "@/components/PlayerSkillUsageTable"; import { PlayerSkillUsageFilter } from "@/components/PlayerSkillUsageFilter"; import { LazyTabbedTimelineSection } from "@/components/LazyTabbedTimelineSection"; -import { PerFightTimelineSection } from "@/components/PerFightTimelineSection"; import { ReplayPlayer } from "@/components/ReplayPlayer"; // v0.10.26-pre plan 169 commit #1: per-section error indicator. // @@ -107,8 +104,6 @@ import { FAILED_TO_LOAD_PLAYER_LIST, FAILED_TO_LOAD_PER_PLAYER_SKILLS, FAILED_TO_LOAD_FIGHT_DETAILS, - COMBAT_READOUT_FETCH_FAILED, - COMBAT_READOUT_LOADING, PER_PLAYER_PROMPT_PLACEHOLDER, NO_EVENT_DATA_TITLE, NO_EVENT_DATA_BODY, diff --git a/web/src/app/fights/compare/page.tsx b/web/src/app/fights/compare/page.tsx index eb95acd0..01f51af1 100644 --- a/web/src/app/fights/compare/page.tsx +++ b/web/src/app/fights/compare/page.tsx @@ -85,13 +85,6 @@ function FightCompareInner() { const fightAId = searchParams.get("a") ?? null; const fightBId = searchParams.get("b") ?? null; - const [readoutA, setReadoutA] = useState(null); - const [readoutB, setReadoutB] = useState(null); - const [loadingA, setLoadingA] = useState(false); - const [loadingB, setLoadingB] = useState(false); - const [errorA, setErrorA] = useState(null); - const [errorB, setErrorB] = useState(null); - useEffect(() => { let cancelled = false; (async () => { @@ -111,52 +104,6 @@ function FightCompareInner() { return () => { cancelled = true; }; }, []); - useEffect(() => { - if (!fightAId) return; - let cancelled = false; - setReadoutA(null); - setLoadingA(true); - setErrorA(null); - (async () => { - try { - const data = await fetchFightReadout(fightAId); - if (!cancelled) { - setReadoutA(data); - setLoadingA(false); - } - } catch (err) { - if (!cancelled) { - setErrorA(err instanceof Error ? err.message : String(err)); - setLoadingA(false); - } - } - })(); - return () => { cancelled = true; }; - }, [fightAId]); - - useEffect(() => { - if (!fightBId) return; - let cancelled = false; - setReadoutB(null); - setLoadingB(true); - setErrorB(null); - (async () => { - try { - const data = await fetchFightReadout(fightBId); - if (!cancelled) { - setReadoutB(data); - setLoadingB(false); - } - } catch (err) { - if (!cancelled) { - setErrorB(err instanceof Error ? err.message : String(err)); - setLoadingB(false); - } - } - })(); - return () => { cancelled = true; }; - }, [fightBId]); - const updateFight = useCallback( (slot: "a" | "b", fightId: string) => { const params = new URLSearchParams(searchParams.toString()); @@ -279,42 +226,28 @@ function FightCompareInner() { }} >
- {loadingA &&

Loading Fight A…

} - {errorA && ( -

- Error: {errorA} -

- )} - {!fightAId && ( -
Select a fight for this slot.
- )} - {readoutA && ( - + ) : ( +
Select a fight for this slot.
)}
- {loadingB &&

Loading Fight B…

} - {errorB && ( -

- Error: {errorB} -

- )} - {!fightBId && ( -
Select a fight for this slot.
- )} - {readoutB && ( - + ) : ( +
Select a fight for this slot.
)}
@@ -330,6 +263,44 @@ function FightCompareInner() { ); } +/** Loads one comparison column. The key resets its initial loading state per fight. */ +function FightReadoutColumn({ + label, + fightId, + fightInfo, +}: { + label: string; + fightId: string; + fightInfo?: FightRow; +}) { + const [readout, setReadout] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchFightReadout(fightId) + .then((data) => { + if (!cancelled) setReadout(data); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + }); + return () => { + cancelled = true; + }; + }, [fightId]); + + if (error) { + return

Error: {error}

; + } + if (!readout) { + return

Loading {label}…

; + } + return ; +} + /** One column of the side-by-side comparison. */ function FightColumn({ label, diff --git a/web/src/components/CreateWebhookPanel.tsx b/web/src/components/CreateWebhookPanel.tsx index f2fe375b..548e478d 100644 --- a/web/src/components/CreateWebhookPanel.tsx +++ b/web/src/components/CreateWebhookPanel.tsx @@ -67,8 +67,6 @@ import { import styles from "./CreateWebhookPanel.module.css"; -type Phase = "closed" | "form" | "reveal"; - type State = | { phase: "closed" } | { diff --git a/web/src/components/FightSummaryCards.tsx b/web/src/components/FightSummaryCards.tsx index 6a0ca8f9..9bc639aa 100644 --- a/web/src/components/FightSummaryCards.tsx +++ b/web/src/components/FightSummaryCards.tsx @@ -115,13 +115,11 @@ function ProfessionOrEliteCell({ function SummaryPlayerRow({ player, value, - valueFormatter, rank, onRoleFilter, }: { player: PlayerReadoutOut; value: string; - valueFormatter?: (v: number) => string; rank: 0 | 1 | 2; onRoleFilter?: (role: string) => void; }) { diff --git a/web/src/components/LazyTabbedTimelineSection.tsx b/web/src/components/LazyTabbedTimelineSection.tsx index 18816f36..efc26ada 100644 --- a/web/src/components/LazyTabbedTimelineSection.tsx +++ b/web/src/components/LazyTabbedTimelineSection.tsx @@ -70,9 +70,9 @@ import { PerFightTimelineSection } from "@/components/PerFightTimelineSection"; import { SectionErrorChip } from "@/components/SectionErrorChip"; type State = - | { status: "loading" } - | { status: "success"; data: FightPlayerTimeline } - | { status: "error"; error: string }; + | { status: "loading"; fightId: string; windowS: number; generation: number } + | { status: "success"; fightId: string; windowS: number; generation: number; data: FightPlayerTimeline } + | { status: "error"; fightId: string; windowS: number; generation: number; error: string }; // Skeleton styles mirror the existing CAPTION_STYLE palette so // the loading state blends with the rest of the page. The fixed @@ -100,7 +100,16 @@ export function LazyTabbedTimelineSection({ fightId: string; windowS: number; }) { - const [state, setState] = useState({ status: "loading" }); + const [request, setRequest] = useState({ fightId, windowS, generation: 0 }); + if (request.fightId !== fightId || request.windowS !== windowS) { + setRequest({ + fightId, + windowS, + generation: request.generation + 1, + }); + } + const generation = request.generation; + const [state, setState] = useState({ status: "loading", fightId, windowS, generation }); useEffect(() => { // The `active` flag prevents the cleanup-then-resolve race @@ -108,24 +117,30 @@ export function LazyTabbedTimelineSection({ // call setState on an unmounted component, which React // warns about). let active = true; - setState({ status: "loading" }); fetchFightPlayerTimeline(fightId, { windowS }) .then((data) => { if (active) { - setState({ status: "success", data }); + setState({ status: "success", fightId, windowS, generation, data }); } }) .catch((err: unknown) => { if (active) { const message = err instanceof Error ? err.message : "Failed to load player timeline"; - setState({ status: "error", error: message }); + setState({ status: "error", fightId, windowS, generation, error: message }); } }); return () => { active = false; }; - }, [fightId, windowS]); + }, [fightId, windowS, generation]); + + // Keep PerFightTimelineSection mounted so its selected tab survives a + // window change. A response tagged for another request is never displayed. + const currentState = + state.fightId === fightId && state.windowS === windowS && state.generation === generation + ? state + : ({ status: "loading", fightId, windowS, generation } as const); // The PerFightTimelineSection is ALWAYS rendered (no display:none // wrapper). During loading we pass playerTimeline=null which lets @@ -144,7 +159,7 @@ export function LazyTabbedTimelineSection({ // per-player view failed to load. return ( <> - {state.status === "loading" ? ( + {currentState.status === "loading" ? (
) : null} - {state.status === "error" ? ( + {currentState.status === "error" ? ( ) : null} ); diff --git a/web/src/components/PlayerPositionGrid.tsx b/web/src/components/PlayerPositionGrid.tsx index f309f9b4..f76017ba 100644 --- a/web/src/components/PlayerPositionGrid.tsx +++ b/web/src/components/PlayerPositionGrid.tsx @@ -9,7 +9,7 @@ * ``dist_to_com`` (average distance to the squad's center of mass). */ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { fetchFightPositions, type PlayerPositionOut } from "@/lib/api"; @@ -28,17 +28,21 @@ interface PlayerPositionGridProps { } export function PlayerPositionGrid({ fightId }: PlayerPositionGridProps) { + return ; +} + +function PlayerPositionGridContent({ fightId }: PlayerPositionGridProps) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const loadPositions = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await fetchFightPositions(fightId); - setRows( - data.players.map((p: PlayerPositionOut) => ({ + useEffect(() => { + let active = true; + void fetchFightPositions(fightId) + .then((data) => { + if (!active) return; + setRows( + data.players.map((p: PlayerPositionOut) => ({ account_name: p.account_name, name: p.name, profession: p.profession, @@ -46,19 +50,20 @@ export function PlayerPositionGrid({ fightId }: PlayerPositionGridProps) { stack_dist: p.stack_dist ?? null, dist_to_com: p.dist_to_com ?? null, sample_count: p.samples?.length ?? 0, - })), - ); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load positions"); - } finally { - setLoading(false); - } + })), + ); + setLoading(false); + }) + .catch((err: unknown) => { + if (!active) return; + setError(err instanceof Error ? err.message : "Failed to load positions"); + setLoading(false); + }); + return () => { + active = false; + }; }, [fightId]); - useEffect(() => { - loadPositions(); - }, [loadPositions]); - if (loading) { return

Loading positions…

; } diff --git a/web/src/components/PlayerPositionHeatmap.tsx b/web/src/components/PlayerPositionHeatmap.tsx index 3ac3a7d5..c9b0c2f8 100644 --- a/web/src/components/PlayerPositionHeatmap.tsx +++ b/web/src/components/PlayerPositionHeatmap.tsx @@ -19,11 +19,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import React from "react"; import { fetchFightPositions, type PlayerPositionOut, type FightPositionsOut } from "@/lib/api"; -import { - FALLBACK_COLOR, - PROFESSION_COLORS, - professionColor, -} from "@/lib/professionColors"; +import { PROFESSION_COLORS, professionColor } from "@/lib/professionColors"; // --------------------------------------------------------------------------- // Canvas constants @@ -65,26 +61,40 @@ interface PlayerPositionHeatmapProps { // --------------------------------------------------------------------------- export function PlayerPositionHeatmap({ fightId, positionsData }: PlayerPositionHeatmapProps) { + return ( + + ); +} + +function PlayerPositionHeatmapContent({ fightId, positionsData }: PlayerPositionHeatmapProps) { const canvasRef = useRef(null); const animRef = useRef(null); + const drawRef = useRef<() => void>(() => {}); + const positionsDataRef = useRef(positionsData); + const autoPlayTimerRef = useRef | null>(null); const [players, setPlayers] = useState([]); const [durationMs, setDurationMs] = useState(0); const [currentTime, setCurrentTime] = useState(0); + const currentTimeRef = useRef(0); const [playing, setPlaying] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // ---- data fetching -------------------------------------------------------- - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { + useEffect(() => { + let active = true; + void (async () => { + try { // Use pre-fetched data when provided (avoids double-fetch). const data: FightPositionsOut = - positionsData && positionsData.players.length > 0 - ? positionsData + positionsDataRef.current && positionsDataRef.current.players.length > 0 + ? positionsDataRef.current : await fetchFightPositions(fightId); const built: HeatmapPlayer[] = data.players.map((p: PlayerPositionOut) => { const samples: [number, number, number][] = (p.samples ?? []).map( @@ -98,35 +108,35 @@ export function PlayerPositionHeatmap({ fightId, positionsData }: PlayerPosition samples, }; }); + if (!active) return; setPlayers(built); const maxT = Math.max(0, ...built.flatMap((p) => p.samples.map((s) => s[0]))); setDurationMs(maxT); + currentTimeRef.current = 0; setCurrentTime(0); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load positions"); - } finally { - setLoading(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- positionsData - // is stable once provided (parent guards with {positions && ...}), - // so it never changes after mount. + if (maxT > 0) { + setPlaying(true); + autoPlayTimerRef.current = setTimeout(() => { + setPlaying(false); + autoPlayTimerRef.current = null; + }, 2000); + } + } catch (err) { + if (active) { + setError(err instanceof Error ? err.message : "Failed to load positions"); + } + } finally { + if (active) setLoading(false); + } + })(); + return () => { + active = false; + if (autoPlayTimerRef.current !== null) { + clearTimeout(autoPlayTimerRef.current); + } + }; }, [fightId]); - useEffect(() => { - load(); - }, [load]); - - // Auto-play for 2 seconds on first load so positions are immediately - // visible (at t=0 the canvas renders but dots may be hard to spot - // without movement context). - useEffect(() => { - if (players.length > 0 && durationMs > 0) { - setPlaying(true); - const timer = setTimeout(() => setPlaying(false), 2000); - return () => clearTimeout(timer); - } - }, [players.length, durationMs]); - // ---- animation loop ------------------------------------------------------- useEffect(() => { @@ -135,10 +145,10 @@ export function PlayerPositionHeatmap({ fightId, positionsData }: PlayerPosition const tick = (now: number) => { const delta = now - last; last = now; - setCurrentTime((prev) => { - const next = prev + delta; - return next >= durationMs ? durationMs : next; - }); + const next = Math.min(currentTimeRef.current + delta, durationMs); + currentTimeRef.current = next; + setCurrentTime(next); + if (next >= durationMs) setPlaying(false); animRef.current = requestAnimationFrame(tick); }; animRef.current = requestAnimationFrame(tick); @@ -147,13 +157,6 @@ export function PlayerPositionHeatmap({ fightId, positionsData }: PlayerPosition }; }, [playing, durationMs]); - // stop playing when we hit the end - useEffect(() => { - if (currentTime >= durationMs && playing) { - setPlaying(false); - } - }, [currentTime, durationMs, playing]); - // ---- canvas drawing (handles DPR sizing internally) ----------------------- const draw = useCallback(() => { @@ -176,7 +179,7 @@ export function PlayerPositionHeatmap({ fightId, positionsData }: PlayerPosition const retries = (canvas as unknown as Record)._heatmapRetries || 0; if (retries < 10) { (canvas as unknown as Record)._heatmapRetries = retries + 1; - requestAnimationFrame(() => draw()); + requestAnimationFrame(() => drawRef.current()); } return; } @@ -348,6 +351,7 @@ export function PlayerPositionHeatmap({ fightId, positionsData }: PlayerPosition }, [players, currentTime]); useEffect(() => { + drawRef.current = draw; draw(); }, [draw]); @@ -471,7 +475,11 @@ export function PlayerPositionHeatmap({ fightId, positionsData }: PlayerPosition max={durationMs} step={100} value={currentTime} - onChange={(e) => setCurrentTime(Number(e.target.value))} + onChange={(e) => { + const nextTime = Number(e.target.value); + currentTimeRef.current = nextTime; + setCurrentTime(nextTime); + }} aria-label="Curseur temporel" style={{ flex: 1, minWidth: 120 }} /> diff --git a/web/src/components/ReadoutTabClient.tsx b/web/src/components/ReadoutTabClient.tsx index d57da126..cfd474aa 100644 --- a/web/src/components/ReadoutTabClient.tsx +++ b/web/src/components/ReadoutTabClient.tsx @@ -144,6 +144,7 @@ const CHART_W = 800; const CHART_H = 100; const CHART_PAD = 2; const CHART_MAX_POINTS = 200; +const EMPTY_EVENT_WINDOWS: FightEventsSummaryRow["event_windows"] = []; function TimelineMiniChart({ events }: { events: FightEventsSummaryRow | null }) { const [activityOnly, setActivityOnly] = useState(false); @@ -169,16 +170,7 @@ function TimelineMiniChart({ events }: { events: FightEventsSummaryRow | null }) } }, []); - if (!events || events.event_windows.length === 0) { - return ( -
- Aucune donnée temporelle disponible. -
- ); - } - - const raw = events.event_windows; - const durationMin = (raw[raw.length - 1]?.end_ms ?? 0) / 60000; + const raw = events?.event_windows ?? EMPTY_EVENT_WINDOWS; // Count active (non-zero) windows for the toggle label const activeWindows = useMemo( @@ -225,6 +217,16 @@ function TimelineMiniChart({ events }: { events: FightEventsSummaryRow | null }) return gaps; }, [raw, activeWindows, activityOnly]); + if (raw.length === 0) { + return ( +
+ Aucune donnée temporelle disponible. +
+ ); + } + + const durationMin = (raw[raw.length - 1]?.end_ms ?? 0) / 60000; + const maxDmg = Math.max(...points.map((p) => p.dmg), 1); const maxHeal = Math.max(...points.map((p) => p.heal), 1); @@ -499,34 +501,76 @@ const TAB_LABELS: Record = { defense: "Défense", }; +type LoadState = + | { status: "loading"; fightId: string | null; generation: number } + | { + status: "success"; + fightId: string; + generation: number; + readout: FightReadoutOut; + positions: FightPositionsOut | null; + events: FightEventsSummaryRow | null; + } + | { status: "error"; fightId: string; generation: number; error: string }; + export function ReadoutTabClient({ fightId }: ReadoutTabClientProps) { - const [readout, setReadout] = useState(null); - const [positions, setPositions] = useState(null); - const [events, setEvents] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [request, setRequest] = useState({ fightId, generation: 0 }); + if (request.fightId !== fightId) { + setRequest({ + fightId, + generation: request.generation + 1, + }); + } + const generation = request.generation; + const [loadState, setLoadState] = useState({ + status: "loading", + fightId: null, + generation: -1, + }); const [activeTableTab, setActiveTableTab] = useState("damage"); - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [r, p, e] = await Promise.all([ + useEffect(() => { + let active = true; + void Promise.all([ fetchFightReadout(fightId), fetchFightPositions(fightId).catch(() => null), fetchFightEvents(fightId).catch(() => null), - ]); - setReadout(r); - setPositions(p); - setEvents(e); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load combat data"); - } finally { - setLoading(false); - } - }, [fightId]); - - useEffect(() => { load(); }, [load]); + ]) + .then(([readoutData, positionsData, eventsData]) => { + if (!active) return; + setLoadState({ + status: "success", + fightId, + generation, + readout: readoutData, + positions: positionsData, + events: eventsData, + }); + }) + .catch((err: unknown) => { + if (!active) return; + setLoadState({ + status: "error", + fightId, + generation, + error: err instanceof Error ? err.message : "Failed to load combat data", + }); + }); + return () => { + active = false; + }; + }, [fightId, generation]); + + // A previous request may complete after the fight changes. Its data stays in + // state only until this render, where the fight id prevents it from being + // displayed. Keeping the component mounted preserves local table controls. + const currentState = + loadState.fightId === fightId && loadState.generation === generation + ? loadState + : ({ status: "loading", fightId, generation } as const); + const readout = currentState.status === "success" ? currentState.readout : null; + const positions = currentState.status === "success" ? currentState.positions : null; + const events = currentState.status === "success" ? currentState.events : null; // Role filter — MUST be before early returns const players = readout?.players ?? []; @@ -547,7 +591,7 @@ export function ReadoutTabClient({ fightId }: ReadoutTabClientProps) { const boonSort = useSortedPlayers(filteredPlayers, "boon_in_might", "desc"); const defenseSort = useSortedPlayers(filteredPlayers, "damage_taken", "desc"); - if (loading) { + if (currentState.status === "loading") { return (
⏳ Chargement des données de combat… @@ -555,10 +599,12 @@ export function ReadoutTabClient({ fightId }: ReadoutTabClientProps) { ); } - if (error || !readout) { + if (currentState.status === "error" || !readout) { return (
- {error ?? "Impossible de charger les données de combat."} + {currentState.status === "error" + ? currentState.error + : "Impossible de charger les données de combat."}
); } diff --git a/web/src/components/TimelineChart.tsx b/web/src/components/TimelineChart.tsx index 2c013669..e66769bb 100644 --- a/web/src/components/TimelineChart.tsx +++ b/web/src/components/TimelineChart.tsx @@ -128,18 +128,6 @@ const HEADER_STYLE: React.CSSProperties = { gap: 8, }; -const BUTTON_STYLE: React.CSSProperties = { - fontSize: 12, - fontFamily: "var(--font-geist-sans), Arial, Helvetica, sans-serif", - padding: "4px 10px", - border: "1px solid var(--border)", - borderRadius: 4, - background: "var(--background)", - color: "var(--foreground)", - cursor: "pointer", - fontWeight: 500, -}; - const CAPTION_STYLE: React.CSSProperties = { fontSize: 13, opacity: 0.7, diff --git a/web/src/lib/fetchCached.ts b/web/src/lib/fetchCached.ts index 62a97729..a0b8449b 100644 --- a/web/src/lib/fetchCached.ts +++ b/web/src/lib/fetchCached.ts @@ -58,6 +58,7 @@ export async function fetchCached( } if (typeof parsed.error_code === "string") errorCode = parsed.error_code; } catch { + // Non-JSON error responses retain the response text as detail. } throw new ApiError(resp.status, detail || resp.statusText || String(resp.status), errorCode); } diff --git a/web/tests/components/ReadoutTabClient.test.tsx b/web/tests/components/ReadoutTabClient.test.tsx index 12ef7d3f..6c7623f5 100644 --- a/web/tests/components/ReadoutTabClient.test.tsx +++ b/web/tests/components/ReadoutTabClient.test.tsx @@ -13,7 +13,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import type { MockInstance } from "vitest"; import * as React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; // We test the components as part of the full ReadoutTabClient. // Mock the API fetchers so we can render the client without network. @@ -392,3 +392,113 @@ describe("TimelineMiniChart export", () => { expect(clicked[0]?.href).toContain("blob:mock-timeline-export"); }); }); + +describe("ReadoutTabClient fight changes", () => { + it("preserves table controls while hiding data from the previous fight", async () => { + const firstPlayers = [ + makePlayer({ + name: "A Healer", + agent_id: 1, + roles: ["Heal"], + heal: { ...makePlayer().heal, hps: 300 }, + }), + makePlayer({ name: "A DPS", agent_id: 2, roles: ["DPS"] }), + ]; + const secondPlayers = [ + makePlayer({ + name: "B Healer high", + agent_id: 11, + roles: ["Heal"], + heal: { ...makePlayer().heal, hps: 300 }, + }), + makePlayer({ + name: "B Healer low", + agent_id: 12, + roles: ["Heal"], + heal: { ...makePlayer().heal, hps: 100 }, + }), + makePlayer({ name: "B DPS", agent_id: 13, roles: ["DPS"] }), + ]; + let resolveSecondReadout!: (value: FightReadoutOut) => void; + const secondReadout = new Promise((resolve) => { + resolveSecondReadout = resolve; + }); + + mockFetchReadout + .mockResolvedValueOnce(makeReadout(firstPlayers)) + .mockReturnValueOnce(secondReadout); + mockFetchEvents + .mockResolvedValueOnce(makeEvents(1)) + .mockResolvedValueOnce(makeEvents(1)); + + const { rerender } = render(); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Dégâts" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Soins" })); + fireEvent.click(screen.getByRole("button", { name: "Heal (1)" })); + fireEvent.click(screen.getByRole("columnheader", { name: /Heal \/ Barrier/ })); + + rerender(); + expect(screen.getByLabelText("Chargement")).toBeInTheDocument(); + expect(screen.queryByText("A Healer")).not.toBeInTheDocument(); + + resolveSecondReadout(makeReadout(secondPlayers)); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Soins" })).toBeInTheDocument(); + }); + expect(screen.getByText("2/3 joueurs")).toBeInTheDocument(); + + const rows = within( + screen.getByRole("heading", { name: "Soins" }).closest("section")!, + ).getAllByRole("row").slice(1); + expect(rows.map((row) => row.textContent)).toEqual([ + expect.stringContaining("B Healer low"), + expect.stringContaining("B Healer high"), + ]); + }); + + it("waits for the current request when returning to a fight", async () => { + let resolveInitialA!: (value: FightReadoutOut) => void; + let resolveB!: (value: FightReadoutOut) => void; + let resolveCurrentA!: (value: FightReadoutOut) => void; + const initialA = new Promise((resolve) => { + resolveInitialA = resolve; + }); + const pendingB = new Promise((resolve) => { + resolveB = resolve; + }); + const currentA = new Promise((resolve) => { + resolveCurrentA = resolve; + }); + + mockFetchReadout + .mockReturnValueOnce(initialA) + .mockReturnValueOnce(pendingB) + .mockReturnValueOnce(currentA); + mockFetchEvents + .mockResolvedValueOnce(makeEvents(1)) + .mockResolvedValueOnce(makeEvents(1)) + .mockResolvedValueOnce(makeEvents(1)); + + const { rerender } = render(); + rerender(); + rerender(); + + await act(async () => { + resolveInitialA(makeReadout([makePlayer({ name: "Old A result" })])); + }); + expect(screen.getByLabelText("Chargement")).toBeInTheDocument(); + expect(screen.queryByText("Old A result")).not.toBeInTheDocument(); + + await act(async () => { + resolveCurrentA(makeReadout([makePlayer({ name: "Current A result" })])); + resolveB(makeReadout([makePlayer({ name: "B result" })])); + }); + await waitFor(() => { + expect(screen.getByText("Current A result")).toBeInTheDocument(); + }); + expect(screen.queryByText("Old A result")).not.toBeInTheDocument(); + }); +}); diff --git a/web/tests/components/per-fight-timeline-chart.test.tsx b/web/tests/components/per-fight-timeline-chart.test.tsx index 58d1229f..ef006250 100644 --- a/web/tests/components/per-fight-timeline-chart.test.tsx +++ b/web/tests/components/per-fight-timeline-chart.test.tsx @@ -27,14 +27,29 @@ * :class:`TimelineChartPoint` with just the 3 series numbers * + placeholder ``key``/``xLabel``/``tooltip`` strings. */ -import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const { fetchFightPlayerTimelineMock } = vi.hoisted(() => ({ + fetchFightPlayerTimelineMock: vi.fn(), +})); + +vi.mock("@/lib/api/fights", async (importOriginal) => ({ + ...(await importOriginal()), + fetchFightPlayerTimeline: fetchFightPlayerTimelineMock, +})); + +vi.mock("@/components/PerFightTimelineSection", async (importOriginal) => { + return await importOriginal(); +}); + import { PerFightTimelineChart, buildPerFightTimelineLayout, formatPerFightLogTick, } from "@/components/PerFightTimelineChart"; -import type { PerFightTimelinePoint } from "@/lib/api"; +import { LazyTabbedTimelineSection } from "@/components/LazyTabbedTimelineSection"; +import type { FightPlayerTimeline, FightTimeline, PerFightTimelinePoint } from "@/lib/api"; import type { TimelineChartPoint } from "@/components/TimelineChart"; function makePoint( @@ -210,3 +225,97 @@ describe("formatPerFightLogTick", () => { expect(formatPerFightLogTick(1_500_000_000)).toBe("1.5B"); }); }); + +describe("LazyTabbedTimelineSection", () => { + it("keeps the selected per-player tab while a new window loads", async () => { + const timeline: FightTimeline = { + fight_id: "fight-1", + window_s: 10, + duration_s: 20, + points: THREE_BUCKETS, + }; + const playerTimeline = (windowS: number): FightPlayerTimeline => ({ + fight_id: "fight-1", + window_s: windowS, + duration_s: 20, + series: [{ account_name: "Player.1234", name: "Player", points: [] }], + }); + let resolveSecondRequest!: (value: FightPlayerTimeline) => void; + const secondRequest = new Promise((resolve) => { + resolveSecondRequest = resolve; + }); + fetchFightPlayerTimelineMock + .mockResolvedValueOnce(playerTimeline(10)) + .mockReturnValueOnce(secondRequest); + + const { rerender } = render( + , + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Per-player" })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("button", { name: "Per-player" })); + expect(screen.getByText(/Showing 1 player \(10-second window/)).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByTestId("timeline-skeleton")).toBeInTheDocument(); + expect(screen.queryByText(/Showing 1 player \(10-second window/)).not.toBeInTheDocument(); + + resolveSecondRequest(playerTimeline(20)); + await waitFor(() => { + expect(screen.getByText(/Showing 1 player \(20-second window/)).toBeInTheDocument(); + }); + }); + + it("waits for the current request when returning to a window", async () => { + const timeline: FightTimeline = { + fight_id: "fight-1", + window_s: 10, + duration_s: 20, + points: THREE_BUCKETS, + }; + const playerTimeline = (windowS: number): FightPlayerTimeline => ({ + fight_id: "fight-1", + window_s: windowS, + duration_s: 20, + series: [{ account_name: "Player.1234", name: "Player", points: [] }], + }); + let resolveWindow20!: (value: FightPlayerTimeline) => void; + let resolveCurrentWindow10!: (value: FightPlayerTimeline) => void; + const window20 = new Promise((resolve) => { + resolveWindow20 = resolve; + }); + const currentWindow10 = new Promise((resolve) => { + resolveCurrentWindow10 = resolve; + }); + fetchFightPlayerTimelineMock + .mockResolvedValueOnce(playerTimeline(10)) + .mockReturnValueOnce(window20) + .mockReturnValueOnce(currentWindow10); + + const { rerender } = render( + , + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Per-player" })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("button", { name: "Per-player" })); + + rerender( + , + ); + rerender( + , + ); + expect(screen.getByTestId("timeline-skeleton")).toBeInTheDocument(); + expect(screen.queryByText(/Showing 1 player \(10-second window/)).not.toBeInTheDocument(); + + resolveWindow20(playerTimeline(20)); + resolveCurrentWindow10(playerTimeline(10)); + await waitFor(() => { + expect(screen.getByText(/Showing 1 player \(10-second window/)).toBeInTheDocument(); + }); + }); +}); diff --git a/web/tests/components/player-position-heatmap.test.tsx b/web/tests/components/player-position-heatmap.test.tsx index 2227ca4c..95827171 100644 --- a/web/tests/components/player-position-heatmap.test.tsx +++ b/web/tests/components/player-position-heatmap.test.tsx @@ -6,7 +6,7 @@ */ import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; // Unmock the component under test. @@ -100,7 +100,7 @@ describe("PlayerPositionHeatmap", () => { // Play/pause button. expect( - screen.getByRole("button", { name: "Lecture" }), + screen.getByRole("button", { name: "Pause" }), ).toBeInTheDocument(); // Time slider. @@ -178,4 +178,43 @@ describe("PlayerPositionHeatmap", () => { expect(container).toHaveTextContent("0:01 / 0:01"); }); }); + + it("resumes playback from the time selected by the slider", async () => { + let lastFrame: FrameRequestCallback | undefined; + let frameId = 0; + const rafSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback) => { + lastFrame = callback; + frameId += 1; + return frameId; + }); + + try { + mockFetchFightPositions.mockResolvedValueOnce({ players: [MOCK_PLAYERS[0]] }); + render(); + + const pauseButton = await screen.findByRole("button", { name: "Pause" }); + fireEvent.click(pauseButton); + const slider = screen.getByRole("slider", { name: "Curseur temporel" }); + fireEvent.change(slider, { target: { value: "500" } }); + expect(slider).toHaveValue("500"); + + const previousFrameCount = rafSpy.mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "Lecture" })); + await waitFor(() => { + expect(rafSpy.mock.calls.length).toBeGreaterThan(previousFrameCount); + }); + + act(() => { + lastFrame?.(performance.now() + 100); + }); + await waitFor(() => { + expect(Number(slider.getAttribute("value"))).toBeGreaterThan(550); + expect(Number(slider.getAttribute("value"))).toBeLessThan(700); + }); + } finally { + rafSpy.mockRestore(); + } + }); }); diff --git a/web/tests/setup.ts b/web/tests/setup.ts index 9b750023..c534234f 100644 --- a/web/tests/setup.ts +++ b/web/tests/setup.ts @@ -61,7 +61,7 @@ beforeEach(() => { if (typeof URL.createObjectURL !== "function") { URL.createObjectURL = (() => { let counter = 0; - return (obj: Blob | MediaSource): string => { + return (_obj: Blob | MediaSource): string => { counter += 1; return `blob:mock-${counter.toString(36)}`; }; @@ -353,4 +353,3 @@ vi.mock("@/components/PerFightTimelineSection", () => ({ * chart be tested directly at the component level. */ -