MOTIF BELOW_MIN_POWER — premier ajout au catalogue depuis qu'il existe.
Le sens porté est précis et facile à perdre : il Y A du surplus, mais pas au bon format —
sous le plus petit cran commandable, la charge ne prend rien et le budget file à la
suivante. À distinguer de SURPLUS_INSUFFICIENT et SG_NORMAL, qui ne sortent plus que pour
un budget ≤ 0, « il n'y a rien ». Les confondre ferait chercher un défaut de production là
où il n'y a qu'une granularité. Deux variantes, départagées par la présence de `state`.
Les deux phrases voisines sont reprises en conséquence : SG_NORMAL peut enfin dire « aucun
surplus » sans risque, ce qu'elle ne pouvait pas avant, et SURPLUS_INSUFFICIENT ne parle
plus de « palier non payé » — elle dit qu'il n'y a rien à distribuer.
LE REPLI AURAIT TENU, et c'est vérifié plutôt qu'affirmé : sur BELOW_MIN_POWER privé de
`minPowerW`, l'app rend le code et ses paramètres triés au lieu de fabriquer une phrase
approximative. C'était exactement le cas prévu, et c'est le premier vrai à se présenter.
GLISSEMENT — le test POSE désormais sa condition au lieu de la subir. Il regroupe les deux
charges dans un même domaine, exerce, puis REND le classement d'origine. Deux fois de suite
il s'était contenté d'annoncer « non exercé » parce que le banc était revenu à une charge
par domaine : un test qui dépend de l'état du terrain déclare forfait au moment où on
aurait besoin de lui. Exercé sur appareil : [chauffe-eau, pac-terrain] →
[pac-terrain, chauffe-eau], classement rétabli {ecs, heating}.
COMPTEURS — rattachés pour de vrai depuis le chemin de l'app, et non par une sonde.
measuredW apparaît par charge, chacune portant SON compteur. L'affichage met la mesure à
côté de l'allocation, jamais à sa place : l'une est ce que le waterfall a décidé, l'autre
ce qui passe réellement.
Mon assertion initiale était fautive : j'exigeais des mesures DISTINCTES pour prouver le
rattachement par charge. Deux compteurs à l'arrêt lisent légitimement 0 W tous les deux —
le test échouait donc sur une installation saine. Ce qui prouve le rattachement, c'est que
chaque charge porte un compteur différent. Vérifié aussi, en détachant puis rattachant :
une charge sans compteur ne publie RIEN — null n'est pas 0, et afficher un zéro ferait
passer une absence de mesure pour une absence de consommation.
PLANCHER — préparé AVANT son arrivée au schéma, précisément à cause de ce qui vient de se
passer avec meterThingId et sensorThingId : le grisage dérivé les a activés tout seuls,
mais sur un placeholder mort. Le bloc est donc câblé maintenant, reste grisé tant que le
champ n'existe pas, et fonctionnera le jour où il apparaîtra. La box publie déjà
mechanism.minPowerW en télémétrie : on l'affiche en lecture, ce qui donne la valeur en
vigueur même sans réglage ouvert.
Tests 101/101, analyze 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
326 lines
14 KiB
Dart
326 lines
14 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:etm_powersync_app/l10n/app_localizations.dart';
|
||
import 'package:etm_powersync_app/models/load_telemetry.dart';
|
||
import 'package:etm_powersync_app/services/telemetry_text.dart';
|
||
import 'package:flutter/widgets.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
Map<String, dynamic> _fixture(String name, String key) {
|
||
final raw = File('test/fixtures/$name').readAsStringSync();
|
||
return Map<String, dynamic>.from(
|
||
(jsonDecode(raw) as Map<String, dynamic>)[key] as Map);
|
||
}
|
||
|
||
void main() {
|
||
late L10n t;
|
||
|
||
setUpAll(() async {
|
||
t = await L10n.delegate.load(const Locale('fr'));
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('§7-5 — lecture de la charge utile réelle de .75', () {
|
||
test('le dump du banc se lit entièrement, budget et mécanismes compris', () {
|
||
final tel =
|
||
LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry'));
|
||
|
||
expect(tel.loads.map((l) => l.loadId), ['chauffe-eau', 'pac-terrain']);
|
||
expect(tel.degradedMode, isFalse);
|
||
expect(tel.hasNeverRun, isFalse);
|
||
expect(tel.timestamp, isNotNull);
|
||
|
||
final ecs = tel.entryFor('chauffe-eau')!;
|
||
expect(ecs.available, isTrue);
|
||
expect(ecs.faultCode, isNull);
|
||
expect(ecs.mechanism!.kind, 'relay');
|
||
expect(ecs.mechanism!.stageW, 3500);
|
||
expect(ecs.decision.code, 'SURPLUS_SETPOINT');
|
||
|
||
final pac = tel.entryFor('pac-terrain')!;
|
||
expect(pac.mechanism!.kind, 'sgReady');
|
||
expect(pac.mechanism!.state, 4);
|
||
expect(pac.decision.code, 'SG_FORCED');
|
||
// Aucun verrou sur ce cycle-là : `lock` est optionnel, et son absence se lit
|
||
// « aucun verrou ne mord », jamais « verrou de zéro seconde ».
|
||
expect(pac.lock, isNull);
|
||
});
|
||
|
||
test('un cycle antérieur du même banc porte un verrou minStateHold', () {
|
||
// Même installation, trois cycles plus tôt. Sans ce second dump, la lecture du
|
||
// verrou ne serait éprouvée que sur une charge utile fabriquée.
|
||
final tel = LoadTelemetry.fromJson(
|
||
_fixture('loadtelemetry_hems75_v3_locked.json', 'telemetry'));
|
||
final pac = tel.entryFor('pac-terrain')!;
|
||
expect(pac.lock!.kind, 'minStateHold');
|
||
expect(pac.lock!.remainingS, 60);
|
||
expect(tel.budget!.identityHolds, isTrue);
|
||
});
|
||
|
||
test('l\'identité du budget annoncée par le plugin se vérifie sur le dump réel', () {
|
||
// surplusW + recreditedW − allocatedW == remainingW. Sans le terme de recrédit,
|
||
// l'égalité serait fausse dès qu'une charge consomme déjà — le cas normal.
|
||
final tel =
|
||
LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry'));
|
||
expect(tel.budget!.identityHolds, isTrue);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('champs omis, jamais nuls', () {
|
||
test('timestamp absent = aucun cycle, pas un cycle daté de l\'époque zéro', () {
|
||
final tel = LoadTelemetry.fromJson({'degradedMode': false, 'loads': []});
|
||
expect(tel.hasNeverRun, isTrue);
|
||
expect(tel.timestamp, isNull);
|
||
expect(tel.ageAt(DateTime.now()), isNull);
|
||
});
|
||
|
||
test('budget absent = mode dégradé, pas un budget de zéro watt', () {
|
||
final tel = LoadTelemetry.fromJson({
|
||
'degradedMode': true,
|
||
'timestamp': '2026-08-25T16:47:00Z',
|
||
'loads': [],
|
||
});
|
||
expect(tel.degradedMode, isTrue);
|
||
expect(tel.budget, isNull);
|
||
});
|
||
|
||
test('lock, mechanism et faultCode absents ne fabriquent aucune valeur', () {
|
||
final e = LoadTelemetryEntry.fromJson({
|
||
'loadId': 'x',
|
||
'allocatedW': 0,
|
||
'available': true,
|
||
'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {'budgetW': 12}},
|
||
});
|
||
expect(e.lock, isNull);
|
||
expect(e.mechanism, isNull);
|
||
expect(e.faultCode, isNull);
|
||
expect(e.inFault, isFalse);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('§7-6 — fraîcheur', () {
|
||
LoadTelemetry at(String iso) => LoadTelemetry.fromJson({
|
||
'degradedMode': false,
|
||
'timestamp': iso,
|
||
'loads': [],
|
||
});
|
||
|
||
test('le seuil est supérieur au battement de cœur du plugin', () {
|
||
// Le plugin émet une notification au moins toutes les 60 s et le timestamp avance
|
||
// d'un cycle par minute (vérifié sur .75 : 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s
|
||
// crierait « données anciennes » sur une installation parfaitement vivante.
|
||
expect(kTelemetryStaleAfter.inSeconds, greaterThan(60));
|
||
});
|
||
|
||
test('un cycle d\'il y a deux minutes n\'est pas encore ancien', () {
|
||
final now = DateTime.utc(2026, 8, 25, 16, 49);
|
||
expect(at('2026-08-25T16:47:00Z').ageAt(now), const Duration(minutes: 2));
|
||
expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isFalse);
|
||
});
|
||
|
||
test('un arbitre figé depuis dix minutes dépasse le seuil', () {
|
||
final now = DateTime.utc(2026, 8, 25, 16, 57);
|
||
expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isTrue);
|
||
});
|
||
|
||
test('un âge négatif (horloges désynchronisées) est ramené à zéro, pas au futur', () {
|
||
final now = DateTime.utc(2026, 8, 25, 16, 40);
|
||
expect(at('2026-08-25T16:47:00Z').ageAt(now), Duration.zero);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('§7-7 — charge en défaut', () {
|
||
// Charge utile SYNTHÉTIQUE, mais recopiée de ce que `.75` a réellement publié le
|
||
// 2026-08-25 pendant le passage de `tools/rpc/fault_probe.dart` : une charge
|
||
// etmvariableload dont le ThingId n'existe pas. Le dump brut n'a pas été conservé
|
||
// (le défaut n'a pas été reprovoqué pour ça) — `--dump` le capturera au prochain
|
||
// passage.
|
||
LoadTelemetryEntry faulty() => LoadTelemetryEntry.fromJson({
|
||
'loadId': '{00000000-dead-4bee-8000-000000000042}',
|
||
'allocatedW': 0,
|
||
'available': false,
|
||
'faultCode': 'WRITE_FAILED',
|
||
'decision': {
|
||
'code': 'LOAD_UNAVAILABLE',
|
||
'params': {'frozenW': 0},
|
||
},
|
||
});
|
||
|
||
test('une charge indisponible se lit comme en défaut', () {
|
||
final e = faulty();
|
||
expect(e.inFault, isTrue);
|
||
expect(e.faultCode, 'WRITE_FAILED');
|
||
expect(e.allocatedW, 0);
|
||
});
|
||
|
||
test('le défaut et son motif se rendent en français, sans le code brut', () {
|
||
final e = faulty();
|
||
expect(faultText(t, e.faultCode!), contains('écriture'));
|
||
expect(decisionText(t, e.decision), isNot(contains('LOAD_UNAVAILABLE')));
|
||
});
|
||
|
||
test('le code de défaut prime sur l\'absence de mécanisme', () {
|
||
// Observé sur `.75` : le Thing manquant fait échouer l'écriture, l'échelle
|
||
// s'épuise, et `m_faulted` masque THING_MISSING. L'app affiche ce que la box dit,
|
||
// sans corriger le diagnostic à sa place.
|
||
final e = faulty();
|
||
expect(e.mechanism, isNull);
|
||
expect(faultText(t, e.faultCode!), isNot(faultText(t, 'THING_MISSING')));
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('§7-8 — rendu des codes, et repli sur l\'inconnu', () {
|
||
test('les motifs du catalogue se rendent en français avec leurs paramètres', () {
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'SURPLUS_SETPOINT',
|
||
params: {'budgetW': 11897, 'setpointW': 3500, 'stepped': true}),
|
||
);
|
||
expect(phrase, contains('palier'));
|
||
expect(phrase, isNot(contains('SURPLUS_SETPOINT')));
|
||
});
|
||
|
||
test('`stepped` distingue deux phrases, et son absence ne prétend ni l\'une ni l\'autre',
|
||
() {
|
||
String render(Map<String, dynamic> p) =>
|
||
decisionText(t, LoadDecision(code: 'SURPLUS_SETPOINT', params: p));
|
||
|
||
final stepped = render({'budgetW': 100, 'setpointW': 50, 'stepped': true});
|
||
final plain = render({'budgetW': 100, 'setpointW': 50, 'stepped': false});
|
||
final absent = render({'budgetW': 100, 'setpointW': 50});
|
||
|
||
expect(stepped, isNot(plain));
|
||
expect(absent, plain);
|
||
});
|
||
|
||
test('BELOW_MIN_POWER — deux variantes, départagées par `state`', () {
|
||
// Premier motif ajouté depuis que le catalogue existe. Le sens porté est précis :
|
||
// il Y A du surplus, mais pas au bon format.
|
||
final watts = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'BELOW_MIN_POWER',
|
||
params: {'budgetW': 320, 'minPowerW': 500}),
|
||
);
|
||
final etats = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'BELOW_MIN_POWER',
|
||
params: {'budgetW': 900, 'minPowerW': 1500, 'state': 3}),
|
||
);
|
||
|
||
expect(watts, contains('palier'));
|
||
expect(watts, isNot(contains('BELOW_MIN_POWER')));
|
||
expect(etats, contains('état 3'));
|
||
expect(etats, isNot(watts));
|
||
});
|
||
|
||
test('BELOW_MIN_POWER ne se confond pas avec « il n\'y a rien »', () {
|
||
// SURPLUS_INSUFFICIENT et SG_NORMAL ne sortent plus que pour un budget ≤ 0. Les
|
||
// confondre ferait chercher un défaut de production là où il n'y a qu'une
|
||
// granularité de commande.
|
||
final rien = decisionText(
|
||
t, const LoadDecision(code: 'SURPLUS_INSUFFICIENT', params: {'budgetW': 0}));
|
||
final malForme = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'BELOW_MIN_POWER', params: {'budgetW': 320, 'minPowerW': 500}));
|
||
expect(rien, isNot(malForme));
|
||
expect(rien.toLowerCase(), contains('aucun surplus'));
|
||
});
|
||
|
||
test('le repli AURAIT tenu sur ce motif — code connu, paramètre manquant', () {
|
||
// C'est exactement le cas que le repli couvre, et le premier vrai à se présenter :
|
||
// une box en avance sur l'app. Sans `minPowerW`, on ne fabrique pas une phrase
|
||
// approximative — on rend le code et ses paramètres.
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(code: 'BELOW_MIN_POWER', params: {'budgetW': 320}),
|
||
);
|
||
expect(phrase, contains('BELOW_MIN_POWER'));
|
||
expect(phrase, contains('budgetW = 320'));
|
||
});
|
||
|
||
test('un code absent de l\'ARB s\'affiche en repli lisible, sans plantage', () {
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(code: 'FUTURE_CODE_2027', params: {'budgetW': 42, 'a': true}),
|
||
);
|
||
// Une box en avance sur l'app est un cas normal en parc déployé.
|
||
expect(phrase, contains('FUTURE_CODE_2027'));
|
||
expect(phrase, contains('budgetW = 42'));
|
||
expect(phrase, isNot(isEmpty));
|
||
});
|
||
|
||
test('les paramètres du repli sont triés : deux rendus du même motif sont identiques',
|
||
() {
|
||
final a = formatParams({'zW': 1, 'aW': 2});
|
||
final b = formatParams({'aW': 2, 'zW': 1});
|
||
expect(a, b);
|
||
expect(a, ' (aW = 2, zW = 1)');
|
||
});
|
||
|
||
test('un code connu privé d\'un paramètre attendu bascule en repli, pas en phrase trouée',
|
||
() {
|
||
// « Consigne de W servie par le surplus » aurait l'air d'une donnée ; le code brut
|
||
// a l'air de ce qu'il est.
|
||
final phrase = decisionText(
|
||
t, const LoadDecision(code: 'SG_FORCED', params: {'budgetW': 10}));
|
||
expect(phrase, contains('SG_FORCED'));
|
||
});
|
||
|
||
test('tous les codes du catalogue fermé ont une phrase, aucun ne tombe en repli', () {
|
||
const catalogue = <String, Map<String, dynamic>>{
|
||
'EV_SURPLUS': {},
|
||
'EV_SPOT_MARKET': {},
|
||
'EV_DEADLINE': {},
|
||
'EV_ECO_MIN': {},
|
||
'EV_IDLE': {},
|
||
'LOAD_UNAVAILABLE': {'frozenW': 500},
|
||
'LOCK_MIN_ON': {'appliedW': 500, 'budgetW': 100},
|
||
'LOCK_MIN_OFF': {'budgetW': 100, 'requestedW': 500},
|
||
'LOCK_MIN_OFF_CAPPED': {'appliedW': 1, 'budgetW': 2, 'requestedW': 3},
|
||
'SURPLUS_SETPOINT': {'budgetW': 1, 'setpointW': 2, 'stepped': false},
|
||
'SURPLUS_INSUFFICIENT': {'budgetW': 229},
|
||
'LOCK_MIN_STATE_HOLD': {'state': 4},
|
||
'SG_FORCED': {'budgetW': 1, 'estimatedW': 3000},
|
||
'SG_RECOMMENDED': {'budgetW': 1, 'estimatedW': 1500},
|
||
'SG_NORMAL': {'budgetW': 1},
|
||
'DEGRADED_L2': {},
|
||
'SAFE_STATE_RELAY': {},
|
||
'SAFE_STATE_SETPOINT': {},
|
||
'SAFE_STATE_SG_READY': {},
|
||
};
|
||
for (final entry in catalogue.entries) {
|
||
final phrase =
|
||
decisionText(t, LoadDecision(code: entry.key, params: entry.value));
|
||
expect(phrase, isNot(contains(entry.key)),
|
||
reason: '${entry.key} tombe en repli alors qu\'il est au catalogue');
|
||
}
|
||
});
|
||
|
||
test('défauts, verrous et mécanismes ont eux aussi leur repli', () {
|
||
expect(faultText(t, 'WRITE_FAILED'), isNot(contains('WRITE_FAILED')));
|
||
expect(faultText(t, 'THING_MISSING'), isNot(contains('THING_MISSING')));
|
||
expect(faultText(t, 'UNUSABLE_ENCODING'), isNot(contains('UNUSABLE_ENCODING')));
|
||
expect(faultText(t, 'FUTURE_FAULT'), contains('FUTURE_FAULT'));
|
||
|
||
expect(lockText(t, const LoadLock(kind: 'minOff', remainingS: 60)), contains('60'));
|
||
expect(lockText(t, const LoadLock(kind: 'futureLock', remainingS: 5)),
|
||
contains('futureLock'));
|
||
|
||
expect(
|
||
mechanismText(t, LoadMechanism(const {'stageW': 3500, 'maxStageW': 3500}, 'relay')),
|
||
contains('3'));
|
||
expect(mechanismText(t, LoadMechanism(const {}, 'futureMechanism')),
|
||
contains('futureMechanism'));
|
||
});
|
||
});
|
||
}
|