etm-powersync-app/test/measurement_source_test.dart
Patrick Schurig e362fb7430 chore(telemetry): l'alias measuredW est retiré — +etm26 est sur .75
Vérifié par sonde avant de couper : `measuredW` a disparu de la charge utile,
`measurement` est en ligne. L'alias n'aura vécu que le temps de l'intervalle qu'il devait
couvrir, et `MeasurementSource.legacy` part avec lui.

Ce qui reste, `MeasurementSource.inconnue`, ne le remplace pas : il couvre une box PLUS
RÉCENTE que l'app — la même règle que partout ailleurs dans ce fichier, un code inconnu
tombe sur un repli lisible plutôt que sur une phrase inventée. On affiche la valeur, on ne
prétend rien sur ce qu'un écart prouverait.

── La garantie qui manquait à ce que j'avais écrit ──────────────────────────

`measurement` est présent pour toute charge de `loads[]`, et pour elle seule. C'est elle
qui rend la lecture sûre, et elle est maintenant au contrat : son absence ne peut PAS
vouloir dire « pas mesurable » — ça, c'est `none`, un état publié — elle ne peut vouloir
dire que « charge hors de loads[] », cas où l'on ne conclut rien.

J'avais fait la distinction par prudence sur l'écran ; elle est désormais fondée sur une
garantie, pas sur une intuition.

── Les sources du banc ne sont pas celles annoncées, et c'est instructif ────

  chauffe-eau   meter, 1 500 W   porte ECS-Meter
  pac-terrain   meter,   800 W   porte PAC-Meter — pas `none`
  V2C Trydan    absente de loads[]  (pluggedIn: false)

`pac-terrain` en `meter` ne contredit pas LM-1105-b : cette règle décrit ce qu'une PAC
publie SANS compteur dédié. On lui en a posé un — ce que le brief recommandait — et le
compteur explicite l'emporte toujours. Et la V2C illustre la distinction sur machine :
absente, pas `none`. Elle porte `currentPower` et publiera sa mesure au cycle où une
voiture y sera branchée.

⚠️ Le test sur appareil exigeait `measuredW == null` après détachement du compteur.
C'était vrai avant `+etm26`, c'est faux depuis : le moteur retombe sur l'appareil quand il
publie lui-même sa puissance. Ce qui doit cesser, c'est que la source vaille `meter` sans
compteur désigné — pas que la mesure disparaisse.

CLAUDE.md porte le contrat complet et les deux pièges, avec l'illustration du banc.

205 tests, flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 15:32:02 +02:00

216 lines
8.7 KiB
Dart

