etm-powersync-app/test/daily_balance_test.dart
Patrick Schurig a14adcfcc3 fix(bilan): un cumul ABSENT n'est pas un cumul nul
`double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0` : un champ manquant
devenait 0,0 au décodage, deux bornes à zéro donnaient une différence à zéro, et
l'écran affichait « 0,0 kWh · Vers réseau » comme une mesure. C'est le zéro forgé
que nous traquons depuis une semaine, cette fois de NOTRE côté.

Le cas est réel et n'attend pas sa cause : un compteur racine parfaitement en
place, la puissance instantanée qui arrive, et les cumuls qui n'arrivent pas.

`PowerBalanceEntry.totalsPresent` porte la distinction, et `fetchDailyBalance`
refuse de présenter comme mesure un bilan bâti là-dessus — au même titre qu'un
compteur corrompu. « Rien échangé aujourd'hui » et « la box ne publie pas ses
cumuls » sont deux états, et le second n'a pas le droit de porter le premier.
Le repli à 0 reste, pour ne pas propager des nullables dans tout l'écran Énergie,
mais il est désormais ACCOMPAGNÉ du fait qu'il est un repli.

Et `abs()` sur la production devient un choix DOCUMENTÉ plutôt qu'un correctif
tacite : la production n'est pas signée, c'est le compteur qui l'est, donc la
valeur absolue masquerait un compteur câblé à l'envers dont le signe serait la
seule trace. Le correctif appartient à la source. On la garde en connaissance de
cause — un signe faux se voit tous les jours, un câblage inversé ne se voit
jamais — et on ne revient pas en arrière sans savoir ce que la box publiera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 12:41:10 +02:00

184 lines
8.1 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:etm_powersync_app/models/energy_data.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),
periodStart:
DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int) * 1000),
sampleCount: e.length,
productionKwh: d('totalProduction'),
consumptionKwh: d('totalConsumption'),
importKwh: d('totalAcquisition'),
exportKwh: d('totalReturn'),
);
}
void main() {
_cumulsAbsents();
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,
DateTime? periodStart,
}) =>
DailyBalance(
from: from ?? DateTime(2026, 8, 27),
to: DateTime(2026, 8, 27, 21, 45),
// La frontière de la BOX, pas minuit du téléphone : sur `.75` en `Europe/London`
// vue d'un appareil français, la journée commence à 01:00 local.
periodStart: periodStart ?? DateTime(2026, 8, 27, 1),
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', () {
// Le retard se mesure contre le début de période PUBLIÉ (01:00 ici), jamais contre
// minuit du téléphone.
expect(b(from: DateTime(2026, 8, 27, 1, 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');
});
test('la journée de la BOX ne se lit pas « partielle » — le piège du fuseau', () {
// Sur `.75` (Europe/London) vue d'un téléphone français, la journée commence à 01:00
// local. Comparer à minuit du téléphone ferait crier « partiel » TOUS LES JOURS sur
// un bilan complet — et un avertissement permanent cesse d'être lu.
final complet = b(
periodStart: DateTime(2026, 8, 27, 1), from: DateTime(2026, 8, 27, 1));
expect(complet.startsLate, isFalse);
});
});
}
/// **Un cumul ABSENT n'est pas un cumul nul** — la distinction tient côté écran, quelle que
/// soit la raison de l'absence.
///
/// Le cas est réel : un compteur racine parfaitement en place, la puissance instantanée qui
/// arrive, et les cumuls qui n'arrivent pas. Décodés à 0, ils produisent un bilan de zéros
/// que l'écran présenterait comme une mesure — « 0,0 kWh · Vers réseau » sur une journée
/// dont on ne sait rien.
void _cumulsAbsents() {
group('cumuls absents : « non mesurable », jamais « 0 kWh »', () {
test('le drapeau distingue le repli du vrai zéro', () {
// Décodage d'une entrée SANS les quatre cumuls : les valeurs retombent à 0…
final sansCumuls = PowerBalanceEntry(timestamp: DateTime(2026, 9, 6));
expect(sansCumuls.totalsPresent, isFalse,
reason: '…mais l\'écran doit pouvoir savoir que c\'était un repli');
});
test('une entrée complète se déclare présente', () {
final avec = PowerBalanceEntry(
timestamp: DateTime(2026, 9, 6),
totalsPresent: true,
totalProductionKwh: 59.89,
totalConsumptionKwh: 57.31,
totalAcquisitionKwh: 26.31,
totalReturnKwh: 28.89,
);
expect(avec.totalsPresent, isTrue);
});
});
}