etm-powersync-app/test/daily_balance_test.dart
Patrick Schurig bcf5b87e43 feat(bilan): la journée vient de la BOX — periodStartedAt, et non minuit du téléphone
Sondé avant de câbler (`+etm56`) : `periodStartedAt` = 1788562800 = 00:00 Londres
= 01:00 Paris, `measuredSince` = 07:04 Londres, `timeZone` = Europe/London. Sept
heures d'écart entre les deux bornes.

- `EnergyRatiosSnapshot` — les deux instants et le fuseau. `baselineRepriseEnJournee`
  dit quand les ratios de la box ne parlent que depuis un redémarrage : c'est le
  silence que `measuredSince` existe pour fermer, et quand les deux coïncident —
  le cas normal — il n'y a rien à dire de plus ;
- `fetchEnergyRatios()`, et `fetchDailyBalance()` qui prend désormais la frontière
  publiée. Repli sur minuit local pour une box antérieure, écrit comme tel.

Et un effet de bord attrapé avant qu'il ne parte : `startsLate` comparait à minuit
du TÉLÉPHONE. Avec la frontière de la box, il aurait crié « partiel » tous les
jours sur un bilan complet — un avertissement permanent cesse d'être lu. Il se
compare maintenant à `periodStart`, porté par le bilan, et un test fige le piège.

Les taux journaliers de `DailyBalance` sont donc corrigés du même geste : ils se
calculent déjà par différence de compteurs sur la fenêtre, c'était la fenêtre qui
était fausse.

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

152 lines
6.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),
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() {
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);
});
});
}