diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f8d8ce7..155fddf 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -220,6 +220,31 @@ "description": "purchasedWh. Publié dans TOUS les régimes : c'est la commande du moteur, donc un fait connu sans mesure. Ne coïncide avec le livré que quand tout va bien — c'est sa divergence qui informe.", "placeholders": { "wh": { "type": "String" } } }, + "obligationTitre": "Obligation quotidienne", + "obligationSousTitre": "Le plancher que la box servira quoi qu'il arrive, au réseau s'il le faut.", + "obligationEnergie": "Énergie due par jour", + "obligationEcheance": "Avant", + "obligationEcheanceAide": "Heure de l'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.", + "@obligationEcheanceAide": { + "description": "dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.", + "placeholders": { "fuseau": { "type": "String" } } + }, + "obligationNonMesurableTitre": "Cette obligation ne pourra jamais être close", + "@obligationNonMesurableTitre": { + "description": "Sous unmeasurable : ni tenue (R6 interdit ECO_FLOOR_MET), ni chiffrée dans son manque (missingWh absent). ECO_FLOOR_MISSED sortira tous les jours par construction." + }, + "obligationNonMesurableCorps": "La box ne saura pas mesurer ce qu'elle livre ici : l'obligation ne pourra être annoncée ni tenue, ni manquée d'une quantité connue. Elle sera achetée au réseau à taux constant — {cout} par jour — sans que rien ne vienne le corriger.", + "@obligationNonMesurableCorps": { + "description": "Le coût se CHIFFRE, il ne s'estime pas : cible ÷ jour depuis +etm55, et purchasedWh le recoupe à 0,3 % une fois l'obligation en route.", + "placeholders": { "cout": { "type": "String" } } + }, + "obligationCauseNoMeter": "Aucun compteur n'est rattaché à cette charge. Rattachez-en un ci-dessus : c'est la cause la plus fréquente, et elle se répare en une manipulation.", + "obligationCauseNoEnergyMeter": "Le compteur rattaché publie une puissance, pas une énergie. Il faut un compteur qui totalise les kWh — la puissance seule ne suffit pas à mesurer ce qui a été livré.", + "obligationCauseNoSessionEnergy": "Cette borne ne publie pas l'énergie de session. C'est le matériel : aucun réglage ne le changera, et il vaut mieux le savoir maintenant qu'après avoir cherché.", + "obligationCauseInconnue": "La box ne dit pas pourquoi (cause « {cause} » inconnue de cette version).", + "@obligationCauseInconnue": { + "placeholders": { "cause": { "type": "String" } } + }, "ecoIncoherenceTenue": "Incohérence de la box : plancher annoncé atteint alors que l'avancement n'est pas mesurable", "@ecoIncoherenceTenue": { "description": "R6 est armée des deux côtés : ECO_FLOOR_MET ne peut pas sortir sous le régime unmeasurable. Si la combinaison arrive quand même, l'écran la DÉNONCE au lieu de la rendre — servie oui, tenue jamais." diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7ee9c5b..08d8a91 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -577,6 +577,72 @@ abstract class L10n { /// **'{wh} achetés pour cette obligation'** String ecoAchete(String wh); + /// No description provided for @obligationTitre. + /// + /// In fr, this message translates to: + /// **'Obligation quotidienne'** + String get obligationTitre; + + /// No description provided for @obligationSousTitre. + /// + /// In fr, this message translates to: + /// **'Le plancher que la box servira quoi qu\'il arrive, au réseau s\'il le faut.'** + String get obligationSousTitre; + + /// No description provided for @obligationEnergie. + /// + /// In fr, this message translates to: + /// **'Énergie due par jour'** + String get obligationEnergie; + + /// No description provided for @obligationEcheance. + /// + /// In fr, this message translates to: + /// **'Avant'** + String get obligationEcheance; + + /// dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue. + /// + /// In fr, this message translates to: + /// **'Heure de l\'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.'** + String obligationEcheanceAide(String fuseau); + + /// Sous unmeasurable : ni tenue (R6 interdit ECO_FLOOR_MET), ni chiffrée dans son manque (missingWh absent). ECO_FLOOR_MISSED sortira tous les jours par construction. + /// + /// In fr, this message translates to: + /// **'Cette obligation ne pourra jamais être close'** + String get obligationNonMesurableTitre; + + /// Le coût se CHIFFRE, il ne s'estime pas : cible ÷ jour depuis +etm55, et purchasedWh le recoupe à 0,3 % une fois l'obligation en route. + /// + /// In fr, this message translates to: + /// **'La box ne saura pas mesurer ce qu\'elle livre ici : l\'obligation ne pourra être annoncée ni tenue, ni manquée d\'une quantité connue. Elle sera achetée au réseau à taux constant — {cout} par jour — sans que rien ne vienne le corriger.'** + String obligationNonMesurableCorps(String cout); + + /// No description provided for @obligationCauseNoMeter. + /// + /// In fr, this message translates to: + /// **'Aucun compteur n\'est rattaché à cette charge. Rattachez-en un ci-dessus : c\'est la cause la plus fréquente, et elle se répare en une manipulation.'** + String get obligationCauseNoMeter; + + /// No description provided for @obligationCauseNoEnergyMeter. + /// + /// In fr, this message translates to: + /// **'Le compteur rattaché publie une puissance, pas une énergie. Il faut un compteur qui totalise les kWh — la puissance seule ne suffit pas à mesurer ce qui a été livré.'** + String get obligationCauseNoEnergyMeter; + + /// No description provided for @obligationCauseNoSessionEnergy. + /// + /// In fr, this message translates to: + /// **'Cette borne ne publie pas l\'énergie de session. C\'est le matériel : aucun réglage ne le changera, et il vaut mieux le savoir maintenant qu\'après avoir cherché.'** + String get obligationCauseNoSessionEnergy; + + /// No description provided for @obligationCauseInconnue. + /// + /// In fr, this message translates to: + /// **'La box ne dit pas pourquoi (cause « {cause} » inconnue de cette version).'** + String obligationCauseInconnue(String cause); + /// R6 est armée des deux côtés : ECO_FLOOR_MET ne peut pas sortir sous le régime unmeasurable. Si la combinaison arrive quand même, l'écran la DÉNONCE au lieu de la rendre — servie oui, tenue jamais. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 26a74f0..4d52cf8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -335,6 +335,50 @@ class L10nFr extends L10n { return '$wh achetés pour cette obligation'; } + @override + String get obligationTitre => 'Obligation quotidienne'; + + @override + String get obligationSousTitre => + 'Le plancher que la box servira quoi qu\'il arrive, au réseau s\'il le faut.'; + + @override + String get obligationEnergie => 'Énergie due par jour'; + + @override + String get obligationEcheance => 'Avant'; + + @override + String obligationEcheanceAide(String fuseau) { + return 'Heure de l\'INSTALLATION$fuseau — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.'; + } + + @override + String get obligationNonMesurableTitre => + 'Cette obligation ne pourra jamais être close'; + + @override + String obligationNonMesurableCorps(String cout) { + return 'La box ne saura pas mesurer ce qu\'elle livre ici : l\'obligation ne pourra être annoncée ni tenue, ni manquée d\'une quantité connue. Elle sera achetée au réseau à taux constant — $cout par jour — sans que rien ne vienne le corriger.'; + } + + @override + String get obligationCauseNoMeter => + 'Aucun compteur n\'est rattaché à cette charge. Rattachez-en un ci-dessus : c\'est la cause la plus fréquente, et elle se répare en une manipulation.'; + + @override + String get obligationCauseNoEnergyMeter => + 'Le compteur rattaché publie une puissance, pas une énergie. Il faut un compteur qui totalise les kWh — la puissance seule ne suffit pas à mesurer ce qui a été livré.'; + + @override + String get obligationCauseNoSessionEnergy => + 'Cette borne ne publie pas l\'énergie de session. C\'est le matériel : aucun réglage ne le changera, et il vaut mieux le savoir maintenant qu\'après avoir cherché.'; + + @override + String obligationCauseInconnue(String cause) { + return 'La box ne dit pas pourquoi (cause « $cause » inconnue de cette version).'; + } + @override String get ecoIncoherenceTenue => 'Incohérence de la box : plancher annoncé atteint alors que l\'avancement n\'est pas mesurable'; diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart index e156fb1..f25d041 100644 --- a/lib/models/load_config_entry.dart +++ b/lib/models/load_config_entry.dart @@ -161,6 +161,28 @@ class LoadConfigEntry { return n is Map ? ((n['minEnergyWhPerDay'] as num?)?.toInt() ?? 0) : 0; } + // ── Mesurabilité de l'avancement — LECTURE SEULE, publiée par le moteur ──── + // + /// **Le moteur saura-t-il mesurer ce qu'il livre à cette charge ?** (`+etm49`) + /// + /// Publié en `r:` avec la **configuration** et non avec la télémétrie, et c'est ce qui le + /// rend utilisable : `measurement` n'existe que pour les charges présentes dans `loads[]`, + /// or une charge qu'on est en train de déclarer est justement celle qui n'y est pas encore. + /// L'écran serait muet exactement au moment où il doit parler. + /// + /// `null` = box antérieure à `+etm49` : on ne conclut rien, on n'avertit pas. + bool? get progressMeasurable => raw['progressMeasurable'] as bool?; + + /// **Pourquoi** l'avancement n'est pas mesurable — absent quand il l'est. + /// + /// `noMeter` · `noEnergyMeter` · `noSessionEnergy`. Trois causes, et surtout **trois + /// gestes** : deux se réparent, la troisième est le matériel. C'est ce qui fait la + /// différence entre une consigne et une fatalité affichée. + String? get progressUnmeasurableCause { + final c = raw['progressUnmeasurableCause']; + return (c is String && c.isNotEmpty) ? c : null; + } + /// Compteur dédié à CETTE charge, et sonde de la grandeur rendue. Vides = non rattachés. /// /// Ni l'un ni l'autre n'est du **matériel de commande** : ils n'entrent pas dans diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 95943e1..9777af3 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -10,6 +10,7 @@ import '../../providers/load_config_provider.dart'; import '../../providers/load_telemetry_provider.dart'; import '../../providers/schema_provider.dart'; import '../../services/load_hardware.dart'; +import '../../services/telemetry_text.dart' show energieCourte; import '../../services/load_mechanism_switch.dart'; import '../../services/load_priority.dart'; import '../../services/nymea_service.dart'; @@ -164,6 +165,8 @@ class _LoadMechanismScreenState extends State { service: service, telemetry: telemetry), const SizedBox(height: 14), + _SectionObligation(entry: e, provider: provider), + const SizedBox(height: 14), _Arbitrage(entry: e, provider: provider), const SizedBox(height: 18), _BarreEnregistrement( @@ -2161,3 +2164,111 @@ class _Plancher extends StatelessWidget { ); } } + +// ═══════════════════════════════════════════════════════════════════════════ +// Obligation quotidienne (§10) — et l'avertissement qui la rend honnête +// ═══════════════════════════════════════════════════════════════════════════ + +/// **Le plancher éco, et ce qu'il coûtera quand la box ne peut pas le vérifier.** +/// +/// ## Pourquoi l'avertissement est ICI et pas au résultat +/// +/// Au résultat, l'heure est passée : l'obligation a déjà acheté. Or sous le régime +/// `unmeasurable` elle ne peut **jamais** être close — ni annoncée tenue (R6 interdit +/// `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh` est absent, faute de savoir ce +/// qui a été livré). `ECO_FLOOR_MISSED` sortira donc **tous les jours, par construction**, et +/// l'achat se fera à taux constant sans que rien ne le corrige. +/// +/// Un mécanisme dont le succès est inobservable et l'échec quotidien ne doit pas, en plus, +/// dépenser en silence. D'où : on le dit **à la saisie**. +/// +/// ## Et c'est une consigne, pas une fatalité +/// +/// Deux des trois causes se réparent par l'installateur — rattacher un compteur, en mettre un +/// qui totalise l'énergie. La troisième est le matériel, et le dire évite de chercher un +/// réglage qui n'existe pas. C'est pour ça que la cause est affichée, jamais le seul verdict. +class _SectionObligation extends StatelessWidget { + final LoadConfigEntry entry; + final LoadConfigProvider provider; + + const _SectionObligation({required this.entry, required this.provider}); + + /// Le coût quotidien, **chiffré et non estimé** : le taux est constant depuis `+etm55` + /// (cible ÷ jour, LM-1013-b-v), donc le total acheté vaut exactement la cible. + String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble()); + + @override + Widget build(BuildContext context) { + final t = context.l10n; + final mesurable = entry.progressMeasurable; + final cause = entry.progressUnmeasurableCause; + // `null` = box antérieure à `+etm49` : on n'avertit pas sur une absence, on se tait. + final avertir = mesurable == false && entry.minEnergyWhPerDay > 0; + + return _Carte( + titre: t.obligationTitre, + badge: 'enregistré · needs', + sousTitre: t.obligationSousTitre, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + initialValue: entry.minEnergyWhPerDay == 0 + ? '' + : '${entry.minEnergyWhPerDay}', + keyboardType: TextInputType.number, + decoration: InputDecoration( + isDense: true, + labelText: t.obligationEnergie, + suffixText: 'Wh'), + onChanged: (v) { + final n = v.trim().isEmpty ? 0 : int.tryParse(v.trim()); + if (n == null) return; + provider.replaceEntry( + entry.id, + entry.patched(needs: { + 'dailyDeadline': entry.dailyDeadline, + 'minEnergyWhPerDay': n, + })); + }, + ), + const SizedBox(height: 10), + TextFormField( + initialValue: entry.dailyDeadline, + decoration: InputDecoration( + isDense: true, + labelText: t.obligationEcheance, + hintText: '07:00'), + onChanged: (v) => provider.replaceEntry( + entry.id, + entry.patched(needs: { + 'dailyDeadline': v.trim(), + 'minEnergyWhPerDay': entry.minEnergyWhPerDay, + })), + ), + const SizedBox(height: 6), + // L'heure saisie est celle de la BOX, et la phrase le dit sans le fuseau — elle + // reste vraie sans lui. Le NOM du fuseau est publié par `GetEnergyRatios` + // (`timeZone`, vérifié en `+etm56` : `Europe/London`) ; il s'affichera ici quand le + // lot « bilan journalier » aura branché cet appel, et pas avant : nommer un fuseau + // qu'on n'a pas lu serait exactement la faute que cette phrase existe pour éviter. + _Aide(t.obligationEcheanceAide('')), + if (avertir) ...[ + const SizedBox(height: 12), + _Aide( + '${t.obligationNonMesurableTitre}. ' + '${t.obligationNonMesurableCorps(_coutQuotidien())} ' + '${switch (cause) { + 'noMeter' => t.obligationCauseNoMeter, + 'noEnergyMeter' => t.obligationCauseNoEnergyMeter, + 'noSessionEnergy' => t.obligationCauseNoSessionEnergy, + _ => t.obligationCauseInconnue(cause ?? '—'), + }}', + alerte: true, + ), + ], + ], + ), + ); + } +} diff --git a/test/obligation_saisie_test.dart b/test/obligation_saisie_test.dart new file mode 100644 index 0000000..64aa377 --- /dev/null +++ b/test/obligation_saisie_test.dart @@ -0,0 +1,149 @@ +/// **L'avertissement de saisie** — dire à la déclaration ce que l'obligation coûtera quand +/// la box ne peut pas la vérifier. +/// +/// Pourquoi à la saisie et pas au résultat : au résultat, l'heure est passée et l'achat a eu +/// lieu. Et sous le régime `unmeasurable` l'obligation ne peut **jamais** être close — ni +/// annoncée tenue (R6 interdit `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh` +/// est absent, faute de savoir ce qui a été livré). `ECO_FLOOR_MISSED` sort donc tous les +/// jours par construction. +/// +/// Le champ qui déclenche l'avertissement, `progressMeasurable`, a porté une contradiction +/// entre le 2026-08-30 et le 2026-09-03 : il disait ce que le MATÉRIEL permet pendant que le +/// régime disait ce que le MOTEUR fait, et l'avertissement serait resté muet exactement sur +/// les charges achetées à l'aveugle. Revérifié à la sonde le 2026-09-05 : les deux +/// s'accordent (`progressMeasurable: true` et `regime: measured` sur la même charge). +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/providers/schema_provider.dart'; +import 'package:etm_powersync_app/screens/energy/load_mechanism_screen.dart'; +import 'package:etm_powersync_app/services/nymea_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// Le chauffe-eau du banc, avec les champs de mesurabilité tels que `+etm49` les publie. +List> _charge({ + bool? mesurable = true, + String? cause, + int energie = 3000, + String echeance = '12:00', +}) => + [ + { + 'id': 'chauffe-eau', + 'label': 'chauffe-eau', + 'adapter': 'relay-router', + 'domain': 'ecs', + 'mode': 'dynamic', + 'priority': 1, + 'enabled': true, + 'relays': const [], + 'needs': {'dailyDeadline': echeance, 'minEnergyWhPerDay': energie}, + if (mesurable != null) 'progressMeasurable': mesurable, + if (cause != null) 'progressUnmeasurableCause': cause, + } + ]; + +Widget _harness(List> config) => MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: LoadConfigProvider()..adoptForTest(config)), + ChangeNotifierProvider.value( + value: LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []})), + ChangeNotifierProvider.value(value: SchemaProvider()), + ChangeNotifierProvider.value(value: NymeaService()), + ], + child: MaterialApp( + locale: const Locale('fr'), + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: const LoadMechanismScreen(loadId: 'chauffe-eau'), + ), + ); + +/// L'écran est un `ListView` : hors viewport, rien n'est construit, et `find` rend une +/// absence qui se lit comme un succès. Grande surface obligatoire. +Future _poser(WidgetTester tester, Widget w) async { + await tester.binding.setSurfaceSize(const Size(1000, 8000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(w); + await tester.pumpAndSettle(); +} + +void main() { + group('l\'avertissement se déclenche sur ce que le MOTEUR fait', () { + testWidgets('mesurable : la section est là, l\'alarme non', (tester) async { + await _poser(tester, _harness(_charge())); + expect(find.text('Obligation quotidienne'), findsOneWidget); + expect(find.textContaining('ne pourra jamais être close'), findsNothing); + }); + + testWidgets('non mesurable : ce que ça coûte, et le geste qui le répare', + (tester) async { + await _poser(tester, _harness(_charge(mesurable: false, cause: 'noMeter'))); + + expect(find.textContaining('ne pourra jamais être close'), findsOneWidget); + // Le coût est CHIFFRÉ, pas estimé : taux constant depuis +etm55, donc le total + // acheté vaut exactement la cible. + expect(find.textContaining('3,0 kWh par jour'), findsOneWidget); + // Et surtout : une consigne, pas une fatalité. + expect(find.textContaining('Rattachez-en un'), findsOneWidget); + }); + + testWidgets('un compteur sans énergie n\'est pas « pas de compteur »', + (tester) async { + await _poser( + tester, _harness(_charge(mesurable: false, cause: 'noEnergyMeter'))); + expect(find.textContaining('publie une puissance, pas une énergie'), + findsOneWidget); + expect(find.textContaining('Rattachez-en un'), findsNothing); + }); + + testWidgets('la borne sans énergie de session : c\'est le matériel', + (tester) async { + await _poser( + tester, _harness(_charge(mesurable: false, cause: 'noSessionEnergy'))); + expect(find.textContaining('C\'est le matériel'), findsOneWidget); + // On n'envoie pas chercher un réglage qui n'existe pas. + expect(find.textContaining('Rattachez'), findsNothing); + }); + }); + + group('quand il faut se taire', () { + testWidgets('aucune obligation déclarée : rien à avertir', (tester) async { + // Personne ne paiera : l'avertissement serait du bruit sur un écran qu'on ouvre + // pour toute autre chose. + await _poser(tester, + _harness(_charge(mesurable: false, cause: 'noMeter', energie: 0))); + expect(find.textContaining('ne pourra jamais être close'), findsNothing); + }); + + testWidgets('box antérieure à +etm49 : on ne conclut pas d\'une absence', + (tester) async { + // `progressMeasurable` absent ne veut pas dire « non mesurable ». Avertir ici + // serait fabriquer une alarme à partir d'un champ que la box ne publie pas. + await _poser(tester, _harness(_charge(mesurable: null))); + expect(find.textContaining('ne pourra jamais être close'), findsNothing); + }); + + testWidgets('cause inconnue : on le dit, on n\'invente pas de geste', + (tester) async { + await _poser( + tester, _harness(_charge(mesurable: false, cause: 'futureCause'))); + expect(find.textContaining('futureCause'), findsOneWidget); + }); + }); + + group('l\'échéance est à l\'heure de l\'installation', () { + testWidgets('et l\'écran le dit, plutôt que de choisir une horloge', + (tester) async { + await _poser(tester, _harness(_charge())); + expect(find.textContaining('Heure de l\'INSTALLATION'), findsOneWidget); + expect(find.textContaining('pas celle de cet appareil'), findsOneWidget); + }); + }); +}