L'entrée 🔴 du TODO. Aucune des quatre tuiles n'était journalière : deux pourcentages instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot « aujourd'hui ». Elles montrent désormais la journée, sous un en-tête qui le dit une fois. MÉTHODE : une DIFFÉRENCE de compteurs entre minuit local et maintenant, jamais une intégration des puissances. La première est une mesure, prise sur les mêmes compteurs que ceux du fournisseur ; la seconde est une estimation qui suppose que chaque échantillon représente son intervalle. Mesuré sur .75 : l'intégration rend 77,70 kWh là où la différence rend 78,31 — 0,8 % d'écart, systématiquement à la baisse. Il n'y a donc PAS de repli silencieux de l'une vers l'autre : quand la mesure n'est pas exploitable, le type le dit. DEUX BUGS D'UNITÉ, trouvés en cherchant à savoir ce que valaient les compteurs. Aucune documentation ne tranchait, donc recoupement sur machine : l'intégration des puissances (en W) rend 77 697 Wh quand la différence de totalProduction rend 78,310. Le rapport est mille — LES COMPTEURS SONT EN kWh, et le modèle Dart les nommait Wh. 1. L'écran Énergie divisait ces kWh par mille pour les afficher : une journée de 78 kWh s'y lisait « 78 Wh », sous un axe intitulé « Bilan énergétique (Wh) ». Invisible sans connaître l'ordre de grandeur attendu. 2. La simulation accumulait des Wh : un écran juste en simulation aurait cassé d'un facteur mille contre une vraie box. LE CONTRÔLE QUI VALIDE LE BILAN : production + soutiré == consommation + injecté. C'est le seul recoupement disponible sur des compteurs qu'on ne peut pas vérifier autrement, et il tient EXACTEMENT sur .75 — 112,244 des deux côtés sur 21,75 h. Faux, il désigne un compteur cassé ou une remise à zéro, et la carte le signale au lieu de présenter comme une mesure ce qui ne se recoupe pas. Les trois pièges du TODO n'ont pas été repayés : bornes à minuit LOCAL via GetPowerBalanceLogs · décodage délégué à fetchPowerBalanceLogs, qui porte déjà les bonnes clés sans préfixe currentPower · plausibilité vérifiée sur les DEUX bornes avant de soustraire, parce que deux valeurs absurdes peuvent avoir une différence d'apparence saine. Plus un quatrième trouvé en chemin : un compteur qui RECULE (redémarrage en milieu de fenêtre) donne un bilan négatif, et un max(0, …) le rendrait faux en silence. Rien ne s'affiche jamais à zéro quand c'est inconnu. Sans production, le taux d'autoconsommation est null et non 0 % — à 3 h du matin, « 0 % » se lirait comme un défaut alors qu'il n'y a rien à autoconsommer. VÉRIFIÉ SUR APPAREIL contre .75, en lecture seule (le banc est en préparation V2C, rien n'a été écrit) : 32 échantillons depuis minuit, prod 28,37 · conso 26,55 · import 11,95 · export 13,77 kWh, identité OK, et les tuiles affichent 51 % / 55 % / 13,8 / 11,9. Le passage sur appareil a trouvé ce qu'aucun des 138 tests unitaires ne pouvait voir : LES QUATRE LIBELLÉS DÉBORDAIENT — « Autoconsomm… », « Vers réseau · aujou… ». Le suffixe « aujourd'hui » répété quatre fois mangeait le nom de la grandeur ; il est dit une fois en en-tête. Le harnais d'appareil échoue désormais sur tout libellé finissant par « … ». Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
135 lines
5.8 KiB
Dart
135 lines
5.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),
|
||
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,
|
||
}) =>
|
||
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');
|
||
});
|
||
});
|
||
}
|