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
152 lines
6.8 KiB
Dart
152 lines
6.8 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: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);
|
||
});
|
||
});
|
||
}
|