/// 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 _fixture() => jsonDecode( File('test/fixtures/powerbalance_jour_hems75.json').readAsStringSync()) as Map; /// 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>() ..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; 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'); }); }); }