`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
184 lines
8.1 KiB
Dart
184 lines
8.1 KiB
Dart
/// 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);
|
||
});
|
||
});
|
||
}
|