/// Le contrat `measurement` de `1.15.2+etm26` — **écrit avant son déploiement**. /// /// `measuredW` portait trois régimes sous un seul nom et jetait la distinction : une borne /// rendait une mesure, un routeur de relais une mesure **ou** son palier nominal, un /// SG-Ready une puissance toujours déclarée. Le régime se publie désormais : /// `measurement: {source: "meter" | "device" | "none", powerW}`. /// /// Ces tests figent les trois pièges d'affichage nommés dans le §7 du brief, le retrait de /// l'alias `measuredW` (2026-08-28, `+etm26` déployé), et la garantie qui rend la lecture /// sûre : `measurement` est présent pour toute charge de `loads[]`, et pour elle seule. library; import 'package:etm_powersync_app/l10n/app_localizations.dart'; import 'package:etm_powersync_app/models/load_telemetry.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; import 'package:etm_powersync_app/widgets/load_cards.dart'; LoadTelemetryEntry _entree(Map extra) => LoadTelemetryEntry.fromJson({ 'loadId': 'x', 'allocatedW': 3000, 'available': true, 'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}}, ...extra, }); void main() { group('le régime est LU, jamais déduit', () { test('les trois sources se lisent telles que la box les publie', () { expect( _entree({ 'measurement': {'source': 'meter', 'powerW': 3980.0} }).measurement.source, MeasurementSource.meter); expect( _entree({ 'measurement': {'source': 'device', 'powerW': 12.0} }).measurement.source, MeasurementSource.device); expect( _entree({ 'measurement': {'source': 'none'} }).measurement.source, MeasurementSource.none); }); test('`none` n\'a PAS de puissance — même si la box en met une', () { // Le régime est ce qui est publié, pas la valeur : sous `none`, il n'y a rien à // afficher, et un nombre qui traînerait ne doit pas ressusciter le bloc. final m = _entree({ 'measurement': {'source': 'none', 'powerW': 500.0} }).measurement; expect(m.powerW, isNull); expect(m.mesuree, isFalse); }); test('{device, 0} est une MESURE, pas une absence', () { // Une borne branchée qui ne charge pas mesure zéro watt, et c'est une information. // Si la source basculait vers `none` à l'extinction, le bloc de mesure // apparaîtrait et disparaîtrait au rythme du thermostat. final m = _entree({ 'measurement': {'source': 'device', 'powerW': 0.0} }).measurement; expect(m.mesuree, isTrue); expect(m.powerW, 0.0); }); test('une source inconnue n\'est pas devinée', () { // Une box plus récente que l'app : on affichera la valeur sans prétendre savoir ce // qu'un écart prouverait. final m = _entree({ 'measurement': {'source': 'futur', 'powerW': 100.0} }).measurement; expect(m.source, MeasurementSource.inconnue); expect(m.mesuree, isTrue); expect(m.concordanceProbante, isFalse); }); }); group('l\'alias measuredW est RETIRÉ — +etm26 déployé le 2026-08-28', () { test('l\'ancienne clé n\'est plus lue du tout', () { // Vérifié sur `.75` avant de retirer : `measuredW` a disparu de la charge utile. // Le garder « au cas où » ferait vivre deux noms pour une chose — et ils divergent. expect(_entree({'measuredW': 1500.0}).measurement.mesuree, isFalse); }); test('`measurement` absent ne veut PAS dire « pas mesurable »', () { // Garantie du contrat : `measurement` est présent pour toute charge de `loads[]`, et // pour elle seule. Son absence ne peut donc signifier que « charge hors de loads[] » // — cas où l'on ne conclut rien. `none`, lui, est un état publié. final m = _entree(const {}).measurement; expect(m.mesuree, isFalse); expect(m.source, MeasurementSource.none, reason: 'valeur de repli inerte — l\'écran distingue le cas par l\'absence ' 'de la charge dans loads[], pas par cette valeur'); }); test('une source future n\'est pas devinée, et n\'efface pas la valeur', () { final m = _entree({ 'measurement': {'source': 'estimated', 'powerW': 100.0} }).measurement; expect(m.source, MeasurementSource.inconnue); expect(m.powerW, 100.0); expect(m.concordanceProbante, isFalse); }); }); group('ce qu\'une concordance prouve dépend de la source', () { test('seul `meter` rend une concordance probante', () { // Plusieurs wallbox renvoient leur consigne en guise de puissance : sous `device`, // « commandé 3000, mesuré 3000 » peut n'être qu'un écho. for (final (src, attendu) in [ ('meter', true), ('device', false), ('none', false), ]) { expect( _entree({ 'measurement': {'source': src, 'powerW': 3000.0} }).measurement.concordanceProbante, attendu, reason: src); } }); }); group('à l\'écran', () { Widget carte(Map extra) { final tel = LoadTelemetryProvider() ..adoptForTest({ 'degradedMode': false, 'loads': [ { 'loadId': 'x', 'allocatedW': 3000, 'available': true, 'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}}, ...extra, } ], }); return MaterialApp( // `LoadLiveBlock` lit `context.l10n` : sans délégués, il explose avant d'afficher. locale: const Locale('fr'), localizationsDelegates: L10n.localizationsDelegates, supportedLocales: L10n.supportedLocales, home: Scaffold( body: LoadLiveBlock( live: tel.entryFor('x')!, stale: false, telemetry: tel), ), ); } testWidgets('`none` MASQUE le bloc — pas de vide, pas de zéro', (tester) async { await tester.pumpWidget(carte({ 'measurement': {'source': 'none'} })); expect(find.textContaining('Mesuré'), findsNothing); // Et surtout aucun « écart » : « 0 W d'écart » serait la pire des réponses. expect(find.textContaining('conforme'), findsNothing); expect(find.textContaining('consigne'), findsNothing); }); testWidgets('`device` affiche la mesure ET ce qu\'elle ne prouve pas', (tester) async { await tester.pumpWidget(carte({ 'measurement': {'source': 'device', 'powerW': 2950.0} })); expect(find.textContaining('Mesuré 2950 W'), findsOneWidget); expect(find.textContaining('une concordance ne prouve rien'), findsOneWidget); }); testWidgets('`meter` dit que la mesure est indépendante', (tester) async { await tester.pumpWidget(carte({ 'measurement': {'source': 'meter', 'powerW': 2950.0} })); expect(find.textContaining('Mesuré 2950 W'), findsOneWidget); expect(find.textContaining('mesure indépendante'), findsOneWidget); }); testWidgets('un zéro mesuré s\'affiche — c\'est une information', (tester) async { await tester.pumpWidget(carte({ 'measurement': {'source': 'device', 'powerW': 0.0} })); expect(find.textContaining('Mesuré 0 W'), findsOneWidget); }); testWidgets('une source inconnue affiche la valeur SANS portée', (tester) async { // On montre ce que la box dit ; on ne prétend pas savoir ce qu'un écart prouverait. await tester.pumpWidget(carte({ 'measurement': {'source': 'estimated', 'powerW': 1500.0} })); expect(find.textContaining('Mesuré 1500 W'), findsOneWidget); expect(find.textContaining('concordance'), findsNothing); expect(find.textContaining('indépendante'), findsNothing); }); testWidgets('les trois cas du banc, tels que .75 les publie le 2026-08-28', (tester) async { // chauffe-eau et pac-terrain portent chacun un compteur dédié (ECS-Meter, // PAC-Meter) : source `meter`, 1 500 W et 800 W. `pac-terrain` en `meter` ne // contredit pas LM-1105-b — cette règle décrit ce qu'une PAC publie SANS compteur // dédié, et le compteur explicite l'emporte toujours. for (final (w, _) in [(1500.0, 'chauffe-eau'), (800.0, 'pac-terrain')]) { await tester.pumpWidget(carte({ 'measurement': {'source': 'meter', 'powerW': w} })); expect(find.textContaining('Mesuré ${w.round()} W'), findsOneWidget); expect(find.textContaining('mesure indépendante'), findsOneWidget); } }); }); }