/// Le contrat `measurement` de `1.15.2+etm26` — **écrit avant son déploiement**.
///
/// `measuredW` portait trois régimes sous un seul nom et jetait la distinction : une borne
/// rendait une mesure, un routeur de relais une mesure **ou** son palier nominal, un
/// SG-Ready une puissance toujours déclarée. Le régime se publie désormais :
/// `measurement: {source: "meter" | "device" | "none", powerW}`.
///
/// Ces tests figent les trois pièges d'affichage nommés dans le §7 du brief, le retrait de
/// l'alias `measuredW` (2026-08-28, `+etm26` déployé), et la garantie qui rend la lecture
/// sûre : `measurement` est présent pour toute charge de `loads[]`, et pour elle seule.
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/widgets/load_cards.dart';
LoadTelemetryEntry _entree(Map<String, dynamic> extra) =>
LoadTelemetryEntry.fromJson({
'loadId': 'x',
'allocatedW': 3000,
'available': true,
'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}},
...extra,
});
void main() {
group('le régime est LU, jamais déduit', () {
test('les trois sources se lisent telles que la box les publie', () {
expect(
_entree({
'measurement': {'source': 'meter', 'powerW': 3980.0}
}).measurement.source,
MeasurementSource.meter);
expect(
_entree({
'measurement': {'source': 'device', 'powerW': 12.0}
}).measurement.source,
MeasurementSource.device);
expect(
_entree({
'measurement': {'source': 'none'}
}).measurement.source,
MeasurementSource.none);
});
test('`none` n\'a PAS de puissance — même si la box en met une', () {
// Le régime est ce qui est publié, pas la valeur : sous `none`, il n'y a rien à
// afficher, et un nombre qui traînerait ne doit pas ressusciter le bloc.
final m = _entree({
'measurement': {'source': 'none', 'powerW': 500.0}
}).measurement;
expect(m.powerW, isNull);
expect(m.mesuree, isFalse);
});
test('{device, 0} est une MESURE, pas une absence', () {
// Une borne branchée qui ne charge pas mesure zéro watt, et c'est une information.
// Si la source basculait vers `none` à l'extinction, le bloc de mesure
// apparaîtrait et disparaîtrait au rythme du thermostat.
final m = _entree({
'measurement': {'source': 'device', 'powerW': 0.0}
}).measurement;
expect(m.mesuree, isTrue);
expect(m.powerW, 0.0);
});
test('une source inconnue n\'est pas devinée', () {
// Une box plus récente que l'app : on affichera la valeur sans prétendre savoir ce
// qu'un écart prouverait.
final m = _entree({
'measurement': {'source': 'futur', 'powerW': 100.0}
}).measurement;
expect(m.source, MeasurementSource.inconnue);
expect(m.mesuree, isTrue);
expect(m.concordanceProbante, isFalse);
});
});
group('l\'alias measuredW est RETIRÉ — +etm26 déployé le 2026-08-28', () {
test('l\'ancienne clé n\'est plus lue du tout', () {
// Vérifié sur `.75` avant de retirer : `measuredW` a disparu de la charge utile.
// Le garder « au cas où » ferait vivre deux noms pour une chose — et ils divergent.
expect(_entree({'measuredW': 1500.0}).measurement.mesuree, isFalse);
});
test('`measurement` absent ne veut PAS dire « pas mesurable »', () {
// Garantie du contrat : `measurement` est présent pour toute charge de `loads[]`, et
// pour elle seule. Son absence ne peut donc signifier que « charge hors de loads[] »
// — cas où l'on ne conclut rien. `none`, lui, est un état publié.
final m = _entree(const {}).measurement;
expect(m.mesuree, isFalse);
expect(m.source, MeasurementSource.none,
reason: 'valeur de repli inerte — l\'écran distingue le cas par l\'absence '
'de la charge dans loads[], pas par cette valeur');
});
test('une source future n\'est pas devinée, et n\'efface pas la valeur', () {
final m = _entree({
'measurement': {'source': 'estimated', 'powerW': 100.0}
}).measurement;
expect(m.source, MeasurementSource.inconnue);
expect(m.powerW, 100.0);
expect(m.concordanceProbante, isFalse);
});
});
group('ce qu\'une concordance prouve dépend de la source', () {
test('seul `meter` rend une concordance probante', () {
// Plusieurs wallbox renvoient leur consigne en guise de puissance : sous `device`,
// « commandé 3000, mesuré 3000 » peut n'être qu'un écho.
for (final (src, attendu) in [
('meter', true),
('device', false),
('none', false),
]) {
expect(
_entree({
'measurement': {'source': src, 'powerW': 3000.0}
}).measurement.concordanceProbante,
attendu,
reason: src);
}
});
});
group('à l\'écran', () {
Widget carte(Map<String, dynamic> extra) {
final tel = LoadTelemetryProvider()
..adoptForTest({
'degradedMode': false,
'loads': [
{
'loadId': 'x',
'allocatedW': 3000,
'available': true,
'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}},
...extra,
}
],
});
return MaterialApp(
// `LoadLiveBlock` lit `context.l10n` : sans délégués, il explose avant d'afficher.
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: Scaffold(
body: LoadLiveBlock(
live: tel.entryFor('x')!, stale: false, telemetry: tel),
),
);
}
testWidgets('`none` MASQUE le bloc — pas de vide, pas de zéro', (tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'none'}
}));
expect(find.textContaining('Mesuré'), findsNothing);
// Et surtout aucun « écart » : « 0 W d'écart » serait la pire des réponses.
expect(find.textContaining('conforme'), findsNothing);
expect(find.textContaining('consigne'), findsNothing);
});
testWidgets('`device` affiche la mesure ET ce qu\'elle ne prouve pas',
(tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'device', 'powerW': 2950.0}
}));
expect(find.textContaining('Mesuré 2950 W'), findsOneWidget);
expect(find.textContaining('une concordance ne prouve rien'), findsOneWidget);
});
testWidgets('`meter` dit que la mesure est indépendante', (tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'meter', 'powerW': 2950.0}
}));
expect(find.textContaining('Mesuré 2950 W'), findsOneWidget);
expect(find.textContaining('mesure indépendante'), findsOneWidget);
});
testWidgets('un zéro mesuré s\'affiche — c\'est une information', (tester) async {
await tester.pumpWidget(carte({
'measurement': {'source': 'device', 'powerW': 0.0}
}));
expect(find.textContaining('Mesuré 0 W'), findsOneWidget);
});
testWidgets('une source inconnue affiche la valeur SANS portée', (tester) async {
// On montre ce que la box dit ; on ne prétend pas savoir ce qu'un écart prouverait.
await tester.pumpWidget(carte({
'measurement': {'source': 'estimated', 'powerW': 1500.0}
}));
expect(find.textContaining('Mesuré 1500 W'), findsOneWidget);
expect(find.textContaining('concordance'), findsNothing);
expect(find.textContaining('indépendante'), findsNothing);
});
testWidgets('les trois cas du banc, tels que .75 les publie le 2026-08-28',
(tester) async {
// chauffe-eau et pac-terrain portent chacun un compteur dédié (ECS-Meter,
// PAC-Meter) : source `meter`, 1 500 W et 800 W. `pac-terrain` en `meter` ne
// contredit pas LM-1105-b — cette règle décrit ce qu'une PAC publie SANS compteur
// dédié, et le compteur explicite l'emporte toujours.
for (final (w, _) in [(1500.0, 'chauffe-eau'), (800.0, 'pac-terrain')]) {
await tester.pumpWidget(carte({
'measurement': {'source': 'meter', 'powerW': w}
}));
expect(find.textContaining('Mesuré ${w.round()} W'), findsOneWidget);
expect(find.textContaining('mesure indépendante'), findsOneWidget);
}
});
});
}