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() =>