etm-powersync-app/test/brouillon_sans_issue_test.dart
Patrick Schurig 1dece8eb33 fix(brouillon): la barre suit — et le déplacement a révélé un défaut de contrainte
La barre d'action passe en pied d'écran fixe (`bottomNavigationBar`), parce
qu'un correctif juste mais hors du champ reste inaccessible : le geste se fait
en haut de la liste, la barre était un écran entier plus bas. C'est le défaut
d'hier sous une autre forme — cause de disposition au lieu de cause de logique.

`bottomNavigationBar` plutôt qu'une superposition : il RÉDUIT la hauteur du
corps au lieu de le recouvrir, donc rien ne se cache derrière la barre.

⚠️ ET LE DÉPLACEMENT A CASSÉ L'ÉCRAN. Le `Column` d'`ImpactSaveBar` était en
`MainAxisSize.max` — le défaut. Dans un `ListView` la hauteur disponible est
infinie et le Column se contente de ses enfants : le défaut ne pouvait pas se
voir. En pied d'écran la contrainte est lâche mais BORNÉE, le Column a pris
toute la hauteur, le corps a été écrasé à zéro. Écran vide sous une barre
géante, vu en release sur appareil — les 319 tests étaient verts.

Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer
change ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé.

Le test ajouté monte la barre dans un `bottomNavigationBar` et mesure sa
hauteur. Éprouvé par son contraire : sans `mainAxisSize: min` il rend 600 sur
600 et échoue. Aucun test précédent ne pouvait mordre — tous montaient la barre
dans une hauteur infinie, où `max` se comporte comme `min`.

Vérifié sur appareil, chemin des flèches : brouillon réel -> barre en bas avec
Annuler/Enregistrer, corps intact ; ordre rétabli -> « L'ordre est revenu à
celui de la box » avec le seul « Terminer » ; Terminer -> barre absente. Banc
inchangé, aucune écriture.

Et la note gardée sur `draftIsNoOp` : « rien à enregistrer » veut dire « rien
par rapport à ce que tu as vu ». Comparer à l'état vivant conclurait à la place
de l'utilisateur sur des faits qu'il n'a pas vus. Le référentiel de la
CONCLUSION et celui de l'ÉCRITURE sont deux choses, et il faut les deux.

Capture : docs/captures/2026-09-08_barre-en-pied-terminer.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 08:30:11 +02:00

249 lines
9.9 KiB
Dart

