etm-powersync-app/integration_test/daily_balance_on_device_test.dart
Patrick Schurig 23a84fec43 feat(dashboard): le bilan du JOUR — et deux bugs d'unité trouvés en le posant
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
2026-08-28 07:57:57 +02:00

179 lines
7.9 KiB
Dart

/// Le **bilan du jour**, sur l'appareil, contre les vrais compteurs de `.75`.
///
/// ## Pourquoi il faut le voir sur appareil
///
/// `test/daily_balance_test.dart` prouve le calcul sur une fixture. Il ne prouve ni que
/// les bornes de minuit local sont les bonnes une fois dans le fuseau du téléphone, ni que
/// le décodage des clés de log tient bout à bout, ni que les tuiles se remplissent après
/// le premier rendu — le bilan arrive **après** la première image, il faut un aller-retour.
///
/// ## LECTURE SEULE — le banc est en préparation
///
/// La V2C arrive, `.75` doit rester dans l'état où la prépa l'a laissé. Ce test n'écrit
/// **rien** : ni `SetLoadConfig`, ni `SetChargingInfo`, ni réglage. Il lit des logs et
/// regarde un écran.
///
/// Lancement :
/// ```sh
/// flutter test integration_test/daily_balance_on_device_test.dart -d dee659e2
/// ```
library;
import 'package:etm_powersync_app/main.dart' as app;
import 'package:etm_powersync_app/models/daily_balance.dart';
import 'package:etm_powersync_app/services/connection_manager.dart';
import 'package:etm_powersync_app/screens/dashboard_screen.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:integration_test/integration_test.dart';
import 'package:provider/provider.dart';
const _hote = '192.168.1.75';
Future<void> _attendre(
WidgetTester tester,
bool Function() cond, {
Duration limite = const Duration(seconds: 30),
String quoi = 'condition',
}) async {
final fin = DateTime.now().add(limite);
while (DateTime.now().isBefore(fin)) {
await tester.pump(const Duration(milliseconds: 250));
if (cond()) return;
}
fail('délai dépassé en attendant : $quoi');
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('le bilan du jour, sur appareil, contre .75', (tester) async {
final debordements = <String>[];
final precedent = FlutterError.onError;
FlutterError.onError = (details) {
final t = details.exceptionAsString();
if (t.contains('overflowed by')) {
final l = t.split('\n').first;
if (!debordements.contains(l)) debordements.add(l);
return;
}
precedent?.call(details);
};
addTearDown(() => FlutterError.onError = precedent);
app.main();
await tester.pump(const Duration(seconds: 3));
final ctx = tester.element(find.byType(MaterialApp).first);
final svc = Provider.of<NymeaService>(ctx, listen: false);
final cnx = Provider.of<ConnectionManager>(ctx, listen: false);
if (!svc.isConnected) {
// ignore: unawaited_futures
cnx.connectNew(host: _hote);
}
await _attendre(tester, () => svc.isConnected,
limite: const Duration(seconds: 60), quoi: 'la connexion à $_hote');
debugPrint('[appareil] connecté à $_hote');
// ── 1. Le bilan tel que le service le lit ────────────────────────────────
final DailyBalance? b = await svc.fetchDailyBalance();
expect(b, isNotNull,
reason: '.75 publie des logs depuis minuit — la journée doit être mesurable');
debugPrint('[appareil] fenêtre ${b!.from} → ${b.to} · ${b.sampleCount} échantillons');
debugPrint('[appareil] prod ${b.productionKwh.toStringAsFixed(2)} · '
'conso ${b.consumptionKwh.toStringAsFixed(2)} · '
'import ${b.importKwh.toStringAsFixed(2)} · '
'export ${b.exportKwh.toStringAsFixed(2)} kWh');
debugPrint('[appareil] autoconso ${b.selfConsumptionRate?.toStringAsFixed(1)} % · '
'autonomie ${b.autonomyRate?.toStringAsFixed(1)} % · '
'identité ${b.identityHolds ? "OK" : "FAUSSE"} · '
'partielle : ${b.startsLate}');
// ── 2. Les bornes sont bien celles du JOUR, dans le fuseau du téléphone ──
final maintenant = DateTime.now();
final minuit = DateTime(maintenant.year, maintenant.month, maintenant.day);
expect(b.from.isBefore(minuit), isFalse,
reason: 'la fenêtre ne doit pas déborder sur la veille — minuit LOCAL');
expect(b.to.isAfter(minuit), isTrue);
expect(b.sampleCount, greaterThanOrEqualTo(2));
// ── 3. L'ordre de grandeur tranche l'unité, bout à bout ─────────────────
//
// C'est le piège qui a fait afficher « 78 Wh » pour une journée de 78 kWh. Une maison
// ne produit ni des dixièmes de kWh ni des mégawattheures dans la journée.
expect(b.productionKwh, greaterThan(0.5),
reason: 'des Wh donneraient une valeur mille fois trop petite');
expect(b.productionKwh, lessThan(500));
// ── 4. L'identité des compteurs, sur la vraie machine ───────────────────
expect(b.identityHolds, isTrue,
reason: 'production + soutiré doit égaler consommation + injecté : '
'${b.productionKwh + b.importKwh} vs ${b.consumptionKwh + b.exportKwh}');
// ── 5. Aller AU tableau de bord ─────────────────────────────────────────
//
// Se connecter ne navigue pas : l'app reste sur l'écran Installations, et chercher les
// tuiles là-bas ne prouve que leur absence. Le contexte du `MaterialApp` est au-dessus
// du Router — on prend celui d'un Scaffold affiché.
GoRouter.of(tester.element(find.byType(Scaffold).first)).go('/');
await tester.pump(const Duration(seconds: 3));
await _attendre(
tester,
() => find.byType(DashboardScreen).evaluate().isNotEmpty,
quoi: 'le montage du tableau de bord',
);
debugPrint('[appareil] tableau de bord monté');
// ── 6. Les tuiles se remplissent APRÈS le premier rendu ─────────────────
//
// Le tableau de bord lit le bilan dans un post-frame callback : la première image
// porte « — ». Si l'anneau ne se réarmait pas, il resterait vide pour toujours.
await _attendre(
tester,
() => find.text('Autoconsommation').evaluate().isNotEmpty,
quoi: 'les tuiles du jour',
);
expect(find.text('Aujourd\'hui'), findsOneWidget,
reason: 'la période est dite UNE fois, en tête des quatre tuiles');
expect(find.text('Vers réseau'), findsOneWidget);
expect(find.text('Depuis réseau'), findsOneWidget);
expect(find.textContaining('cumulé').evaluate(), isEmpty,
reason: 'plus aucune tuile ne montre un compteur depuis l\'origine');
// Les libellés ne doivent pas être coupés au milieu d'un mot — « Autoconsomm… » est
// ce que la capture d'appareil a montré au premier passage, et aucun test unitaire ne
// pouvait le voir : ils ne rendent pas ces widgets à la largeur d'un vrai écran.
for (final t in tester.widgetList<Text>(find.byType(Text))) {
final d = t.data;
if (d != null && d.endsWith('…')) {
fail('libellé tronqué à l\'écran : « $d »');
}
}
// Laisser le temps au bilan d'arriver et à l'anneau de s'animer, puis regarder.
for (var i = 0; i < 60; i++) {
await tester.pump(const Duration(milliseconds: 250));
}
expect(find.text('— %').evaluate(), isEmpty,
reason: 'les anneaux doivent porter une valeur une fois le bilan lu');
debugPrint('[appareil] ÉCRAN « tableau de bord · bilan du jour » — maintenu 15 s');
for (var i = 0; i < 60; i++) {
await tester.pump(const Duration(milliseconds: 250));
}
if (debordements.isNotEmpty) {
debugPrint('[appareil] DÉBORDEMENTS (${debordements.length}) :');
for (final d in debordements) {
debugPrint('[appareil] • $d');
}
} else {
debugPrint('[appareil] aucun débordement de mise en page');
}
expect(debordements, isEmpty);
});
}