diff --git a/TODO.md b/TODO.md index 179655a..86ce9ae 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,16 @@ ## 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] ~~🔴 **Bilan JOURNALIER**~~ — **fait le 2026-08-28.** Les quatre tuiles montrent la journée, sous un en-tête « Aujourd'hui » qui le dit une fois. Méthode : différence des compteurs entre minuit local et maintenant, jamais l'intégration des puissances — diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 24e356b..23e5866 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -45,6 +45,119 @@ library; /// minutes. const Duration kTelemetryStaleAfter = Duration(seconds: 180); +/// **D'où vient la mesure d'une charge** — publié par le moteur, jamais déduit ici. +/// +/// ## Pourquoi un régime publié plutôt qu'une valeur +/// +/// `measuredW` portait trois régimes sous un seul nom, et jetait la distinction : une borne +/// rendait une mesure ; un routeur de relais rendait une mesure **ou son palier nominal** +/// selon que ses relais mesurent ; un SG-Ready rendait **toujours** une puissance déclarée, +/// jamais mesurée. Rien ne permettait de savoir laquelle on regardait — c'est la condition +/// C2 de LM-1009, mot pour mot. +/// +/// Depuis `+etm26`, le régime se publie : +/// `measurement: {source: "meter" | "device" | "none", powerW: 3980.0}`. +/// +/// ## Les trois sources, et ce qu'elles autorisent à conclure d'un écart +/// +/// | source | un écart commandé ↔ mesuré dit | +/// |---|---| +/// | `device` | **la charge n'obéit pas** — véhicule qui refuse, thermostat ouvert, plafond interne | +/// | `meter` | **la charge, le compteur ou le câblage** — trois hypothèses, l'écart n'en tranche aucune | +/// | `none` | **rien**, et surtout pas « 0 W d'écart » | +/// +/// ⚠️ **L'asymétrie qui compte pour l'écran** : sous `device`, une **concordance** ne prouve +/// rien — plusieurs wallbox renvoient leur consigne en guise de puissance. Sous `meter`, +/// elle est une **preuve**. `device` détecte la désobéissance ; `meter` seul peut détecter +/// un mensonge. +/// +/// ## `none` est un état POSITIF +/// +/// « Cette charge n'est pas mesurable », et non « la mesure manque ». C'est lui qui autorise +/// à **masquer** le bloc plutôt qu'à afficher un vide, et qui sépare enfin « pas mesurable » +/// de « compteur en panne ». +/// +/// Deux cas de terrain qu'il couvre, et qu'aucune déduction côté app n'aurait trouvés : +/// un **SG-Ready** est `none` par construction — son contact commande un signal, la PAC est +/// alimentée ailleurs, et un contact qui mesurerait quelque chose mesurerait sa propre +/// bobine (LM-1105-b) ; une **topologie de relais mixte** est `none` aussi, parce qu'une +/// somme partielle annoncerait 500 W là où 1 500 W coulent. +/// +/// ## `{source: "device", powerW: 0}` est une MESURE +/// +/// Une borne branchée qui ne charge pas mesure zéro watt, et c'est une information. La +/// source ne bascule pas vers `none` quand la charge s'éteint — sinon le bloc de mesure +/// apparaîtrait et disparaîtrait au rythme du thermostat. +enum MeasurementSource { + /// Un compteur a été désigné exprès (`meterThingId`). + meter, + + /// L'appareil publie lui-même sa puissance. + device, + + /// 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, +} + +class LoadMeasurement { + final MeasurementSource source; + + /// Absent sous [MeasurementSource.none] — et `0` n'en est pas l'équivalent. + final double? powerW; + + const LoadMeasurement(this.source, this.powerW); + + static const LoadMeasurement aucune = + LoadMeasurement(MeasurementSource.none, null); + + /// Vrai quand il y a une puissance à afficher. + bool get mesuree => source != MeasurementSource.none && powerW != null; + + /// Vrai quand une **concordance** entre commande et mesure prouve quelque chose. + /// + /// Faux sous `device` : plusieurs wallbox renvoient leur consigne, donc une concordance + /// 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. + /// + /// > ⏳ **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. + 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); + } + + final ancien = _dn(m['measuredW']); + return ancien == null + ? LoadMeasurement.aucune + : LoadMeasurement(MeasurementSource.legacy, ancien); + } +} + /// Décomposition du budget de surplus du cycle. Absent en mode dégradé. class TelemetryBudget { /// Budget entrant du waterfall non-EV : net signé − réservation EV. @@ -298,6 +411,9 @@ class LoadTelemetryEntry { /// à 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; + final LoadLock? lock; final LoadMechanism? mechanism; final LoadDecision decision; @@ -309,6 +425,7 @@ class LoadTelemetryEntry { required this.available, required this.faultCode, this.measuredW, + this.measurement = LoadMeasurement.aucune, this.funding, required this.lock, required this.mechanism, @@ -328,6 +445,7 @@ class LoadTelemetryEntry { 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 : null, diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 55ea254..fb571a2 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -5,6 +5,7 @@ import '../../l10n/l10n_ext.dart'; import '../../models/energy_data.dart' show ChargingMode; import '../../models/ev_charger_live.dart'; import '../../models/load_config_entry.dart'; +import '../../models/load_telemetry.dart'; import '../../providers/load_config_provider.dart'; import '../../providers/load_telemetry_provider.dart'; import '../../providers/schema_provider.dart'; @@ -156,7 +157,8 @@ class _LoadMechanismScreenState extends State { entry: e, provider: provider, schema: schema.schema, - service: service), + service: service, + telemetry: telemetry), const SizedBox(height: 14), _Arbitrage(entry: e, provider: provider), const SizedBox(height: 18), @@ -1536,12 +1538,14 @@ class _CompteurEtSonde extends StatelessWidget { final LoadConfigProvider provider; final RpcSchema schema; final NymeaService service; + final LoadTelemetryProvider telemetry; const _CompteurEtSonde({ required this.entry, required this.provider, required this.schema, required this.service, + required this.telemetry, }); FieldSupport _support(String champ) => schema.paramSupport( @@ -1552,15 +1556,43 @@ class _CompteurEtSonde extends StatelessWidget { final supportCompteur = _support('meterThingId'); final supportSonde = _support('sensorThingId'); + // ⚠️ LA RÈGLE PORTE SUR `measurement.source`, JAMAIS SUR `adapter`. + // + // « Masqué dès que la charge mesure, sauf SG-Ready » paraît équivalent et ne l'est + // pas : un routeur de relais à topologie MIXTE publie `none` lui aussi — une mesure + // partielle n'est pas une mesure — et on masquerait le champ sur un ECS qui ne peut + // être mesuré QUE par un compteur dédié. Redéduire le régime du mécanisme + // remettrait ici la déduction que le moteur vient d'en retirer, et il faudrait la + // maintenir à chaque mécanisme ajouté. + // + // source == device → réglage avancé, replié (l'appareil publie déjà) + // sinon → proposé, visible + // + // Le cas SG-Ready est couvert sans avoir à le connaître : son contact commande un + // signal, la PAC est alimentée ailleurs, la source est `none` par construction + // (LM-1105-b) — donc le compteur reste visible, et c'est sa SEULE voie vers une + // mesure. + // `null` = la charge n'est PAS dans la télémétrie du dernier cycle (hors arbitrage, + // borne sans véhicule branché, charge désactivée). Ce n'est pas `none` : `none` est un + // état publié — « pas mesurable » — alors qu'ici le régime n'est simplement pas dit. + // Les confondre ferait annoncer « seule voie vers une mesure » sur une borne qui, une + // fois arbitrée, publiera très bien la sienne. + final source = telemetry.entryFor(entry.id)?.measurement.source; + final replie = source == MeasurementSource.device; + return Column( children: [ _Carte( titre: 'Compteur dédié', badge: supportCompteur.isWritable - ? 'enregistré · meterThingId' + ? (replie ? 'avancé · meterThingId' : 'enregistré · meterThingId') : 'pas encore supporté', - sousTitre: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, ' - 'jamais pour décider.', + sousTitre: replie + ? 'Cet appareil publie déjà sa puissance : un compteur séparé est un ' + 'réglage avancé, pas un manque.' + : 'Mesure de CETTE charge — pour vérifier ce qui est commandé, ' + 'jamais pour décider.', + replie: replie, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1591,15 +1623,8 @@ class _CompteurEtSonde extends StatelessWidget { 'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la ' 'même énergie.', ), - if (entry.isEvCharger) ...[ - const SizedBox(height: 6), - _Aide( - 'Sur une borne, aucun compteur séparé n\'est nécessaire : elle publie ' - 'elle-même sa puissance. La désigner ICI comme son propre compteur est ' - 'ce qui fait apparaître « mesuré » à côté de « alloué » sur sa carte — ' - 'sans quoi le moteur n\'a rien à comparer à ce qu\'il commande.', - ), - ], + const SizedBox(height: 6), + _Aide(_aideSelonSource(source)), ], ), ), @@ -1643,6 +1668,33 @@ class _CompteurEtSonde extends StatelessWidget { } } +/// Ce qu'un compteur dédié apporte **à cette charge-ci**, selon ce que la box publie. +String _aideSelonSource(MeasurementSource? source) => switch (source) { + null => + 'Cette charge n\'est pas dans la télémétrie du dernier cycle : la box ne dit donc ' + 'pas d\'où viendrait sa mesure. Le compteur reste réglable — il servira dès ' + 'qu\'elle sera arbitrée.', + MeasurementSource.device => + 'Cet appareil publie déjà sa puissance, et elle s\'affiche sur sa carte. Un ' + 'compteur dédié reste permis, et il apporte une chose que l\'appareil ne peut ' + 'pas donner : une mesure INDÉPENDANTE de l\'organe commandé. Sans elle, une ' + 'concordance entre commande et mesure ne prouve rien — plusieurs bornes ' + 'renvoient leur consigne en guise de puissance.', + MeasurementSource.meter => + 'Un compteur est rattaché : la mesure affichée sur la carte vient de lui, pas de ' + 'l\'appareil commandé. C\'est la seule configuration où une concordance entre ' + 'commande et mesure constitue une preuve.', + MeasurementSource.none => + 'La box ne publie AUCUNE mesure pour cette charge — un contact SG-Ready commande ' + '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é.', + }; + /// Inclusion dans l'arbitrage — et ce que la désactivation fait vraiment. class _Arbitrage extends StatelessWidget { final LoadConfigEntry entry; @@ -1868,19 +1920,33 @@ class _BarreEnregistrement extends StatelessWidget { // Habillage // ═══════════════════════════════════════════════════════════════════════════ -class _Carte extends StatelessWidget { +class _Carte extends StatefulWidget { final String titre; final String? badge; final String? sousTitre; final Widget child; + /// Carte **repliée à l'ouverture** — un réglage qui existe sans être une étape. + /// + /// Le contenu reste atteignable en un geste : replier n'est pas masquer. Un réglage + /// caché pour de bon finit par être réinventé ailleurs. + final bool replie; + const _Carte({ required this.titre, this.badge, this.sousTitre, required this.child, + this.replie = false, }); + @override + State<_Carte> createState() => _CarteState(); +} + +class _CarteState extends State<_Carte> { + late bool _ouverte = !widget.replie; + @override Widget build(BuildContext context) { return Container( @@ -1894,17 +1960,33 @@ class _Carte extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SousTitre(titre, badge: badge), - if (sousTitre != null) ...[ + InkWell( + onTap: widget.replie + ? () => setState(() => _ouverte = !_ouverte) + : null, + child: Row(children: [ + Expanded(child: _SousTitre(widget.titre, badge: widget.badge)), + if (widget.replie) + Icon( + _ouverte + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 20, + color: EtmTokens.mutedOf(context)), + ]), + ), + if (widget.sousTitre != null) ...[ const SizedBox(height: 3), - Text(sousTitre!, + Text(widget.sousTitre!, style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.mutedOf(context))), ], - const SizedBox(height: 12), - child, + if (_ouverte) ...[ + const SizedBox(height: 12), + widget.child, + ], ], ), ); diff --git a/lib/screens/settings/bug_report_screen.dart b/lib/screens/settings/bug_report_screen.dart index 236a95c..feff17b 100644 --- a/lib/screens/settings/bug_report_screen.dart +++ b/lib/screens/settings/bug_report_screen.dart @@ -138,7 +138,8 @@ class _BugReportScreenState extends State { for (final l in tel.loads) { final m = l.mechanism; b.writeln(' - ${l.loadId} · alloué ${l.allocatedW.round()} W' - '${l.measuredW != null ? " · mesuré ${l.measuredW!.round()} W" : ""}' + '${l.measurement.mesuree ? " · mesuré ${l.measurement.powerW!.round()} W" + " (${l.measurement.source.name})" : ""}' ' · ${l.funding ?? "financement omis"}' ' · motif ${l.decision.code}${formatParamsBrut(l.decision.params)}' '${l.inFault ? " · EN DÉFAUT ${l.faultCode ?? ""}" : ""}' diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index e8619ea..045cbc0 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -199,12 +199,22 @@ class LoadLiveBlock extends StatelessWidget { // c'est la seule chose que la mesure autorise à conclure. Une charge sans compteur // n'affiche RIEN — un zéro ferait passer l'absence de mesure pour une absence de // consommation. - if (live.measuredW != null) ...[ + // ── LA MESURE, et ce que sa SOURCE autorise à en conclure ────────── + // + // Le bloc est masqué sous `none` — un état positif : « cette charge n'est pas + // mesurable ». Afficher un vide, ou pire un zéro, confondrait ce cas avec un + // compteur en panne. Et surtout : ne PAS aller chercher une puissance nominale + // dans `mechanism` pour combler — elle produirait un écart identiquement nul à + // tous les cycles, c'est-à-dire un « tout concorde » permanent qui ne signale + // plus rien. + if (live.measurement.mesuree) ...[ const SizedBox(height: 2), Row(children: [ Icon(Icons.speed_rounded, size: 13, color: tone), const SizedBox(width: 4), - Text('Mesuré ${live.measuredW!.round()} W', + // `{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. + Text('Mesuré ${live.measurement.powerW!.round()} W', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: tone)), const SizedBox(width: 6), @@ -216,6 +226,11 @@ class LoadLiveBlock extends StatelessWidget { ), ), ]), + if (_porteeMesure(live.measurement) != null) ...[ + const SizedBox(height: 1), + Text(_porteeMesure(live.measurement)!, + style: TextStyle(fontSize: 10.5, height: 1.25, color: tone)), + ], ], const SizedBox(height: 2), Text(decisionText(t, live.decision), @@ -676,9 +691,30 @@ String _etatBorne(L10n t, LoadMechanism? mech) { /// est partie, l'effet n'est pas là ». Deux causes, deux gestes chez le client — l'une /// envoie au bus, l'autre au disjoncteur divisionnaire ou à la résistance. L'app constate, /// elle ne diagnostique pas. +/// **Ce que la source autorise à conclure** — dit à l'écran, parce que ça change le sens +/// de ce qui est affiché juste au-dessus. +/// +/// L'asymétrie n'est pas « le compteur mesure mieux » : sous `device`, une **concordance** +/// ne prouve rien, plusieurs wallbox renvoyant leur consigne en guise de puissance. Elle +/// détecte la désobéissance, jamais le mensonge. Sous `meter`, la mesure est indépendante +/// 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. +String? _porteeMesure(LoadMeasurement m) => switch (m.source) { + MeasurementSource.meter => + 'Compteur dédié : mesure indépendante de la commande — une concordance est une ' + 'preuve, un écart met en cause la charge, le compteur ou le câblage.', + 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, + }; + String _ecartMesure(LoadTelemetryEntry live) { - final m = live.measuredW; - if (m == null) return ''; + final m = live.measurement.powerW; + if (!live.measurement.mesuree || m == null) return ''; final a = live.allocatedW; if (a <= 0) return m > 20 ? '· alloué 0 W' : ''; final ecart = (m - a).abs(); diff --git a/test/load_mechanism_ev_test.dart b/test/load_mechanism_ev_test.dart index 6cd9e28..159a065 100644 --- a/test/load_mechanism_ev_test.dart +++ b/test/load_mechanism_ev_test.dart @@ -99,14 +99,17 @@ NymeaService _serviceAvecBorne({String mode = 'ChargingModeNormal'}) { } Widget _harness(String loadId, - {List>? config, NymeaService? service}) => + {List>? config, + NymeaService? service, + Map? telemetry}) => MultiProvider( providers: [ ChangeNotifierProvider.value( value: LoadConfigProvider()..adoptForTest(config ?? _banc())), ChangeNotifierProvider.value( value: LoadTelemetryProvider() - ..adoptForTest({'degradedMode': false, 'loads': const []})), + ..adoptForTest( + telemetry ?? {'degradedMode': false, 'loads': const []})), ChangeNotifierProvider.value(value: SchemaProvider()), ChangeNotifierProvider.value(value: service ?? NymeaService()), ], @@ -125,7 +128,10 @@ Widget _harness(String loadId, /// sont jamais bâtis — et `find` ne trouve rien, ce qui se lit comme un succès quand on /// cherche une absence. C'est le piège exact que ce fichier éprouve ailleurs. Future _poser(WidgetTester tester, Widget w) async { - await tester.binding.setSurfaceSize(const Size(1000, 4000)); + // 8000 px : les écrans relais et SG-Ready sont bien plus hauts que celui d'une borne, + // et le compteur y tombait hors du viewport — donc jamais construit, donc « absent » + // pour `find`. Une absence qui n'en est pas une : le piège que ce fichier traque. + await tester.binding.setSurfaceSize(const Size(1000, 8000)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(w); await tester.pump(); @@ -168,7 +174,9 @@ void main() { // Le compteur, lui, garde un sens : c'est le seul chemin par lequel `measuredW` // apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`). expect(find.text('Compteur dédié'), findsOneWidget); - expect(find.textContaining('aucun compteur séparé n\'est nécessaire'), + // Hors télémétrie, la box ne dit pas d'où viendrait la mesure : l'écran ne le + // devine pas non plus. + expect(find.textContaining('pas dans la télémétrie du dernier cycle'), findsOneWidget); }); @@ -222,4 +230,55 @@ void main() { expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget); expect(find.textContaining('lu : PV'), findsOneWidget); }); + + group('le compteur dédié se décide sur la SOURCE, jamais sur l\'adaptateur', () { + /// Une trame où la charge visée publie la source donnée. + Map tel(String id, String source) => { + 'degradedMode': false, + 'loads': [ + { + 'loadId': id, + 'allocatedW': 3000, + 'available': true, + 'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}}, + 'measurement': source == 'none' + ? {'source': 'none'} + : {'source': source, 'powerW': 2950.0}, + } + ], + }; + + testWidgets('source `device` : le compteur passe en réglage avancé, replié', + (tester) async { + await _poser(tester, _harness(kV2c, telemetry: tel(kV2c, 'device'))); + + // La carte reste — replier n'est pas masquer, et un réglage caché pour de bon + // finit par être réinventé ailleurs. + expect(find.text('Compteur dédié'), findsOneWidget); + expect(find.textContaining('publie déjà sa puissance'), findsOneWidget); + // Mais son contenu n'est pas déployé : ni le sélecteur, ni les aides. + expect(find.textContaining('n\'entre pas dans le budget'), findsNothing); + }); + + testWidgets('source `none` : le compteur est PROPOSÉ, visible', (tester) async { + // Le cas d'une PAC SG-Ready — `none` par construction (LM-1105-b) — et celui d'une + // topologie de relais mixte. Masquer le champ y couperait la seule voie de mesure. + await _poser( + tester, _harness('pac-terrain', telemetry: tel('pac-terrain', 'none'))); + + expect(find.text('Compteur dédié'), findsOneWidget); + // Carte DÉPLOYÉE : son contenu est monté, pas seulement son titre. + expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget); + expect(find.textContaining('seule voie vers une mesure'), findsOneWidget); + }); + + testWidgets('source `meter` : visible aussi, et l\'écran dit ce qu\'elle prouve', + (tester) async { + await _poser(tester, + _harness('chauffe-eau', telemetry: tel('chauffe-eau', 'meter'))); + + expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget); + expect(find.textContaining('constitue une preuve'), findsOneWidget); + }); + }); } diff --git a/test/measurement_source_test.dart b/test/measurement_source_test.dart new file mode 100644 index 0000000..0a1f9b8 --- /dev/null +++ b/test/measurement_source_test.dart @@ -0,0 +1,193 @@ +/// 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, et la +/// tolérance temporaire vers `measuredW`. +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.legacy); + 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); + }); + + test('`measurement` GAGNE quand les deux sont là', () { + // Deux noms pour une chose divergent : le nouveau fait foi, sans exception. + final m = _entree({ + 'measuredW': 1500.0, + 'measurement': {'source': 'meter', 'powerW': 3980.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); + }); + }); + + 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('sur une box ancienne, la valeur s\'affiche SANS portée', + (tester) async { + await tester.pumpWidget(carte({'measuredW': 1500.0})); + expect(find.textContaining('Mesuré 1500 W'), findsOneWidget); + expect(find.textContaining('concordance'), findsNothing); + expect(find.textContaining('indépendante'), findsNothing); + }); + }); +}