Écrit AVANT le déploiement du moteur, pour qu'il n'y ait pas de trou.
`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 selon que ses
relais mesurent, un SG-Ready une puissance toujours déclarée. C'est la condition C2 de
LM-1009. Le régime se publie désormais :
measurement: { source: "meter" | "device" | "none", powerW }
Les trois pièges d'affichage du §7, figés par test :
· `none` est un état POSITIF — « cette charge n'est pas mesurable ». Il autorise à MASQUER
le bloc au lieu d'afficher un vide, et sépare enfin « pas mesurable » de « compteur en
panne ». Aucune puissance nominale n'est allée la chercher dans mechanism.* pour combler :
elle produirait un écart identiquement nul à tous les cycles, soit un « tout concorde »
permanent qui ne signale plus rien.
· {source: "device", powerW: 0} est une MESURE. Une borne branchée qui ne charge pas mesure
zéro watt. Si la source basculait à l'extinction, le bloc apparaîtrait et disparaîtrait au
rythme du thermostat.
· L'écart n'a pas la même valeur selon la source, et l'écran le DIT : sous `device`, il
montre la désobéissance mais une concordance ne prouve rien — plusieurs wallbox renvoient
leur consigne ; sous `meter`, la mesure est indépendante de l'organe commandé et la
concordance devient une preuve.
Le compteur dédié se décide sur `measurement.source`, JAMAIS sur `adapter` :
source == device → réglage avancé, replié (l'appareil publie déjà)
sinon → proposé, visible
La règle couvre SG-Ready sans avoir à le connaître — `none` par construction, LM-1105-b : un
contact de signal ne voit pas le courant de la machine, et pour une PAC le compteur dédié est
la SEULE voie vers une mesure. Elle couvre aussi la topologie de relais mixte, `none` elle
aussi, qu'un « masqué dès que la charge mesure, sauf SG-Ready » aurait manquée. Redéduire le
régime du mécanisme remettrait ici la déduction que le moteur vient d'en retirer.
⚠️ Une distinction que le contrat n'a pas à porter mais que l'écran doit faire : une charge
ABSENTE de la télémétrie n'est pas `none`. `none` est publié ; l'absence ne dit rien. Les
confondre annoncerait « seule voie vers une mesure » sur une borne qui publiera très bien la
sienne au prochain cycle.
⏳ Alias temporaire `measuredW` en repli, avec sa date de retrait : dès que +etm26 est sur
.75 (entrée TODO, sonde nommée). `measurement` gagne quand les deux sont là.
204 tests (+16), flutter analyze inchangé à 27 remarques. Prêt pour le déploiement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
194 lines
7.2 KiB
Dart
194 lines
7.2 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, et la
|
|
/// tolérance temporaire vers `measuredW`.
|
|
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.legacy);
|
|
expect(m.mesuree, isTrue);
|
|
expect(m.concordanceProbante, isFalse);
|
|
});
|
|
});
|
|
|
|
group('tolérance temporaire vers measuredW — à retirer avec +etm26 sur .75', () {
|
|
test('sans `measurement`, l\'ancienne clé est encore lue', () {
|
|
// Sans cet alias, la mesure disparaîtrait des écrans entre ce livrable et le
|
|
// déploiement du moteur.
|
|
final m = _entree({'measuredW': 1500.0}).measurement;
|
|
expect(m.source, MeasurementSource.legacy);
|
|
expect(m.powerW, 1500.0);
|
|
});
|
|
|
|
test('`measurement` GAGNE quand les deux sont là', () {
|
|
// Deux noms pour une chose divergent : le nouveau fait foi, sans exception.
|
|
final m = _entree({
|
|
'measuredW': 1500.0,
|
|
'measurement': {'source': 'meter', 'powerW': 3980.0}
|
|
}).measurement;
|
|
expect(m.source, MeasurementSource.meter);
|
|
expect(m.powerW, 3980.0);
|
|
});
|
|
|
|
test('ni l\'un ni l\'autre : pas mesurable', () {
|
|
expect(_entree(const {}).measurement.mesuree, 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('sur une box ancienne, la valeur s\'affiche SANS portée',
|
|
(tester) async {
|
|
await tester.pumpWidget(carte({'measuredW': 1500.0}));
|
|
expect(find.textContaining('Mesuré 1500 W'), findsOneWidget);
|
|
expect(find.textContaining('concordance'), findsNothing);
|
|
expect(find.textContaining('indépendante'), findsNothing);
|
|
});
|
|
});
|
|
}
|