fix(carte): le parent domine, et la phrase éco cesse de se répéter

Deuxième passage sur appareil, écran des charges. Trois défauts, dont deux de
notre fait et un que les fixtures ne pouvaient pas produire.

1. LA FRACTION ÉTAIT ÉCRITE DEUX FOIS — `ECO_FLOOR_MET` porte déjà livré et
   cible dans son motif, et la ligne d'avancement les réécrivait dessous :
   « Plancher atteint — 29,9 kWh sur 3,0 kWh livrés » puis « 29,9 kWh sur 3,0
   kWh livrés · mesuré depuis 10:00 ». C'est le défaut de la ligne des ratios de
   la veille, refait à l'identique : déclencher sur ce qu'on SAIT plutôt que sur
   ce qui se VOIT. La ligne ne garde plus que ce que le motif ne dit pas.

2. « X SUR Y » N'EST PAS UNE FRACTION une fois le plancher dépassé — le compteur
   continue de courir après que l'obligation est tenue, et le numérateur finit à
   dix fois le dénominateur. Aucune fixture ne le montrait : toutes avaient un
   livré inférieur à la cible. Reformulé « 3,0 kWh dus, 30,2 kWh livrés ».

3. LE PARENT NE DOMINAIT PAS — à taille et graisse égales, l'allocation et les
   deux niveaux se lisaient comme trois lignes sœurs, et les pastilles colorées
   tiraient l'œil davantage que la commande qu'elles expliquent : le contresens
   exact que la forme doit empêcher (LM-1003). L'allocation passe en 13,5 gras,
   les pastilles perdent leur fond, et un filet vertical rattache chaque niveau
   à la commande.

Vérifié à l'écran après correction, et le repli l'était déjà : V2C et Simulated
wallbox tiennent sur une seule ligne, sans étiquette orpheline ni vide résiduel.

Et le signe du solaire est CONFIRMÉ, ce qui n'avait pas pu l'être hier faute de
production : la box publie -4 376 W, l'écran affiche « 4.4 kW ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-06 08:11:22 +02:00
parent b3000ca2d5
commit 42eef05c3b
6 changed files with 83 additions and 21 deletions

View File

@ -170,9 +170,9 @@
"description": "ECO_FLOOR_GRID. L'achat est le fait actionnable : il coûte, contrairement au surplus.",
"placeholders": { "gridW": { "type": "int" } }
},
"decisionEcoFloorMet": "Plancher atteint — {livre} sur {cible} livrés",
"decisionEcoFloorMet": "Plancher atteint — {cible} dus, {livre} livrés",
"@decisionEcoFloorMet": {
"description": "ECO_FLOOR_MET. Le SEUL état qui ferme l'obligation, et il est interdit sous le régime unmeasurable (R6) : on n'annonce pas tenu ce qu'on ne mesure pas.",
"description": "ECO_FLOOR_MET. Le SEUL état qui ferme l'obligation, et il est interdit sous le régime unmeasurable (R6) : on n'annonce pas tenu ce qu'on ne mesure pas. ⚠️ « X sur Y livrés » était FAUX une fois le plancher dépassé — vu sur appareil le 2026-09-06, « 29,9 kWh sur 3,0 kWh livrés », un numérateur dix fois le dénominateur. Le compteur continue de courir après que l'obligation est tenue : la fraction cesse alors d'en être une. On dit donc ce qui était DÛ, puis ce qui a été livré.",
"placeholders": { "livre": { "type": "String" }, "cible": { "type": "String" } }
},
"decisionEcoFloorMissed": "Obligation abandonnée à la bascule — {raison}",

View File

