Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
50 lines
4.2 KiB
Markdown
50 lines
4.2 KiB
Markdown
---
|
|
issueRef: "#130"
|
|
version: 6
|
|
updatedBy: {"kind":"user"}
|
|
updatedAt: 1785243026193
|
|
---
|
|
|
|
## #130 — Cadrage technique Architect (2026-07-27)
|
|
|
|
**Root cause identifiée dans le code réel** — bug critique de non-idempotence, confirmé et reproductible par construction (pas besoin du détail score 20/25 pour l'expliquer, mais cohérent avec le scénario rapporté).
|
|
|
|
Erreur SQLite 2067 = violation de contrainte `UNIQUE`. La table `active_set_results` (`lib/infrastructure/local/tables.dart:473-513`) porte :
|
|
```sql
|
|
UNIQUE (active_workout_session_id, program_index, exercise_index, set_index)
|
|
```
|
|
|
|
`ActiveWorkoutSessionUseCases.recordCurrentSetResult` (`lib/application/use_cases.dart:2451-2511`), appelée par le bouton "Terminer la série" (`workout_execution_screen.dart:1459`, via `_recordAndAdvance`), construit **toujours** un `ActiveSetResult` avec une **metadata/id neuve** (`_newMetadata(ids, originDeviceId, now)`, ligne 2485) puis appelle `sessionRepository.saveSetResult` → `insertOnConflictUpdate` (`drift_repositories.dart:1130-1140`). Drift résout ce conflit sur la **clé primaire** (`id`), pas sur la contrainte `UNIQUE` métier — donc si une ligne existe déjà pour `(session, program, exercise, set)` avec un autre `id`, `insertOnConflictUpdate` ne trouve pas de conflit sur `id`, tente un `INSERT`, et percute l'`UNIQUE` → 2067.
|
|
|
|
**Comparer avec le code correct existant** : `upsertSetResultAtPosition` (ligne 2513-2588, utilisée pour l'édition a posteriori d'une série passée) fait ça bien — elle **cherche d'abord** la ligne existante via `listSetResults` (ligne 2546-2555) et réutilise son `metadata`/`id` (`existing.metadata.touch(now)`) si trouvée, sinon en crée une neuve. C'est le pattern à répliquer dans `recordCurrentSetResult`.
|
|
|
|
**Deux points d'entrée indépendants écrivent sur la même clé sans coordination**, ce qui rend la collision non hypothétique :
|
|
1. Le bouton téléphone (`_recordAndAdvance` → `recordCurrentSetResult`).
|
|
2. Le handler de commande montre `_finishCurrentSet` (`use_cases.dart:3546`, déclenché par `WatchCommandType.finishCurrentSet`/`skipCurrentSet`, ligne 3472-3476) qui appelle **aussi** `recordCurrentSetResult` (ligne 3574) — un chemin d'écriture séparé pour la même position logique.
|
|
|
|
Toute situation où une ligne existe déjà pour cette position au moment de l'appel (retry après un premier appel resté en vol, double-tap sans garde métier au niveau du use case, ou commande montre + tap téléphone proches dans le temps) déclenche l'erreur — pas seulement le cas particulier score/reps décrit dans le ticket, mais c'est cohérent avec un scénario où l'utilisateur a interagi via les deux surfaces (téléphone + montre) pour la même série.
|
|
|
|
### Correctif (contrat clair pour DevBackend)
|
|
Faire de `recordCurrentSetResult` un **véritable upsert sur la clé logique**, à l'identique du pattern déjà en place dans `upsertSetResultAtPosition` :
|
|
```dart
|
|
final existingResults = await sessionRepository.listSetResults(sessionId);
|
|
final existing = existingResults.firstWhereOrNull((r) =>
|
|
r.programIndex == programIndex &&
|
|
r.exerciseIndex == exerciseIndex &&
|
|
r.setIndex == setIndex);
|
|
final result = ActiveSetResult(
|
|
metadata: existing == null
|
|
? _newMetadata(ids, originDeviceId, now)
|
|
: existing.metadata.touch(now),
|
|
// ... reste inchangé
|
|
);
|
|
```
|
|
Ce correctif rend `recordCurrentSetResult` idempotent et sûr vis-à-vis des deux points d'entrée (téléphone/montre) et de tout retry, sans changer sa signature publique ni le contrat vu par l'UI/la montre.
|
|
|
|
### Tests
|
|
- Use case : appeler `recordCurrentSetResult` deux fois de suite pour la même position (simulant un retry ou une double soumission téléphone/montre) → doit réussir les deux fois sans exception, avec la seconde valeur qui gagne (dernière écriture) et un seul enregistrement en base.
|
|
- Cas croisé : `recordCurrentSetResult` puis vérifier `listSetResults` ne retourne qu'une ligne pour cette position.
|
|
- Régression : vérifier que `upsertSetResultAtPosition` reste inchangée et continue de passer ses tests existants.
|
|
|
|
**Prêt pour implémentation directe, aucun arbitrage supplémentaire nécessaire. Priorité critique confirmée** (bloque la fin de série dans un cas déjà survenu en usage réel).
|