diff --git a/TODO.md b/TODO.md index 5e36935..9597daa 100644 --- a/TODO.md +++ b/TODO.md @@ -20,12 +20,15 @@ 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. + - [x] ~~**Écrire `rankOrigin: "user"`**~~ — **fait le 2026-08-29.** Bouton « Ce rang me + convient » **sur le badge**, jamais un effet de bord d'un autre enregistrement : écrire + `"user"` parce que l'utilisateur a touché à autre chose affirmerait un jugement qu'il + n'a pas porté. Le libellé distingue adopter de classer — « Adopter ce rang ne le change + pas. Pour en changer, glissez la charge. » — sans quoi un installateur valide un ordre + qu'il voulait changer, et n'y revient pas puisque le badge s'est éteint. La marque ne + s'annule pas côté app : seul le moteur la lève, quand un rang différent arrive. + **À vérifier au premier appairage réel d'une borne**, `"auto"` n'étant pas observable + sur `.75` (les deux bornes y sont antérieures à `+etm27`). - [ ] **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/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index aa94afd..79fb602 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -443,6 +443,119 @@ class LoadConfigProvider extends ChangeNotifier { return 'Diverged'; } + /// **Adopter le rang posé d'office** — écrit `rankOrigin: "user"` sur une charge. + /// + /// ## Ce que ce geste AFFIRME + /// + /// « Ce rang me va, je l'assume » — l'installateur adopte un rang qu'il n'a pas choisi, + /// sans le déplacer. Ce n'est **pas** « j'ai vu le badge » : c'est un jugement porté sur + /// l'ordre de service, et c'est la seule voie pour éteindre le badge sans changer + /// l'arbitrage. + /// + /// D'où trois règles, et elles sont dans le code parce qu'elles sont faciles à perdre : + /// + /// 1. **Le geste est explicite, jamais un effet de bord.** Cette méthode n'est appelée + /// que par le bouton du badge. Écrire `"user"` parce que l'utilisateur a enregistré + /// autre chose affirmerait un jugement qu'il n'a pas porté — exactement ce qu'on + /// reproche à un `"auto"` fabriqué côté client. + /// 2. **Il ne s'annule pas tout seul.** Une fois posé, seul le moteur l'efface, quand un + /// rang différent arrive pour cet id. L'app ne le retire jamais. + /// 3. **Il ne remplace pas le déplacement.** Pour un autre rang, on glisse la charge ; + /// `"user"` est pour le cas où le défaut convient. Le libellé du bouton le dit. + /// + /// Même chemin d'écriture que la suppression — lire → patcher → réécrire verbatim, avec + /// le garde-fou des rangs à égalité et la relecture qui vérifie — parce que c'est le même + /// danger : `SetLoadConfig` remplace tout l'ensemble. + /// + /// \return `null` si la box a accepté, sinon un code. + Future adoptRank(String loadId) async { + final service = _service; + if (service == null) return 'NoService'; + if (_draft != null) return 'PendingDraft'; + _removalDiff = const []; + + final List> frais; + try { + frais = await service.getLoadConfig(); + } catch (e) { + _saveError = e.toString(); + _saveState = LoadSaveState.rejected; + notifyListeners(); + return _saveError; + } + + var trouvee = false; + final envoye = >[]; + for (final m in frais) { + if (m['id'] == loadId) { + trouvee = true; + // Seul `rankOrigin` change. Le rang, lui, ne bouge PAS : adopter n'est pas + // reclasser, et un geste qui déplacerait la charge en même temps rendrait + // l'affirmation fausse. + envoye.add({...m, 'rankOrigin': 'user'}); + } else { + envoye.add(Map.from(m)); + } + } + if (!trouvee) return 'NotFound'; + + // Même refus que la suppression : sur une configuration héritée d'avant `+etm27`, la + // box rejetterait l'ensemble sans motif lisible. + if (tiedByPriority([for (final m in envoye) LoadConfigEntry(m)]).isNotEmpty) { + _saveError = 'La configuration porte des rangs à égalité, que la box refuse ' + 'depuis 1.15.2+etm27. Classez les charges d\'abord.'; + _saveState = LoadSaveState.rejected; + notifyListeners(); + return 'TiedRanks'; + } + + _expectedSignature = _signatureOfPayload(envoye); + _conflict = null; + _saveError = null; + _saveState = LoadSaveState.pending; + notifyListeners(); + + final err = await service.setLoadConfig(envoye); + if (err != null) { + _saveState = LoadSaveState.rejected; + _saveError = err; + _expectedSignature = null; + notifyListeners(); + return err; + } + + // Relire et vérifier tout de suite : l'acquittement ne vaut pas application, et une + // adoption qui aurait déplacé autre chose serait pire que pas d'adoption du tout. + List> relu; + try { + relu = await service.getLoadConfig(); + } catch (e) { + _saveState = LoadSaveState.diverged; + _saveError = 'Adoption envoyée, mais la relecture a échoué ($e).'; + _expectedSignature = null; + notifyListeners(); + return 'RereadFailed'; + } + + _adopt(relu); + _expectedSignature = null; + _confirmTimeout?.cancel(); + + final ecarts = diffPayloads(envoye, relu); + if (ecarts.isEmpty) { + _saveState = LoadSaveState.confirmed; + _saveError = null; + notifyListeners(); + return null; + } + _saveState = LoadSaveState.diverged; + _removalDiff = ecarts; + _saveError = 'Rang adopté, mais ${ecarts.length} écart(s) : ' + '${ecarts.take(3).join(' · ')}'; + notifyListeners(); + return 'Diverged'; + } + /// Écarts constatés par la dernière suppression — vide quand elle n'a touché à rien /// d'autre. Ce n'est pas un échec de l'app : c'est ce que la box porte, et il faut le /// montrer plutôt que de le taire. diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 11a8756..d1704ec 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -226,35 +226,117 @@ class _NoteOrdreEntrelace extends StatelessWidget { /// 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(); +/// ## Les deux façons de l'éteindre, et ce qu'elles affirment +/// +/// - **Déplacer** la charge : le moteur efface `rankOrigin` dès qu'un rang différent arrive +/// pour cet id. C'est le geste de qui voulait un autre ordre. +/// - **Adopter** : écrire `rankOrigin: "user"`, c'est-à-dire « ce rang me va, je +/// l'assume ». Ce n'est PAS « j'ai vu le badge » — c'est un jugement porté sur l'ordre de +/// service, la seule voie pour éteindre le badge **sans changer l'arbitrage**. Il ne +/// s'annule pas tout seul : une fois posé, seul un déplacement le lève, côté moteur. +/// +/// L'app ne peut jamais écrire `"auto"` — le moteur le refuse, et c'est ce qui empêche un +/// écran d'inventer une provenance. +class _BadgeRangDOffice extends StatefulWidget { + final LoadConfigEntry entry; + final LoadConfigProvider provider; + + const _BadgeRangDOffice({required this.entry, required this.provider}); + + @override + State<_BadgeRangDOffice> createState() => _BadgeRangDOfficeState(); +} + +class _BadgeRangDOfficeState extends State<_BadgeRangDOffice> { + bool _enCours = false; + + /// **« Ce rang me convient »** — écrit `rankOrigin: "user"`, et rien d'autre. + /// + /// Le geste est **explicite**, jamais un effet de bord d'un autre enregistrement : + /// écrire `"user"` parce que l'utilisateur a touché à autre chose affirmerait un jugement + /// qu'il n'a pas porté — le reproche exact qu'on fait à un `"auto"` fabriqué côté client. + Future _adopter() async { + final messenger = ScaffoldMessenger.of(context); + if (widget.provider.hasPendingChanges) { + messenger.showSnackBar(const SnackBar( + content: Text('Enregistrez ou annulez vos modifications en cours : adopter un ' + 'rang réécrit toute la configuration.'), + )); + return; + } + + setState(() => _enCours = true); + final err = await widget.provider.adoptRank(widget.entry.id); + if (!mounted) return; + setState(() => _enCours = false); + + messenger.showSnackBar(SnackBar( + backgroundColor: err == null ? null : const Color(0xFF7F1D1D), + content: Text(err == null + ? 'Rang adopté. Il ne bouge pas — seul un déplacement le changera.' + : err == 'TiedRanks' + ? 'Deux charges partagent un rang : la box refuse toute la configuration ' + '(1.15.2+etm27). Classez-les d\'abord.' + : err == 'PendingDraft' + ? 'Modifications en cours : enregistrez-les ou annulez-les d\'abord.' + : 'La box a refusé ($err). Rien n\'a été modifié.'), + )); + } @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)), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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)), + ), + ), + ]), + ), + const SizedBox(height: 2), + // ⚠️ Le libellé doit empêcher la confusion la plus coûteuse : adopter n'est PAS + // classer. Sans cette phrase, un installateur « valide » un ordre qu'il voulait + // changer — et il n'aura aucune raison d'y revenir, puisque le badge s'est éteint. + Text( + 'Adopter ce rang ne le change pas. Pour en changer, glissez la charge.', + style: TextStyle(fontSize: 10.5, color: EtmTokens.mutedOf(context)), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _enCours ? null : _adopter, + icon: _enCours + ? const SizedBox( + width: 11, + height: 11, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.check_rounded, size: 15), + label: const Text('Ce rang me convient'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 6), + foregroundColor: EtmTokens.brand, + textStyle: const TextStyle(fontSize: 11.5), + ), ), ), - ]), + ], ); } } @@ -636,7 +718,7 @@ class LoadConfigCard extends StatelessWidget { // charge par charge — là où la note d'ensemble ne pouvait que le // supposer d'une égalité. if (entry.rangPoseDOffice) ...[ - const _BadgeRangDOffice(), + _BadgeRangDOffice(entry: entry, provider: provider), const SizedBox(height: 3), ], // Ce que l'installateur doit reconnaître d'un coup d'œil : le diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index d5f2f46..8b87d42 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -338,6 +338,39 @@ void main() { expect(find.textContaining('Rang posé par la box'), findsNothing); }); + testWidgets('le badge porte un geste EXPLICITE, et dit qu\'il ne classe pas', + (tester) async { + // « user » n'est pas « j'ai vu le badge » : c'est « ce rang me va, je l'assume ». + // Sans la phrase qui distingue adopter de classer, un installateur valide un ordre + // qu'il voulait changer — et n'y reviendra pas, le badge s'étant éteint. + 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.text('Ce rang me convient'), findsOneWidget); + expect(find.textContaining('Adopter ce rang ne le change pas'), findsOneWidget); + expect(find.textContaining('glissez la charge'), findsOneWidget); + }); + + testWidgets('aucun bouton d\'adoption là où il n\'y a rien à adopter', + (tester) async { + // Le geste ne s'offre que sur `auto` : proposer d'« adopter » un rang que quelqu'un + // a posé lui-même n'aurait aucun sens. + for (final origine in ['user', null]) { + final config = LoadConfigProvider()..adoptForTest(classe(origine: origine)); + await tester.pumpWidget(_harness( + config: config, + telemetry: LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}))); + await tester.pump(); + expect(find.text('Ce rang me convient'), findsNothing, + reason: 'origine=$origine'); + } + }); + 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 diff --git a/test/rangs_distincts_test.dart b/test/rangs_distincts_test.dart index 4ff6729..5038e94 100644 --- a/test/rangs_distincts_test.dart +++ b/test/rangs_distincts_test.dart @@ -187,6 +187,33 @@ void main() { reason: 'une clé absente le reste — on n\'en fabrique pas'); }); + test('`"user"` PASSE le point de sortie — sinon le geste ne partirait jamais', () { + // Le filtre vise `"auto"`, que l'app n'a pas le droit d'écrire. `"user"` est + // précisément ce qu'elle a le droit d'écrire : le confondre avec `"auto"` rendrait + // le bouton « ce rang me convient » silencieusement inopérant. + final envoye = sansRankOriginAuto([ + {'id': 'a', 'priority': 1, 'rankOrigin': 'user'}, + ]); + expect(envoye.single['rankOrigin'], 'user'); + }); + + test('adopter n\'est pas classer : le rang ne bouge pas', () { + // La charge utile d'une adoption ne change QUE `rankOrigin`. Un geste qui déplacerait + // la charge en même temps rendrait l'affirmation fausse — « ce rang me va » ne peut + // pas porter sur un rang qu'on vient de changer. + final avant = { + 'id': 'a', + 'label': 'A', + 'priority': 3, + 'rankOrigin': 'auto', + 'domain': 'ev', + }; + final apres = {...avant, 'rankOrigin': 'user'}; + expect(apres['priority'], avant['priority']); + expect(diffPayloads([avant], [apres]).single, contains('rankOrigin')); + expect(diffPayloads([avant], [apres]).length, 1); + }); + test('le nettoyage ne touche à RIEN d\'autre', () { final avant = { 'id': 'a',