@ -493,10 +493,10 @@ abstract class L10n {
/// **'Plancher servi — {gridW} W achetés au réseau'**
String decisionEcoFloorGrid(int gridW);
/// ECO_FLOOR_MET. Le SEUL état qui ferme l'obligation, et il est interdit sous le régime unmeasurable (R6) : on n'annonce pas tenu ce qu'on ne mesure pas.
/// ECO_FLOOR_MET. Le SEUL état qui ferme l'obligation, et il est interdit sous le régime unmeasurable (R6) : on n'annonce pas tenu ce qu'on ne mesure pas. ⚠️ « X sur Y livrés » était FAUX une fois le plancher dépassé — vu sur appareil le 2026-09-06, « 29,9 kWh sur 3,0 kWh livrés », un numérateur dix fois le dénominateur. Le compteur continue de courir après que l'obligation est tenue : la fraction cesse alors d'en être une. On dit donc ce qui était DÛ, puis ce qui a été livré.
///
/// In fr, this message translates to:
/// **'Plancher atteint — {livre} sur {cible} livrés'**
/// **'Plancher atteint — {cible} dus, {livre} livrés'**
String decisionEcoFloorMet(String livre, String cible);
/// ECO_FLOOR_MISSED. La cause décide de la phrase ET du geste. Pas de cause « surplus insuffisant » : l'éco achète au réseau, donc le surplus ne peut jamais l'empêcher.

View File

@ -276,7 +276,7 @@ class L10nFr extends L10n {
@override
String decisionEcoFloorMet(String livre, String cible) {
return 'Plancher atteint — $livre sur $cible livrés';
return 'Plancher atteint — $cible dus, $livre livrés';
}
@override

View File

@ -350,7 +350,21 @@ String? ecoProgressText(L10n t, LoadLevel niveau) {
final livre = p.deliveredWh;
if (livre == null) return null;
final base = t.ecoAvancementMesure(energieCourte(livre), energieCourte(p.targetWh));
// ⚠️ **`ECO_FLOOR_MET` porte DÉJÀ le livré et la cible dans son motif.** Répéter la même
// phrase juste dessous donnait, sur appareil le 2026-09-06 :
//
// ÉCO Plancher atteint — 29,9 kWh sur 3,0 kWh livrés
// 29,9 kWh sur 3,0 kWh livrés · mesuré depuis 10:00
//
// Deux fois les mêmes chiffres, et le lecteur cherche ce que la seconde ligne ajoute.
// C'est le même défaut que la ligne des ratios instantanés du 2026-09-05 : je déclenche
// sur ce que je SAIS (il y a un avancement) plutôt que sur ce qui se VOIT (est-il déjà
// dit ?). La ligne ne garde donc que ce que le motif ne dit pas — l'origine de la mesure.
final dejaDit = d?.code == 'ECO_FLOOR_MET';
final base = dejaDit
? null
: t.ecoAvancementMesure(energieCourte(livre), energieCourte(p.targetWh));
final depuis = p.measuredSince;
if (depuis == null) return base;
final h = '${depuis.hour.toString().padLeft(2, '0')}:'
@ -358,5 +372,7 @@ String? ecoProgressText(L10n t, LoadLevel niveau) {
// La date reste le PREMIER instant mesuré ; l'intermittence se dit à part. Après une
// bascule en cours de période, `deliveredWh` ne compte que depuis elle pendant que la cible
// reste entière — la paire n'est donc plus une fraction, et c'est cette date qui l'explique.
return '$base · ${p.parIntermittence ? t.ecoMesureIntermittente(h) : t.ecoMesureDepuis(h)}';
final origine =
p.parIntermittence ? t.ecoMesureIntermittente(h) : t.ecoMesureDepuis(h);
return base == null ? origine : '$base · $origine';
}

View File

@ -180,9 +180,14 @@ class LoadLiveBlock extends StatelessWidget {
size: 14,
color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)),
const SizedBox(width: 4),
// ⚠️ **Le parent doit DOMINER ses deux lignes.** Vu sur appareil le 2026-09-06 :
// à taille et graisse égales, l'allocation et les deux niveaux se lisaient comme
// trois lignes sœurs — et les pastilles ÉCO/CONFORT, colorées, tiraient même
// l'œil DAVANTAGE que la commande. C'est le contresens exact que la forme doit
// empêcher (LM-1003 : deux lignes, une seule décision).
Text(t.telemetryAllocated(live.allocatedW.round()),
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
fontSize: 13.5, fontWeight: FontWeight.w700, color: tone)),
if (mechText != null) ...[
const SizedBox(width: 6),
Flexible(
@ -746,24 +751,28 @@ List<Widget> _lignesNiveaux(L10n t, LoadTelemetryEntry live, Color tone) {
Padding(
padding: const EdgeInsets.only(top: 3),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Un filet vertical rattache les niveaux à la commande : ils en sont la
// décomposition, pas des lignes voisines. La pastille, elle, s'efface — colorée
// et grasse, elle captait l'œil avant le nombre qu'elle explique.
Container(
width: 54,
padding: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: (n.estEco ? const Color(0xFFF9A825) : const Color(0xFF2E7D32))
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
width: 2,
height: 30,
margin: const EdgeInsets.only(left: 2, right: 8, top: 2),
color: (n.estEco ? const Color(0xFFF9A825) : const Color(0xFF2E7D32))
.withValues(alpha: 0.35),
),
SizedBox(
width: 52,
child: Text(n.estEco ? t.niveauEco : t.niveauConfort,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: n.estEco
? const Color(0xFFF9A825)
: const Color(0xFF2E7D32))),
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: (n.estEco
? const Color(0xFFF9A825)
: const Color(0xFF2E7D32))
.withValues(alpha: 0.75))),
),
const SizedBox(width: 6),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_phraseNiveau(t, n),

View File

@ -185,6 +185,43 @@ void main() {
expect(find.textContaining('atteint —'), findsNothing);
});
testWidgets('le plancher DÉPASSÉ ne s\'écrit pas comme une fraction',
(tester) async {
// Vu sur appareil le 2026-09-06 : « Plancher atteint — 29,9 kWh sur 3,0 kWh
// livrés ». Le compteur continue de courir après que l'obligation est tenue, donc
// le numérateur dépasse le dénominateur et la fraction cesse d'en être une.
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 3000.0, 'deliveredWh': 29900.0,
'purchasedWh': 127.0, 'measuredSince': '2026-09-05T08:00:00Z',
},
decisionEco: {
'code': 'ECO_FLOOR_MET',
'params': {'deliveredWh': 29900, 'targetWh': 3000},
},
)));
expect(find.textContaining('3,0 kWh dus, 29,9 kWh livrés'), findsOneWidget);
expect(find.textContaining('sur 3,0 kWh'), findsNothing);
});
testWidgets('et la fraction n\'est PAS répétée sous le motif', (tester) async {
// Le motif porte déjà livré et cible ; la ligne d'avancement ne garde que ce qu'il
// ne dit pas — l'origine de la mesure.
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 3000.0, 'deliveredWh': 29900.0,
'purchasedWh': 127.0, 'measuredSince': '2026-09-05T08:00:00Z',
},
decisionEco: {
'code': 'ECO_FLOOR_MET',
'params': {'deliveredWh': 29900, 'targetWh': 3000},
},
)));
expect(find.textContaining('29,9 kWh livrés'), findsOneWidget,
reason: 'une seule fois, dans le motif');
expect(find.textContaining('mesuré depuis'), findsOneWidget);
});
testWidgets('sous `measured`, il se rend normalement', (tester) async {
await tester.pumpWidget(_carte(_payload(
progressEco: {