diff --git a/CLAUDE.md b/CLAUDE.md index 9b5bbb4..eff71d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,9 +262,43 @@ depuis `+etm23`, `adjustEvChargers()` ne commande plus, l'arbitre est seul (mesu commandes à la borne, 9 par l'arbitre, 0 par le proxy). Mais ce qui devient vrai, c'est que **personne d'autre ne commande** — pas que la charge obéit. Un plafond matériel, un véhicule qui refuse, un câble débranché font toujours diverger l'ordre et la réalité. -L'écart se lit dans `measuredW` et dans la charge utile `mechanism` (`chargingEnabled`, +L'écart se lit dans `measurement` et dans la charge utile `mechanism` (`chargingEnabled`, `currentA`, `phaseCount`, `pluggedIn`), **jamais dans `allocatedW`**. +**2-bis. `measuredW` n'existe plus — `measurement` porte le RÉGIME, pas seulement la +valeur** (`+etm26`, déployé sur `.75` le 2026-08-28) : + +```jsonc +"measurement": { "source": "meter" | "device" | "none", "powerW": 3980.0 } +``` + +`meter` = un compteur a été désigné exprès · `device` = l'appareil publie lui-même · +`none` = **pas mesurable**, et `powerW` est alors absent. + +Trois règles d'affichage, chacune payée une fois : + +- **`none` est un état positif**, pas une absence : il autorise à **masquer** le bloc de + mesure, et sépare « pas mesurable » de « compteur en panne ». Ne jamais combler avec une + puissance nominale de `mechanism.*` — l'écart serait identiquement nul à tous les cycles, + soit un « tout concorde » qui ne signale plus rien. +- **`{source: "device", powerW: 0}` est une MESURE.** Une borne branchée qui ne charge pas + mesure zéro watt. La source ne bascule pas vers `none` quand la charge s'éteint. +- **Ce qu'un écart prouve dépend de la source** : sous `device`, il montre la désobéissance + mais une *concordance* ne prouve rien — plusieurs wallbox renvoient leur consigne ; sous + `meter`, la mesure est indépendante et la concordance devient une preuve. + +Et **deux pièges de lecture**, tous deux au contrat : + +1. **Décider sur `measurement.source`, jamais sur `adapter`.** Un SG-Ready est `none` par + construction (LM-1105-b : son contact commande un signal, la PAC est alimentée ailleurs) + — mais un routeur de relais à topologie **mixte** l'est aussi, une somme partielle + n'étant pas une mesure. « Masqué dès que la charge mesure, sauf SG-Ready » manque ce + second cas. +2. **`measurement` est présent pour toute charge de `loads[]`, et pour elle seule.** Donc + une charge **absente** de `loads[]` n'est pas `none` : elle ne dit rien. Sur `.75` le + 2026-08-28, la V2C était absente (`pluggedIn: false`) alors qu'elle publie + `currentPower` — la déclarer « pas mesurable » aurait été faux. + **3. `EV_GRID_START` partage une allocation entre les DEUX compteurs du budget.** C'est le seul motif qui le fasse : `budgetW` vient du surplus et entre dans `budget.allocatedW`, `gridW` est acheté au réseau et entre dans `budget.evReservedW`, avec diff --git a/TODO.md b/TODO.md index 86ce9ae..13bc3d1 100644 --- a/TODO.md +++ b/TODO.md @@ -2,14 +2,12 @@ ## Ouvert au 2026-08-27 -- [ ] ⏳ **Retirer l'alias `measuredW` dès que `1.15.2+etm26` est sur `.75`.** - L'app lit `measurement: {source, powerW}` et retombe sur `measuredW` en son absence — - uniquement pour couvrir l'intervalle entre ce livrable et le déploiement du moteur, sans - quoi la mesure disparaîtrait des écrans quelques jours. **Le moteur, lui, ne double pas - la clé** : deux noms pour une chose divergent. La branche à supprimer est nommée dans - `LoadMeasurement.fromJson` (`models/load_telemetry.dart`), avec la sonde qui tranche : - `dart tools/rpc/probe.dart 192.168.1.75 NymeaEnergy.GetLoadTelemetry` — dès que - `measurement` y figure, l'alias part, et `MeasurementSource.legacy` avec lui. +- [x] ~~⏳ Retirer l'alias `measuredW`~~ — **fait le 2026-08-28**, `1.15.2+etm26` déployé + sur `.75` et vérifié par sonde : `measuredW` a disparu de la charge utile, `measurement` + est en ligne. `MeasurementSource.legacy` est parti avec lui ; ce qui reste, + `MeasurementSource.inconnue`, couvre une box **plus récente** que l'app, pas plus + ancienne. Sources relevées sur le banc : `meter` 1 500 W (chauffe-eau, ECS-Meter), + `meter` 800 W (pac-terrain, PAC-Meter), et la V2C **absente de `loads[]`** — pas `none`. - [x] ~~🔴 **Bilan JOURNALIER**~~ — **fait le 2026-08-28.** Les quatre tuiles montrent la diff --git a/integration_test/config_on_device_test.dart b/integration_test/config_on_device_test.dart index 4e90a7b..187ba50 100644 --- a/integration_test/config_on_device_test.dart +++ b/integration_test/config_on_device_test.dart @@ -27,6 +27,7 @@ import 'package:etm_powersync_app/main.dart' as app; import 'package:etm_powersync_app/providers/energy_setup_provider.dart'; import 'package:etm_powersync_app/providers/installer_mode_provider.dart'; import 'package:etm_powersync_app/providers/load_config_provider.dart'; +import 'package:etm_powersync_app/models/load_telemetry.dart'; import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; import 'package:etm_powersync_app/providers/schema_provider.dart'; import 'package:etm_powersync_app/screens/energy/roles_devices_screen.dart'; @@ -480,16 +481,17 @@ void main() { final tele = Provider.of(ctx, listen: false); await _attendre( tester, - () => config.entries - .every((e) => tele.entryFor(e.id)?.measuredW != null), + () => config.entries.every( + (e) => tele.entryFor(e.id)?.measurement.source == MeasurementSource.meter), limite: const Duration(seconds: 150), - quoi: 'la publication de measuredW pour chaque charge', + quoi: 'la publication de measurement.source = meter pour chaque charge', ); final mesures = { - for (final e in config.entries) e.id: tele.entryFor(e.id)?.measuredW + for (final e in config.entries) + e.id: tele.entryFor(e.id)?.measurement.powerW }; - debugPrint('[appareil] measuredW par charge : $mesures'); + debugPrint('[appareil] mesure par charge : $mesures'); expect(mesures.values.every((v) => v != null), isTrue); // Ce qui prouve le rattachement PAR CHARGE, c'est que chacune porte un compteur @@ -501,9 +503,14 @@ void main() { expect(compteurs.values.toSet().length, compteurs.length, reason: 'chaque charge doit porter SON propre compteur'); - // Et une charge SANS compteur ne publie rien — `null` n'est pas 0. On le vérifie en - // détachant, puis on rattache de nouveau : afficher un zéro à la place ferait passer - // une absence de mesure pour une absence de consommation. + // Et détacher le compteur doit faire CHANGER LE RÉGIME, pas forcément disparaître la + // mesure. + // + // ⚠️ Ce test exigeait `measuredW == null` après détachement. C'était vrai avant + // `+etm26` et c'est faux depuis : le moteur retombe sur l'appareil quand il publie + // lui-même sa puissance (`source: "device"`), et ne publie `none` que s'il n'y a + // vraiment rien à mesurer. Les deux sont corrects — ce qui doit cesser, c'est que la + // source vaille `meter` alors qu'aucun compteur n'est désigné. final cible = config.entries.first; final garde = cible.meterThingId; config.replaceEntry(cible.id, cible.patched(meterThingId: '')); @@ -517,12 +524,18 @@ void main() { ); await _attendre( tester, - () => tele.entryFor(cible.id)?.measuredW == null, + () => + tele.entryFor(cible.id)?.measurement.source != MeasurementSource.meter, limite: const Duration(seconds: 150), - quoi: 'l\'arrêt de publication de measuredW après détachement', + quoi: 'le changement de régime après détachement (meter → device ou none)', ); - debugPrint('[appareil] ${cible.id} sans compteur → measuredW = ' - '${tele.entryFor(cible.id)?.measuredW}'); + final apres = tele.entryFor(cible.id)?.measurement; + debugPrint('[appareil] ${cible.id} sans compteur → source=' + '${apres?.source.name} powerW=${apres?.powerW}'); + // Sous `none`, il n'y a pas de puissance — et `0` n'en est pas l'équivalent. + if (apres?.source == MeasurementSource.none) { + expect(apres?.powerW, isNull); + } // Rendre le rattachement : il est demandé, il reste. final remise = config.entries.firstWhere((e) => e.id == cible.id); diff --git a/lib/features/dashboard/widgets/energy_flow_card.dart b/lib/features/dashboard/widgets/energy_flow_card.dart index 6b3208d..371fea8 100644 --- a/lib/features/dashboard/widgets/energy_flow_card.dart +++ b/lib/features/dashboard/widgets/energy_flow_card.dart @@ -246,7 +246,7 @@ class _EnergyFlowCardState extends State // // Elles sont donc marquées `dashed` / `estimated`, comme « Autres » l'était déjà // seul, et la légende sous le schéma le dit en toutes lettres. Le marquage n'est - // pas le correctif : la source existe (`GetLoadTelemetry` publie `measuredW` par + // pas le correctif : la source existe (`GetLoadTelemetry` publie `measurement` par // charge), et l'y brancher est un lot à part — cf. `TODO.md`. En attendant, un // écran qui annonce son estimation ne trompe personne ; un écran qui affiche // « PAC 1,3 kW » en dur, si. diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 23e5866..613dc98 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -98,9 +98,13 @@ enum MeasurementSource { /// Pas mesurable — état positif, pas une absence. none, - /// **Box antérieure à `+etm26`** : `measuredW` était là, sans régime. On affiche la - /// valeur, on ne prétend rien sur ce qu'un écart prouverait. - legacy, + /// Une source qu'**aucune version de cette app ne connaît** — box plus récente. + /// + /// Ce n'est pas l'ancien repli `measuredW`, retiré le 2026-08-28 avec le déploiement de + /// `+etm26` : c'est la règle qui vaut partout ailleurs dans ce fichier — un code inconnu + /// tombe sur un repli lisible, jamais sur une phrase inventée. On affiche la valeur, on + /// ne prétend rien sur ce qu'un écart prouverait. + inconnue, } class LoadMeasurement { @@ -123,38 +127,33 @@ class LoadMeasurement { /// peut n'être qu'un écho. L'écart, lui, reste informatif dans les deux cas. bool get concordanceProbante => source == MeasurementSource.meter; - /// Lecture tolérante — `measurement` s'il est là, `measuredW` sinon. + /// Lit `measurement` — **et rien d'autre**. /// - /// > ⏳ **ALIAS TEMPORAIRE, à retirer quand `1.15.2+etm26` sera déployé sur `.75`.** - /// > Il n'existe que pour couvrir l'intervalle entre cette version de l'app et le - /// > déploiement du moteur : sans lui, la mesure disparaîtrait des écrans pendant - /// > quelques jours. Le moteur, lui, **ne double pas la clé** — deux noms pour une chose - /// > divergent — et l'app ne doit pas les doubler plus longtemps que nécessaire. - /// > Vérification : `dart tools/rpc/probe.dart 192.168.1.75 NymeaEnergy.GetLoadTelemetry` - /// > ; dès que `measurement` y figure, supprimer la branche `measuredW` et ce bloc. + /// L'alias temporaire vers `measuredW` a été retiré le 2026-08-28 : `1.15.2+etm26` est + /// déployé sur `.75`, et `measuredW` a bien disparu de la charge utile (vérifié). Il n'a + /// existé que pour couvrir l'intervalle entre le livrable de l'app et celui du moteur. + /// Le moteur ne double jamais une clé — deux noms pour une chose divergent — et l'app ne + /// les double pas plus longtemps que nécessaire. + /// + /// **La garantie du contrat, celle qui rend cette lecture sûre** : `measurement` est + /// présent pour **toute** charge de `loads[]`, et pour elle seule. Son absence ici ne + /// peut donc pas vouloir dire « pas mesurable » — ça, c'est [MeasurementSource.none], + /// qui est un état **publié**. Elle ne peut vouloir dire que « cette charge n'est pas + /// dans `loads[]` », et là on ne conclut rien, on n'affiche rien qui affirme. factory LoadMeasurement.fromJson(Map m) { final brut = m['measurement']; - if (brut is Map) { - final src = brut['source'] as String?; - final source = switch (src) { - 'meter' => MeasurementSource.meter, - 'device' => MeasurementSource.device, - 'none' => MeasurementSource.none, - // Une source qu'aucune version d'app ne connaît : on affiche la valeur sans - // prétendre savoir ce qu'un écart prouverait. Deviner serait pire. - _ => MeasurementSource.legacy, - }; - final w = _dn(brut['powerW']); - // `none` avec une puissance ne devrait pas arriver ; si ça arrive, `none` gagne — - // c'est le régime qui est publié, pas la valeur. - return LoadMeasurement( - source, source == MeasurementSource.none ? null : w); - } + if (brut is! Map) return LoadMeasurement.aucune; - final ancien = _dn(m['measuredW']); - return ancien == null - ? LoadMeasurement.aucune - : LoadMeasurement(MeasurementSource.legacy, ancien); + final source = switch (brut['source'] as String?) { + 'meter' => MeasurementSource.meter, + 'device' => MeasurementSource.device, + 'none' => MeasurementSource.none, + _ => MeasurementSource.inconnue, + }; + final w = _dn(brut['powerW']); + // `none` avec une puissance ne devrait pas arriver ; si ça arrive, `none` gagne — + // c'est le régime qui est publié, pas la valeur. + return LoadMeasurement(source, source == MeasurementSource.none ? null : w); } } @@ -399,18 +398,6 @@ class LoadTelemetryEntry { /// plus — d'où le repli explicite de [isSurplusFunded]. final String? funding; - /// Puissance **MESURÉE** par le compteur rattaché à cette charge. `null` quand aucun - /// compteur ne l'est. - /// - /// Elle ne remplace pas [allocatedW] et ne s'y substitue jamais : l'une est ce que le - /// waterfall a **décidé**, l'autre ce qui **passe réellement**. Les afficher côte à côte - /// est tout l'intérêt du rattachement — commandé 3 000 W, mesuré 0 W, la charge ne fait - /// pas ce qu'on lui demande, et c'est la seule chose que la mesure autorise à conclure. - /// - /// **`null` n'est pas 0.** Une charge sans compteur ne publie rien, et afficher un zéro - /// à la place ferait passer une absence de mesure pour une absence de consommation. - final double? measuredW; - /// D'où vient la mesure, et ce qu'elle autorise à conclure. Voir [LoadMeasurement]. final LoadMeasurement measurement; @@ -424,7 +411,6 @@ class LoadTelemetryEntry { required this.allocatedW, required this.available, required this.faultCode, - this.measuredW, this.measurement = LoadMeasurement.aucune, this.funding, required this.lock, @@ -444,7 +430,6 @@ class LoadTelemetryEntry { // « disponible » est le repli qui n'invente pas un défaut. available: m['available'] as bool? ?? true, faultCode: (fault is String && fault.isNotEmpty) ? fault : null, - measuredW: _dn(m['measuredW']), measurement: LoadMeasurement.fromJson(m), funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) ? m['funding'] as String @@ -508,8 +493,9 @@ class LoadTelemetryEntry { /// pour un chauffe-eau dont la résistance a lâché. /// /// L'écart commande/réalité ne se lit donc **jamais** dans [allocatedW] : il se lit dans - /// [measuredW] quand un compteur est rattaché, et dans la charge utile [mechanism] — - /// pour une borne, `chargingEnabled`, `currentA`, `phaseCount`, `pluggedIn`. + /// [LoadTelemetryEntry.measurement] — dont la `source` dit ce que l'écart prouve — et + /// dans la charge utile [mechanism] : pour une borne, `chargingEnabled`, `currentA`, + /// `phaseCount`, `pluggedIn`. /// /// Ce getter n'existe plus : il portait la réserve « pour une borne, ce n'est même pas /// une commande », qui est levée. Sa disparition est délibérée — le garder à `true` diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index fb571a2..43e0be4 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -1636,7 +1636,7 @@ class _CompteurEtSonde extends StatelessWidget { // borne de recharge ne rend aucun service thermique : le champ ne mènerait nulle // part. Proposé sur cet écran, il faisait chercher à quoi il sert. // - // Le compteur, lui, RESTE : c'est le seul chemin par lequel `measuredW` apparaît + // Le compteur, lui, RESTE : c'est le seul chemin par lequel une mesure apparaît // pour cette charge dans la télémétrie — et sur une borne qui publie // `currentPower`, on peut la désigner elle-même. if (!entry.isEvCharger) ...[ @@ -1689,10 +1689,10 @@ String _aideSelonSource(MeasurementSource? source) => switch (source) { 'un signal et ne voit pas le courant de la machine, et une topologie de relais ' 'où un seul mesure ne mesure rien d\'exploitable. Un compteur dédié est donc ' 'ici la seule voie vers une mesure.', - MeasurementSource.legacy => - 'Cette box ne publie pas encore l\'origine de la mesure (avant 1.15.2+etm26) : ' - 'la valeur affichée sur la carte est juste, mais ce qu\'un écart prouverait ' - 'reste indéterminé.', + MeasurementSource.inconnue => + 'La box publie une origine de mesure que cette version de l\'app ne connaît pas : ' + 'la valeur affichée sur la carte est celle de la box, mais ce qu\'un écart ' + 'prouverait reste indéterminé ici.', }; /// Inclusion dans l'arbitrage — et ce que la désactivation fait vraiment. diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index 045cbc0..31554a0 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -700,8 +700,8 @@ String _etatBorne(L10n t, LoadMechanism? mech) { /// de l'organe commandé — la concordance devient une preuve, et un écart ouvre trois /// hypothèses (charge, compteur, câblage) qu'il ne tranche pas. /// -/// `null` sous `legacy` : une box antérieure à `+etm26` ne publie pas de régime, et -/// inventer une portée serait pire que se taire. +/// `null` sous `inconnue` : une box plus récente que l'app publie une source qu'on ne sait +/// pas interpréter, et inventer une portée serait pire que se taire. String? _porteeMesure(LoadMeasurement m) => switch (m.source) { MeasurementSource.meter => 'Compteur dédié : mesure indépendante de la commande — une concordance est une ' @@ -709,7 +709,7 @@ String? _porteeMesure(LoadMeasurement m) => switch (m.source) { MeasurementSource.device => 'Mesure publiée par l\'appareil : un écart montre qu\'il n\'obéit pas, mais une ' 'concordance ne prouve rien — certaines bornes renvoient leur consigne.', - MeasurementSource.none || MeasurementSource.legacy => null, + MeasurementSource.none || MeasurementSource.inconnue => null, }; String _ecartMesure(LoadTelemetryEntry live) { diff --git a/test/load_mechanism_ev_test.dart b/test/load_mechanism_ev_test.dart index 159a065..1750989 100644 --- a/test/load_mechanism_ev_test.dart +++ b/test/load_mechanism_ev_test.dart @@ -171,7 +171,7 @@ void main() { await _poser(tester, _harness(kV2c)); expect(find.text('Sonde de température'), findsNothing); - // Le compteur, lui, garde un sens : c'est le seul chemin par lequel `measuredW` + // Le compteur, lui, garde un sens : c'est le seul chemin par lequel une mesure // apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`). expect(find.text('Compteur dédié'), findsOneWidget); // Hors télémétrie, la box ne dit pas d'où viendrait la mesure : l'écran ne le diff --git a/test/measurement_source_test.dart b/test/measurement_source_test.dart index 0a1f9b8..03d03ef 100644 --- a/test/measurement_source_test.dart +++ b/test/measurement_source_test.dart @@ -5,8 +5,9 @@ /// 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, et la -/// tolérance temporaire vers `measuredW`. +/// 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'; @@ -72,33 +73,37 @@ void main() { final m = _entree({ 'measurement': {'source': 'futur', 'powerW': 100.0} }).measurement; - expect(m.source, MeasurementSource.legacy); + expect(m.source, MeasurementSource.inconnue); expect(m.mesuree, isTrue); expect(m.concordanceProbante, isFalse); }); }); - group('tolérance temporaire vers measuredW — à retirer avec +etm26 sur .75', () { - test('sans `measurement`, l\'ancienne clé est encore lue', () { - // Sans cet alias, la mesure disparaîtrait des écrans entre ce livrable et le - // déploiement du moteur. - final m = _entree({'measuredW': 1500.0}).measurement; - expect(m.source, MeasurementSource.legacy); - expect(m.powerW, 1500.0); + 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` GAGNE quand les deux sont là', () { - // Deux noms pour une chose divergent : le nouveau fait foi, sans exception. + 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({ - 'measuredW': 1500.0, - 'measurement': {'source': 'meter', 'powerW': 3980.0} + 'measurement': {'source': 'estimated', 'powerW': 100.0} }).measurement; - expect(m.source, MeasurementSource.meter); - expect(m.powerW, 3980.0); - }); - - test('ni l\'un ni l\'autre : pas mesurable', () { - expect(_entree(const {}).measurement.mesuree, isFalse); + expect(m.source, MeasurementSource.inconnue); + expect(m.powerW, 100.0); + expect(m.concordanceProbante, isFalse); }); }); @@ -182,12 +187,29 @@ void main() { expect(find.textContaining('Mesuré 0 W'), findsOneWidget); }); - testWidgets('sur une box ancienne, la valeur s\'affiche SANS portée', - (tester) async { - await tester.pumpWidget(carte({'measuredW': 1500.0})); + 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); + } + }); }); }