From 8b7bfbcdedfc711ec5b35da4da7283e642993680 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 29 Aug 2026 09:35:17 +0200 Subject: [PATCH] feat(config): le badge dit la provenance du rang, la note alerte sur un refus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux natures, donc deux messages — et la fin d'une redondance : la note d'ex æquo et le badge disaient tous deux « ce rang n'est le choix de personne », ce qui garantissait que ni l'un ni l'autre ne serait lu. ── Le badge PREND ce rôle, et le fait mieux ───────────────────────────────── `rankOrigin == "auto"` (+etm27) → « Rang posé par la box, pas un choix », sur la carte de la charge concernée. La note ne pouvait que le DÉDUIRE d'une égalité de rangs, donc elle était muette sur une borne arrivée seule là où son rang ne heurtait personne — le cas qui devient la norme, puisque les bornes auto-provisionnées entrent désormais en queue. Ce que le badge n'affirme pas : il n'apparaît que sur "auto". Un champ ABSENT ne dit rien — ni choix humain, ni pose d'office — et une box antérieure à +etm27 rend exactement l'écran d'avant. Même règle que measurement.source. "user" n'affiche rien non plus : quelqu'un a regardé ce rang, même sans déplacer la charge. ── La note CHANGE de nature : constat → alerte avec remède ────────────────── Depuis +etm27, deux charges au même rang font refuser TOUT l'ensemble. En voir deux à l'écran ne dit donc plus quelque chose sur l'ordre de service : ça dit que cette configuration est héritée d'avant +etm27 — le plugin lui-même créait ces égalités — et qu'elle sera rejetée à la première écriture, y compris pour un simple renommage, sans motif lisible puisque le refus ne franchit pas la frontière RPC. Elle passe donc en rouge, annonce le rejet, et donne le seul geste qui répare : des rangs distincts. Le garde-fou de la suppression le disait déjà au moment du geste ; l'écran le dit maintenant avant qu'on le tente. Cinq tests montés : badge sur "auto" seulement, rien sur "user", rien sur champ absent, l'alerte porte bien un refus ET un remède, et elle se tait sur des rangs distincts. Reste au TODO : écrire `rankOrigin: "user"` depuis l'app — « ce rang me va » sans déplacer la charge. Sans ce geste, le badge reste affiché pour dire une chose que l'installateur a déjà tranchée. 218 tests (+5), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f --- TODO.md | 22 ++-- lib/models/load_config_entry.dart | 25 +++++ lib/widgets/controllable_loads_section.dart | 108 ++++++++++++++++---- test/merged_config_screen_test.dart | 93 +++++++++++++++++ 4 files changed, 221 insertions(+), 27 deletions(-) diff --git a/TODO.md b/TODO.md index 48600c0..5e36935 100644 --- a/TODO.md +++ b/TODO.md @@ -11,13 +11,21 @@ tests, dont le réaplatissage après **chaque** permutation. Le refus de la box est muet (le motif ne franchit pas la frontière RPC) : sans ce miroir, le symptôme aurait été « mon glissement ne s'enregistre pas ». - - [ ] **`o:rankOrigin` — badge « rang par défaut, pas un choix ».** `"auto"` écrit par le - moteur quand il crée une entrée d'office, **effacé dès qu'un rang différent arrive** - pour cet id. **Absent = rien d'affirmé**, jamais « choisi par un humain » — même règle - que `measurement.source`. `"user"` est ce qu'un client écrit pour dire « j'ai vu ce - défaut et il me va » sans déplacer la charge ; l'app ne peut **jamais** écrire `"auto"`, - le moteur le refuse. C'est l'équivalent, pour une charge arrivée seule, de la note de - rangs ex æquo — et il faudra décider si le badge remplace cette note ou s'y ajoute. + - [x] ~~**`o:rankOrigin` — badge « rang par défaut, pas un choix ».**~~ **Fait le + 2026-08-29 : le badge REMPLACE la note dans ce rôle**, deux formulations pour une idée + faisant qu'aucune n'est lue. Il ne s'affiche que sur `"auto"` — un champ absent + n'affirme rien, ni choix humain ni pose d'office, et une box antérieure à `+etm27` rend + l'écran d'avant sans badge. `"user"` n'en affiche aucun : quelqu'un a regardé ce rang. + La note d'ex æquo, elle, **change de nature** : depuis `+etm27` deux rangs doublés + signifient une configuration héritée qui sera **rejetée en bloc à la première + écriture** — ce n'est plus un constat sur l'ordre de service, c'est une alerte avec son + remède (réordonner). + - [ ] **Écrire `rankOrigin: "user"` — « ce rang me va », sans déplacer la charge.** Le + badge s'efface aujourd'hui d'une seule façon : déplacer la charge, ce qui fait effacer + le champ par le moteur. Accepter le rang tel quel demande une écriture — et donc le + chemin lire → patcher → réécrire verbatim, avec le garde-fou des rangs à égalité. Utile + surtout sur une installation dont l'ordre par défaut convient : sans ce geste, le badge + reste à l'écran pour dire une chose que l'installateur a déjà tranchée. - [ ] **Les bornes auto-provisionnées entrent en QUEUE (`1 + max`)**, plus en tête. Une borne détectée après la mise en service ne passe donc plus devant l'ECS classé exprès ; sur une installation neuve, la liste étant vide, le VE garde le rang 1. Vérifier que la diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart index 317055c..9c8f45a 100644 --- a/lib/models/load_config_entry.dart +++ b/lib/models/load_config_entry.dart @@ -95,6 +95,31 @@ class LoadConfigEntry { return d.trim(); } + /// **Qui a posé ce rang** — `"auto"`, `"user"`, ou **rien** (`+etm27`). + /// + /// Le moteur écrit `"auto"` quand il crée une entrée d'office — une borne détectée — et + /// l'**efface dès qu'un rang différent arrive** pour cet identifiant. Le champ se répare + /// donc tout seul au premier vrai réglage, sans que l'app ait à y penser. + /// + /// ## Absent n'affirme RIEN + /// + /// Ni « choisi par un humain », ni « posé d'office ». Une box antérieure à `+etm27` ne + /// publie pas le champ, et une entrée créée à la main non plus. C'est la même règle que + /// `measurement.source` : l'absence est un silence, pas une affirmation — et c'est + /// pourquoi le badge ne s'affiche que sur `"auto"`, jamais par défaut. + /// + /// `"user"` est ce qu'un client écrit pour dire « j'ai vu ce rang par défaut et il me + /// va », sans déplacer la charge. **L'app ne peut jamais écrire `"auto"`** — le moteur le + /// refuse, et c'est ce qui empêche un écran de fabriquer une provenance. + String? get rankOrigin { + final v = raw['rankOrigin']; + if (v is! String || v.trim().isEmpty) return null; + return v.trim(); + } + + /// Vrai quand le rang vient du moteur et que **personne ne l'a confirmé**. + bool get rangPoseDOffice => rankOrigin == 'auto'; + bool get isRelayRouter => adapter == 'relay-router'; bool get isSgReady => adapter == 'sg-ready'; diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 947e4d4..11a8756 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -206,21 +206,80 @@ class _NoteOrdreEntrelace extends StatelessWidget { } } -/// **L'ordre affiché n'est pas un choix quand des rangs sont à égalité.** +/// **« Rang par défaut, pas un choix »** — `rankOrigin == "auto"` (`1.15.2+etm27`). /// -/// À la détection d'une borne, le plugin lui attribue « le plus petit rang existant moins -/// un, borné à 1 ». Dès qu'une charge occupe déjà le rang 1 — le cas de toute installation -/// déjà réglée — l'intention « en tête » dégénère en égalité, et plusieurs charges se -/// retrouvent au même rang. +/// ## Ce qu'il remplace, et pourquoi il le fait mieux /// -/// Le tri reste **total** : l'identifiant départage, donc l'ordre ne bouge pas d'un cycle -/// à l'autre. Mais reproductible n'est pas choisi, et c'est toute la différence pour qui -/// regarde cette liste à la mise en service. Sans cette note, l'installateur lit un -/// classement, le trouve plausible, et n'y touche pas — validant par inadvertance un -/// ordre que personne n'a posé. +/// La note de rangs ex æquo portait cette idée jusqu'au 2026-08-29 : elle la **déduisait** +/// d'une égalité de rangs, donc elle ne pouvait rien dire d'une borne arrivée seule sur une +/// installation où son rang ne heurtait personne. Le moteur publie désormais la +/// provenance ; l'app la lit au lieu de la supposer, charge par charge. /// -/// Elle disparaît d'elle-même dès que les rangs sont distincts, c'est-à-dire dès le -/// premier vrai réglage. +/// Depuis `+etm27`, une borne auto-provisionnée entre en **queue** de liste (`1 + max`) et +/// ne passe donc plus devant l'ECS que l'installateur a classé exprès — le badge n'annonce +/// plus un problème d'ordre, seulement une **provenance** : ce rang est celui que le moteur +/// a posé, personne ne l'a regardé. +/// +/// ## Ce qu'il n'affirme pas +/// +/// Il n'apparaît que sur `"auto"`. Un champ **absent** n'affirme rien — ni choix humain, ni +/// pose d'office — et une box antérieure à `+etm27` n'en publie aucun : l'écran est alors +/// exactement celui d'avant, sans badge et sans mensonge. +/// +/// Il s'efface de deux façons, toutes deux côté moteur : **déplacer** la charge (le moteur +/// efface `rankOrigin` dès qu'un rang différent arrive), ou écrire `"user"` — « j'ai vu ce +/// rang et il me va » — que l'app peut poser mais qu'elle ne fabrique pas : `"auto"` lui +/// est refusé, et c'est ce qui empêche un écran d'inventer une provenance. +class _BadgeRangDOffice extends StatelessWidget { + const _BadgeRangDOffice(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: EtmTokens.solar.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.auto_awesome_rounded, size: 11, color: EtmTokens.solar), + const SizedBox(width: 4), + Flexible( + child: Text( + 'Rang posé par la box, pas un choix', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: EtmTokens.inkOf(context)), + ), + ), + ]), + ); + } +} + +/// **Deux charges au même rang : la box REFUSERA cette configuration.** +/// +/// ## Cette note a changé de nature le 2026-08-29 +/// +/// Elle disait « l'ordre affiché n'est pas un choix » — une information sur l'ordre de +/// service, née du rang par défaut d'une borne détectée. Ce rôle passe au **badge** +/// `rankOrigin` (`_BadgeRangDOffice`), qui le dit charge par charge et sans supposition : +/// deux formulations pour une même idée font qu'aucune n'est lue. +/// +/// Ce qui reste ici est **autre chose, et plus urgent**. Depuis `1.15.2+etm27` la box +/// refuse **en bloc** tout ensemble où deux charges partagent un rang. En voir deux à +/// l'écran signifie donc une configuration **héritée d'avant `+etm27`** — le plugin +/// lui-même créait ces égalités — qui sera **rejetée à la première écriture**, sans motif +/// lisible : le refus ne franchit pas la frontière RPC. +/// +/// D'où une alerte avec un remède, et non un constat : réordonner répare, et c'est le seul +/// geste qui répare. Le contrôle côté suppression le dit déjà au moment du geste ; l'écran +/// doit le dire **avant** qu'on le tente. +/// +/// Elle disparaît d'elle-même dès que les rangs sont distincts — c'est-à-dire dès que la +/// configuration redevient acceptable. class _NoteRangsExAequo extends StatelessWidget { final List entries; @@ -238,20 +297,22 @@ class _NoteRangsExAequo extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(11), decoration: BoxDecoration( - color: EtmTokens.solar.withValues(alpha: 0.10), + color: EtmTokens.danger.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + border: Border.all(color: EtmTokens.danger.withValues(alpha: 0.45)), ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.low_priority_rounded, - size: 15, color: EtmTokens.inkOf(context)), + Icon(Icons.report_problem_rounded, size: 15, color: EtmTokens.danger), const SizedBox(width: 7), Expanded( child: Text( - 'Rangs à égalité : $noms. L\'ordre affiché entre elles est stable, mais ' - 'ce n\'est pas un réglage — c\'est ce qui reste quand personne n\'a ' - 'tranché. Les bornes détectées arrivent au rang 1 par défaut, sur lequel ' - 'une autre charge est souvent déjà posée. Classez-les avant la mise en ' - 'service.', + 'Rangs à égalité : $noms. Depuis la version 1.15.2+etm27, la box REFUSE ' + 'toute configuration où deux charges partagent un rang — celle-ci sera ' + 'donc rejetée en bloc à la première écriture, y compris pour un simple ' + 'renommage.\n\n' + 'Le remède est le classement : donnez-leur des rangs distincts, puis ' + 'enregistrez. Rien d\'autre ne répare cette configuration, et rien ne ' + 'part à la box tant qu\'elle porte l\'égalité.', style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), ), @@ -571,6 +632,13 @@ class LoadConfigCard extends StatelessWidget { ), ), const SizedBox(height: 2), + // Le rang de CETTE charge vient-il du moteur ? Le badge le dit ici, + // charge par charge — là où la note d'ensemble ne pouvait que le + // supposer d'une égalité. + if (entry.rangPoseDOffice) ...[ + const _BadgeRangDOffice(), + const SizedBox(height: 3), + ], // Ce que l'installateur doit reconnaître d'un coup d'œil : le // mécanisme, ce qu'il commande, et les verrous qui le brident. // « rang 1 » n'apprend rien ici — la pastille le dit déjà. diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index a671ece..d5f2f46 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -280,6 +280,99 @@ void main() { }); }); + group('rankOrigin — le badge dit la provenance, la note alerte sur un refus', () { + List> v2c() => + (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) + .cast>(); + + /// Rangs distincts (l'état d'après `+etm27`), plus la provenance demandée. + List> classe({String? origine, String cible = 'V2C'}) { + final entrees = v2c(); + final ordonnees = [ + for (final (i, c) in entrees.indexed) + { + ...c, + 'priority': i + 1, + if (origine != null && (c['label'] as String).contains(cible)) + 'rankOrigin': origine, + } + ]; + return ordonnees; + } + + testWidgets('`auto` affiche le badge, sur CETTE charge seulement', (tester) async { + final config = LoadConfigProvider()..adoptForTest(classe(origine: 'auto')); + await tester.pumpWidget(_harness( + config: config, + telemetry: LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}))); + await tester.pump(); + + expect(find.textContaining('Rang posé par la box'), findsOneWidget); + }); + + testWidgets('`user` n\'affiche RIEN — quelqu\'un a regardé ce rang', + (tester) async { + // « J'ai vu ce rang par défaut et il me va » : c'est un choix, même sans déplacement. + final config = LoadConfigProvider()..adoptForTest(classe(origine: 'user')); + await tester.pumpWidget(_harness( + config: config, + telemetry: LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}))); + await tester.pump(); + + expect(find.textContaining('Rang posé par la box'), findsNothing); + }); + + testWidgets('champ ABSENT : aucun badge — l\'absence n\'affirme rien', + (tester) async { + // Une box antérieure à +etm27 ne publie pas le champ, et une entrée créée à la main + // non plus : l'écran doit être exactement celui d'avant. + final config = LoadConfigProvider()..adoptForTest(classe()); + await tester.pumpWidget(_harness( + config: config, + telemetry: LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}))); + await tester.pump(); + + expect(find.textContaining('Rang posé par la box'), findsNothing); + }); + + testWidgets('la note d\'ex æquo est devenue une ALERTE avec un remède', + (tester) async { + // Sur la config du banc, qui porte encore deux charges au rang 1 : depuis +etm27 + // elle sera rejetée en bloc à la première écriture, et le motif du refus ne franchit + // pas la frontière RPC. + final config = LoadConfigProvider()..adoptForTest(v2c()); + await tester.pumpWidget(_harness( + config: config, + telemetry: LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}))); + await tester.pump(); + + final note = find.textContaining('Rangs à égalité'); + expect(note, findsOneWidget); + final texte = tester.widget(note).data!; + expect(texte, contains('REFUSE')); + expect(texte, contains('rejetée en bloc')); + // Un remède, pas un constat. + expect(texte, contains('rangs distincts')); + // Et l'ancienne formulation, celle que le badge reprend, a disparu d'ici. + expect(texte, isNot(contains('ce n\'est pas un réglage'))); + }); + + testWidgets('des rangs distincts font taire l\'alerte', (tester) async { + final config = LoadConfigProvider()..adoptForTest(classe()); + await tester.pumpWidget(_harness( + config: config, + telemetry: LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}))); + await tester.pump(); + + expect(find.textContaining('Rangs à égalité'), findsNothing); + }); + }); + // ─────────────────────────────────────────────────────────────────────────── group('une borne à pleine puissance n\'est pas arbitrée — et l\'écran le nomme', () { List> v2cConfig() =>