etm-powersync-app/test/daily_balance_test.dart
Patrick Schurig 23a84fec43 feat(dashboard): le bilan du JOUR — et deux bugs d'unité trouvés en le posant
L'entrée 🔴 du TODO. Aucune des quatre tuiles n'était journalière : deux pourcentages
instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot
« aujourd'hui ». Elles montrent désormais la journée, sous un en-tête qui le dit une fois.

MÉTHODE : une DIFFÉRENCE de compteurs entre minuit local et maintenant, jamais une
intégration des puissances. La première est une mesure, prise sur les mêmes compteurs que
ceux du fournisseur ; la seconde est une estimation qui suppose que chaque échantillon
représente son intervalle. Mesuré sur .75 : l'intégration rend 77,70 kWh là où la différence
rend 78,31 — 0,8 % d'écart, systématiquement à la baisse. Il n'y a donc PAS de repli
silencieux de l'une vers l'autre : quand la mesure n'est pas exploitable, le type le dit.

DEUX BUGS D'UNITÉ, trouvés en cherchant à savoir ce que valaient les compteurs. Aucune
documentation ne tranchait, donc recoupement sur machine : l'intégration des puissances (en
W) rend 77 697 Wh quand la différence de totalProduction rend 78,310. Le rapport est mille —
LES COMPTEURS SONT EN kWh, et le modèle Dart les nommait Wh.

  1. L'écran Énergie divisait ces kWh par mille pour les afficher : une journée de 78 kWh
     s'y lisait « 78 Wh », sous un axe intitulé « Bilan énergétique (Wh) ». Invisible sans
     connaître l'ordre de grandeur attendu.
  2. La simulation accumulait des Wh : un écran juste en simulation aurait cassé d'un
     facteur mille contre une vraie box.

LE CONTRÔLE QUI VALIDE LE BILAN : production + soutiré == consommation + injecté. C'est le
seul recoupement disponible sur des compteurs qu'on ne peut pas vérifier autrement, et il
tient EXACTEMENT sur .75 — 112,244 des deux côtés sur 21,75 h. Faux, il désigne un compteur
cassé ou une remise à zéro, et la carte le signale au lieu de présenter comme une mesure ce
qui ne se recoupe pas.

Les trois pièges du TODO n'ont pas été repayés : bornes à minuit LOCAL via
GetPowerBalanceLogs · décodage délégué à fetchPowerBalanceLogs, qui porte déjà les bonnes
clés sans préfixe currentPower · plausibilité vérifiée sur les DEUX bornes avant de
soustraire, parce que deux valeurs absurdes peuvent avoir une différence d'apparence saine.
Plus un quatrième trouvé en chemin : un compteur qui RECULE (redémarrage en milieu de
fenêtre) donne un bilan négatif, et un max(0, …) le rendrait faux en silence.

Rien ne s'affiche jamais à zéro quand c'est inconnu. Sans production, le taux
d'autoconsommation est null et non 0 % — à 3 h du matin, « 0 % » se lirait comme un défaut
alors qu'il n'y a rien à autoconsommer.

VÉRIFIÉ SUR APPAREIL contre .75, en lecture seule (le banc est en préparation V2C, rien n'a
été écrit) : 32 échantillons depuis minuit, prod 28,37 · conso 26,55 · import 11,95 ·
export 13,77 kWh, identité OK, et les tuiles affichent 51 % / 55 % / 13,8 / 11,9.

Le passage sur appareil a trouvé ce qu'aucun des 138 tests unitaires ne pouvait voir : LES
QUATRE LIBELLÉS DÉBORDAIENT — « Autoconsomm… », « Vers réseau · aujou… ». Le suffixe
« aujourd'hui » répété quatre fois mangeait le nom de la grandeur ; il est dit une fois en
en-tête. Le harnais d'appareil échoue désormais sur tout libellé finissant par « … ».

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

