/// 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 _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 = []; 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(ctx, listen: false); final cnx = Provider.of(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(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); }); }