/// **Un brouillon revenu à son point de départ doit pouvoir se CONCLURE.**
///
/// « Je n'ai rien changé » et « j'ai décidé de ne rien changer » sont deux états
/// différents. Le second s'atteint par le seul geste de l'écran des charges qui n'a pas de
/// repli — déplacer une plage — puis en le défaisant.
///
/// L'écran traitait les deux pareil, et n'offrait plus pour en sortir que des portes mal
/// nommées : la barre annonçait « Modifications en attente », la sortie d'écran menaçait
/// d'abandonner des changements, l'adoption d'un rang et le retrait d'une charge
/// réclamaient d'« enregistrer ou annuler ». Quatre messages, tous faux au même moment.
///
/// ⚠️ Le remède N'EST PAS d'effacer le brouillon dès qu'il devient sans effet : la barre
/// disparaîtrait sous le doigt à l'instant où l'utilisateur repose la plage, et
/// « j'ai décidé » redeviendrait « il ne s'est rien passé ».
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:etm_powersync_app/widgets/controllable_loads_section.dart';
import 'package:etm_powersync_app/widgets/garde_brouillon.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
Map<String, dynamic> _charge(String id, String label, String domain, int rang) => {
'id': id,
'label': label,
'domain': domain,
'priority': rang,
'adapter': 'relay-router',
'mode': 'fixed',
'enabled': true,
};
/// Trois domaines distincts : trois plages, donc trois éléments déplaçables.
List<Map<String, dynamic>> _config() => [
_charge('a', 'V2C Trydan', 'ev', 1),
_charge('b', 'chauffe-eau', 'ecs', 2),
_charge('c', 'PAC banc', 'heating', 3),
];
LoadConfigProvider _provider() => LoadConfigProvider()..adoptForTest(_config());
void main() {
group('l\'état « j\'ai décidé de ne rien changer »', () {
test('sans brouillon, il n\'existe pas', () {
final p = _provider();
expect(p.hasPendingChanges, isFalse);
expect(p.draftIsNoOp, isFalse,
reason: 'pas de brouillon n\'est pas un brouillon sans effet');
expect(p.hasSubstantiveChanges, isFalse);
});
test('un déplacement réel change vraiment quelque chose', () {
final p = _provider()..reorderDomains(2, 0);
expect(p.hasPendingChanges, isTrue);
expect(p.draftIsNoOp, isFalse);
expect(p.hasSubstantiveChanges, isTrue);
});
test('déplacer puis remettre laisse un brouillon SANS EFFET', () {
final p = _provider()
..reorderDomains(2, 0) // C remonte en tête
..reorderDomains(0, 3); // et redescend en queue
expect(p.hasPendingChanges, isTrue,
reason: 'le brouillon SURVIT — il ne s\'évapore pas sous le doigt');
expect(p.draftIsNoOp, isTrue);
expect(p.hasSubstantiveChanges, isFalse);
// Et l'ordre affiché est bien celui du départ.
expect([for (final g in p.groups) ...g.loads.map((e) => e.id)],
['a', 'b', 'c']);
});
});
group('les portes de sortie disent la vérité', () {
testWidgets('la barre ne propose plus d\'enregistrer, elle propose de TERMINER',
(tester) async {
final p = _provider()
..reorderDomains(2, 0)
..reorderDomains(0, 3);
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ImpactSaveBar(provider: p)),
));
expect(find.textContaining('rien à enregistrer'), findsOneWidget);
expect(find.text('Terminer'), findsOneWidget);
// Les contrôles qui MORDENT : les deux portes mal nommées ont disparu.
expect(find.text('Enregistrer'), findsNothing,
reason: 'réécrire tout l\'ensemble pour n\'y rien changer '
'reconstruirait les charges pour rien');
expect(find.textContaining('Modifications en attente'), findsNothing);
await tester.tap(find.text('Terminer'));
await tester.pump();
expect(p.hasPendingChanges, isFalse, reason: 'l\'état s\'est CONCLU');
});
testWidgets('un brouillon réel garde sa barre d\'enregistrement', (tester) async {
final p = _provider()..reorderDomains(2, 0);
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ImpactSaveBar(provider: p)),
));
expect(find.text('Enregistrer'), findsOneWidget);
expect(find.text('Terminer'), findsNothing);
});
testWidgets('la sortie d\'écran ne menace plus d\'une perte inexistante',
(tester) async {
final p = _provider()
..reorderDomains(2, 0)
..reorderDomains(0, 3);
await tester.pumpWidget(ChangeNotifierProvider<LoadConfigProvider>.value(
value: p,
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const GardeBrouillon(
child: Scaffold(body: Text('écran des charges')),
),
)),
child: const Text('ouvrir'),
),
),
),
),
));
await tester.tap(find.text('ouvrir'));
await tester.pumpAndSettle();
expect(find.text('écran des charges'), findsOneWidget);
// Retour arrière : aucune alerte, l'écran se ferme.
final nav = tester.state<NavigatorState>(find.byType(Navigator));
nav.maybePop();
await tester.pumpAndSettle();
expect(find.text('Modifications non enregistrées'), findsNothing,
reason: 'il n\'y avait rien à perdre');
expect(find.text('écran des charges'), findsNothing);
expect(p.hasPendingChanges, isFalse,
reason: 'le brouillon vide est ramassé, pas laissé traîner');
});
testWidgets('un brouillon RÉEL fait toujours barrage', (tester) async {
final p = _provider()..reorderDomains(2, 0);
await tester.pumpWidget(ChangeNotifierProvider<LoadConfigProvider>.value(
value: p,
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const GardeBrouillon(
child: Scaffold(body: Text('écran des charges')),
),
)),
child: const Text('ouvrir'),
),
),
),
),
));
await tester.tap(find.text('ouvrir'));
await tester.pumpAndSettle();
tester.state<NavigatorState>(find.byType(Navigator)).maybePop();
await tester.pumpAndSettle();
expect(find.text('Modifications non enregistrées'), findsOneWidget);
expect(find.text('écran des charges'), findsOneWidget);
});
});
testWidgets('la barre n\'est plus DANS la liste — elle est en pied d\'écran',
(tester) async {
// Posée à la suite des cartes, elle sortait du champ à l'instant où le geste la fait
// apparaître : le correctif était juste et restait inatteignable. Une porte qu'on ne
// voit pas est une porte fermée.
//
// Ce test garde le NÉGATIF — qu'elle ne revienne pas s'ancrer dans la liste. Le
// positif (elle est bien en pied, visible sans défiler) se vérifie à l'appareil :
// monter l'écran entier ici demanderait cinq providers et ne prouverait toujours rien
// sur ce qui est à l'écran sans défilement.
final config = LoadConfigProvider()..adoptForTest(_config());
config.reorderDomains(2, 0);
expect(config.hasPendingChanges, isTrue);
await tester.pumpWidget(MultiProvider(
providers: [
ChangeNotifierProvider.value(value: config),
ChangeNotifierProvider.value(value: LoadTelemetryProvider()),
ChangeNotifierProvider.value(value: NymeaService()),
],
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
child: ControllableLoadsSection(onConfigure: (_, __) {}),
),
),
),
));
await tester.pump();
expect(find.byType(ImpactSaveBar), findsNothing,
reason: 'la section ne doit plus porter la barre');
});
testWidgets('la barre tient dans un pied d\'écran — elle ne mange pas le corps',
(tester) async {
// Le contrôle qui MORD, et qu'aucun des 319 tests précédents ne pouvait faire :
// tous montaient la barre dans une hauteur INFINIE (un `ListView`), où
// `MainAxisSize.max` — le défaut d'un `Column` — se comporte comme `min`.
//
// En `bottomNavigationBar` la contrainte est lâche mais BORNÉE par l'écran, et le
// Column a pris toute la hauteur : corps écrasé à zéro, écran vide sous la barre.
// Trouvé à l'appareil, en release, pas ici.
//
// Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer change
// ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé.
final p = _provider()..reorderDomains(2, 0);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
bottomNavigationBar: ImpactSaveBar(provider: p),
body: const Center(child: Text('le corps')),
),
));
await tester.pump();
final h = tester.getSize(find.byType(ImpactSaveBar)).height;
final ecran = tester.getSize(find.byType(Scaffold)).height;
expect(h, lessThan(ecran / 3),
reason: 'la barre doit se contenter de ses enfants, pas prendre l\'écran '
'(mesurée : $h sur $ecran)');
expect(find.text('le corps'), findsOneWidget);
});
}