135 lines
5.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Le **bilan du jour** — la première vue journalière de l'app.
///
/// ## Ce que ces tests protègent
///
/// Aucune des quatre tuiles du tableau de bord n'était journalière : deux pourcentages
/// instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot
/// « aujourd'hui ». Le lot corrige ça, et trois pièges le guettaient — tous déjà payés une
/// fois dans ce projet, aucun ne produisant d'erreur visible :
///
/// 1. prendre les totaux de `GetPowerBalance` au lieu d'une différence sur les logs ;
/// 2. lire les clés `currentPower*` sur des entrées de log qui n'en portent pas ;
/// 3. bâtir un bilan sur des compteurs corrompus.
///
/// La fixture est un relevé RÉEL de `.75`, avec ses deltas attendus calculés hors de
/// l'app : un test qui recalcule avec le code testé ne prouve rien.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/daily_balance.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _fixture() => jsonDecode(
File('test/fixtures/powerbalance_jour_hems75.json').readAsStringSync())
as Map<String, dynamic>;
/// Rejoue le calcul du service : une DIFFÉRENCE entre la première et la dernière borne.
DailyBalance _depuisFixture() {
final e = (_fixture()['powerBalanceLogEntries'] as List)
.cast<Map<String, dynamic>>()
..sort((a, b) => (a['timestamp'] as int).compareTo(b['timestamp'] as int));
final a = e.first, b = e.last;
double d(String k) => (b[k] as num).toDouble() - (a[k] as num).toDouble();
return DailyBalance(
from: DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int) * 1000),
to: DateTime.fromMillisecondsSinceEpoch((b['timestamp'] as int) * 1000),
sampleCount: e.length,
productionKwh: d('totalProduction'),
consumptionKwh: d('totalConsumption'),
importKwh: d('totalAcquisition'),
exportKwh: d('totalReturn'),
);
}
void main() {
group('bilan du jour — sur le relevé réel de .75', () {
test('les deltas sont ceux calculés hors de l\'app', () {
final b = _depuisFixture();
final attendu =
_fixture()['_deltas_attendus_kwh'] as Map<String, dynamic>;
expect(b.productionKwh, closeTo(attendu['production'] as double, 0.001));
expect(b.consumptionKwh, closeTo(attendu['consumption'] as double, 0.001));
expect(b.exportKwh, closeTo(attendu['return'] as double, 0.001));
expect(b.importKwh, closeTo(attendu['acquisition'] as double, 0.001));
});
test('l\'identité des compteurs tient — production + soutiré == conso + injecté', () {
// C'est le seul contrôle disponible sur des compteurs qu'on ne peut pas recouper
// autrement. Il a tenu EXACTEMENT sur le banc : 112,244 des deux côtés.
final b = _depuisFixture();
expect(b.identityHolds, isTrue);
expect(b.productionKwh + b.importKwh,
closeTo(b.consumptionKwh + b.exportKwh, 0.001));
});
test('les taux du jour se déduisent des énergies, pas de l\'instantané', () {
final b = _depuisFixture();
// autoconso = (produit − injecté) / produit ; autonomie = (consommé − soutiré) / consommé
expect(b.selfConsumptionKwh, closeTo(78.310 - 37.809, 0.001));
expect(b.selfConsumptionRate!, closeTo(51.72, 0.1));
expect(b.autonomyRate!, closeTo(54.41, 0.1));
});
test('l\'unité est le kWh — un ordre de grandeur, pas une convention', () {
// Le piège qui a coûté « 78 Wh » affichés pour une journée de 78 kWh. Une maison
// ne produit ni 78 Wh ni 78 MWh dans la journée : l'ordre de grandeur suffit à
// trancher, et ce test le fige.
final b = _depuisFixture();
expect(b.productionKwh, greaterThan(1),
reason: 'des Wh donneraient une valeur mille fois plus petite');
expect(b.productionKwh, lessThan(1000));
});
});
group('ce qui n\'est PAS mesurable ne s\'affiche pas comme zéro', () {
DailyBalance b({
double prod = 10,
double conso = 10,
double imp = 0,
double exp = 0,
DateTime? from,
}) =>
DailyBalance(
from: from ?? DateTime(2026, 8, 27),
to: DateTime(2026, 8, 27, 21, 45),
sampleCount: 88,
productionKwh: prod,
consumptionKwh: conso,
importKwh: imp,
exportKwh: exp,
);
test('sans production, le taux d\'autoconsommation est NUL, pas 0 %', () {
// À 3 h du matin il n'y a rien à autoconsommer. « 0 % » se lirait comme un défaut
// de l'installation ; l'absence se lit comme ce qu'elle est.
expect(b(prod: 0).selfConsumptionRate, isNull);
expect(b(prod: 0).autonomyRate, isNotNull);
});
test('sans consommation, le taux d\'autonomie est NUL', () {
expect(b(conso: 0).autonomyRate, isNull);
});
test('une identité fausse se voit — compteur cassé ou remis à zéro', () {
// 10 produits + 0 soutiré ne peut pas donner 10 consommés + 5 injectés.
expect(b(prod: 10, conso: 10, imp: 0, exp: 5).identityHolds, isFalse);
});
test('la tolérance absorbe l\'arrondi sans absorber une erreur', () {
// 1 % de la production, plancher 0,1 kWh : les compteurs ne s'incrémentent pas au
// même instant, mais un écart réel ne doit pas passer.
expect(b(prod: 100, conso: 100, imp: 0, exp: 0.5).identityHolds, isTrue);
expect(b(prod: 100, conso: 100, imp: 0, exp: 5).identityHolds, isFalse);
});
test('une fenêtre qui commence tard est signalée comme partielle', () {
expect(b(from: DateTime(2026, 8, 27, 0, 10)).startsLate, isFalse,
reason: 'dix minutes, c\'est le pas d\'échantillonnage, pas un trou');
expect(b(from: DateTime(2026, 8, 27, 9, 30)).startsLate, isTrue,
reason: 'la box a démarré à 9 h 30 — le bilan ne couvre pas la journée');
});
});
}