feat(config): « Ce rang me convient » — adopter un rang sans le changer

`"user"` n'est pas « j'ai vu le badge ». C'est « ce rang me va, je l'assume » : un jugement
porté sur l'ordre de service, et la seule voie pour éteindre le badge SANS changer
l'arbitrage. Les trois conséquences sont dans le code, parce qu'elles sont faciles à perdre.

1. LE GESTE EST EXPLICITE. Un bouton sur le badge, appelé de là et de nulle part ailleurs.
   Jamais une écriture au passage, jamais l'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.

2. IL NE S'ANNULE PAS TOUT SEUL. Une fois posé, seul le moteur le lève, quand un rang
   différent arrive pour cet id. L'app ne le retire jamais.

3. IL NE REMPLACE PAS LE DÉPLACEMENT, et le libellé le dit : « Adopter ce rang ne le change
   pas. Pour en changer, glissez la charge. » Sans cette phrase, un installateur valide un
   ordre qu'il voulait changer — et n'y reviendra pas, puisque le badge s'est éteint.

L'écriture emprunte le chemin de la suppression, pour la même raison : SetLoadConfig
remplace tout l'ensemble. Lire (frais) → patcher le SEUL rankOrigin de la SEULE charge visée
→ réécrire verbatim → relire et comparer clé par clé. Le rang ne bouge pas : « ce rang me
va » ne peut pas porter sur un rang qu'on vient de changer. Garde-fou des rangs à égalité
inclus, et refus si un brouillon est en cours.

Vérifié au passage, comme demandé : sansRankOriginAuto() laisse passer "user" — le confondre
avec "auto" aurait rendu le bouton silencieusement inopérant. Test dédié, en plus de celui
qui existait.

Quatre tests de plus : le bouton est là sur "auto" avec sa phrase, absent sur "user" et sur
champ vide, et la charge utile d'une adoption ne diffère que par rankOrigin.

⚠️ Non vérifiable sur `.75` : les deux bornes y sont antérieures à `+etm27`, donc aucun
"auto" n'y est observable. À reprendre au premier appairage réel d'une borne.

226 tests (+4), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
Patrick Schurig 2026-08-29 10:28:21 +02:00
parent 761e1e369d
commit 99a24a5f3e
5 changed files with 289 additions and 31 deletions

15
TODO.md
View File

@ -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

View File

@ -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<String?> adoptRank(String loadId) async {
final service = _service;
if (service == null) return 'NoService';
if (_draft != null) return 'PendingDraft';
_removalDiff = const [];
final List<Map<String, dynamic>> frais;
try {
frais = await service.getLoadConfig();
} catch (e) {
_saveError = e.toString();
_saveState = LoadSaveState.rejected;
notifyListeners();
return _saveError;
}
var trouvee = false;
final envoye = <Map<String, dynamic>>[];
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<String, dynamic>.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<Map<String, dynamic>> 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.

View File

@ -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<void> _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

View File

@ -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

View File

@ -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',