feat(saisie): l'horloge se qualifie, elle ne s'offre pas en réglage
« 12:00 heure de la box (Europe/London) — pas celle de cet appareil », et non « Fuseau : Europe/Paris » : sous un champ, un intitulé de réglage laisse croire qu'on choisit le fuseau DE CETTE échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les heures murales de l'installation — échéances, frontière de la journée de reporting, et demain les plages tarifaires (LM-1005-b-iii). Affichage CONDITIONNEL : seulement quand les deux horloges divergent. C'est la règle déjà appliquée deux fois cette semaine — ligne des ratios instantanés, avertissement « partiel » du bilan — et sa raison tient en une phrase : un texte permanent cesse d'être lu le jour exact où il devient vrai. La divergence se DÉDUIT de ce que la box publie, sans base de fuseaux : `periodStartedAt` est minuit de l'installation en epoch, donc rendu dans le fuseau de l'appareil il tombe sur 00:00 si et seulement si les deux partagent le même décalage. Exact pour l'instant présent, y compris les jours à 23 ou 25 heures — et c'est justement pourquoi nous avions demandé cette frontière plutôt qu'un nom IANA. Quatre silences testés : fuseaux identiques, échéance vide, box qui ne publie pas la date, et l'intitulé « Fuseau : » qui ne doit jamais apparaître. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
parent
49e92b6b11
commit
71bb26c81b
@ -224,10 +224,10 @@
|
||||
"obligationSousTitre": "Le plancher que la box servira quoi qu'il arrive, au réseau s'il le faut.",
|
||||
"obligationEnergie": "Énergie due par jour",
|
||||
"obligationEcheance": "Avant",
|
||||
"obligationEcheanceAide": "Heure de l'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.",
|
||||
"@obligationEcheanceAide": {
|
||||
"description": "dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.",
|
||||
"placeholders": { "fuseau": { "type": "String" } }
|
||||
"obligationEcheanceHorloge": "{heure} heure de la box ({fuseau}) — pas celle de cet appareil.",
|
||||
"@obligationEcheanceHorloge": {
|
||||
"description": "QUALIFIE UNE HORLOGE, N'OFFRE PAS UN CHOIX. « Fuseau : Europe/Paris » sous un champ laisserait croire qu'on règle le fuseau DE CETTE ÉCHÉANCE : il n'y en a qu'un, c'est un réglage système, et il déplace d'un coup toutes les heures murales de l'installation — échéances, frontière de journée de reporting, et demain les plages tarifaires (LM-1005-b-iii). D'où une phrase qui dit l'heure lue, pas un réglage offert.\n\nAFFICHAGE CONDITIONNEL : seulement quand le fuseau de la box diffère de celui de l'appareil. Sinon c'est un texte permanent — et un avertissement permanent cesse d'être lu le jour exact où il devient vrai.",
|
||||
"placeholders": { "heure": { "type": "String" }, "fuseau": { "type": "String" } }
|
||||
},
|
||||
"obligationNonMesurableTitre": "Cette obligation ne pourra jamais être close",
|
||||
"@obligationNonMesurableTitre": {
|
||||
|
||||
@ -601,11 +601,13 @@ abstract class L10n {
|
||||
/// **'Avant'**
|
||||
String get obligationEcheance;
|
||||
|
||||
/// dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.
|
||||
/// QUALIFIE UNE HORLOGE, N'OFFRE PAS UN CHOIX. « Fuseau : Europe/Paris » sous un champ laisserait croire qu'on règle le fuseau DE CETTE ÉCHÉANCE : il n'y en a qu'un, c'est un réglage système, et il déplace d'un coup toutes les heures murales de l'installation — échéances, frontière de journée de reporting, et demain les plages tarifaires (LM-1005-b-iii). D'où une phrase qui dit l'heure lue, pas un réglage offert.
|
||||
///
|
||||
/// AFFICHAGE CONDITIONNEL : seulement quand le fuseau de la box diffère de celui de l'appareil. Sinon c'est un texte permanent — et un avertissement permanent cesse d'être lu le jour exact où il devient vrai.
|
||||
///
|
||||
/// In fr, this message translates to:
|
||||
/// **'Heure de l\'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.'**
|
||||
String obligationEcheanceAide(String fuseau);
|
||||
/// **'{heure} heure de la box ({fuseau}) — pas celle de cet appareil.'**
|
||||
String obligationEcheanceHorloge(String heure, String fuseau);
|
||||
|
||||
/// Sous unmeasurable : ni tenue (R6 interdit ECO_FLOOR_MET), ni chiffrée dans son manque (missingWh absent). ECO_FLOOR_MISSED sortira tous les jours par construction.
|
||||
///
|
||||
|
||||
@ -349,8 +349,8 @@ class L10nFr extends L10n {
|
||||
String get obligationEcheance => 'Avant';
|
||||
|
||||
@override
|
||||
String obligationEcheanceAide(String fuseau) {
|
||||
return 'Heure de l\'INSTALLATION$fuseau — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.';
|
||||
String obligationEcheanceHorloge(String heure, String fuseau) {
|
||||
return '$heure heure de la box ($fuseau) — pas celle de cet appareil.';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -82,6 +82,24 @@ class EnergyRatiosSnapshot {
|
||||
return depuis.difference(debut) > const Duration(minutes: 1);
|
||||
}
|
||||
|
||||
/// **Les deux horloges divergent-elles ?** — sans base de données de fuseaux.
|
||||
///
|
||||
/// [periodStartedAt] est **minuit de l'installation**, publié en epoch. Rendu dans le
|
||||
/// fuseau de l'appareil, il tombe donc sur 00:00 **si et seulement si** les deux partagent
|
||||
/// le même décalage. Sur `.75` en `Europe/London` vu d'un téléphone français, il tombait
|
||||
/// sur 01:00 ; les deux étant passés en `Europe/Paris`, il tombe sur 00:00.
|
||||
///
|
||||
/// C'est exact pour l'instant présent, y compris les jours de changement d'heure, et ça
|
||||
/// n'exige **rien d'autre que ce que la box publie** — le nom IANA ne donnerait pas de
|
||||
/// décalage, et une base embarquée pour le recalculer serait le mauvais geste.
|
||||
///
|
||||
/// `false` quand la date manque : on ne conclut pas d'une absence.
|
||||
bool get fuseauDiffereDeLAppareil {
|
||||
final d = periodStartedAt?.toLocal();
|
||||
if (d == null) return false;
|
||||
return d.hour != 0 || d.minute != 0;
|
||||
}
|
||||
|
||||
/// Vrai quand la box publie de quoi dater la journée. Sans ça, l'app ne **peut pas**
|
||||
/// calculer la bonne fenêtre — et elle le dit plutôt que de prendre minuit du téléphone
|
||||
/// en silence.
|
||||
|
||||
@ -10,6 +10,7 @@ import '../../providers/load_config_provider.dart';
|
||||
import '../../providers/load_telemetry_provider.dart';
|
||||
import '../../providers/schema_provider.dart';
|
||||
import '../../services/load_hardware.dart';
|
||||
import '../../models/energy_ratios_snapshot.dart';
|
||||
import '../../services/telemetry_text.dart' show energieCourte;
|
||||
import '../../services/load_mechanism_switch.dart';
|
||||
import '../../services/load_priority.dart';
|
||||
@ -2187,12 +2188,37 @@ class _Plancher extends StatelessWidget {
|
||||
/// Deux des trois causes se réparent par l'installateur — rattacher un compteur, en mettre un
|
||||
/// qui totalise l'énergie. La troisième est le matériel, et le dire évite de chercher un
|
||||
/// réglage qui n'existe pas. C'est pour ça que la cause est affichée, jamais le seul verdict.
|
||||
class _SectionObligation extends StatelessWidget {
|
||||
class _SectionObligation extends StatefulWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
|
||||
const _SectionObligation({required this.entry, required this.provider});
|
||||
|
||||
@override
|
||||
State<_SectionObligation> createState() => _SectionObligationState();
|
||||
}
|
||||
|
||||
class _SectionObligationState extends State<_SectionObligation> {
|
||||
/// Le fuseau ne sert qu'à **qualifier une heure**, jamais à en proposer un réglage : il
|
||||
/// est système, et il déplace d'un coup toutes les heures murales de l'installation.
|
||||
/// Lu une fois, sans bloquer l'écran — absent, la ligne ne s'affiche simplement pas.
|
||||
EnergyRatiosSnapshot? _ratios;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final svc = context.read<NymeaService>();
|
||||
_ratios = svc.energyRatios;
|
||||
if (_ratios == null) {
|
||||
svc.fetchEnergyRatios().then((r) {
|
||||
if (mounted && r != null) setState(() => _ratios = r);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
LoadConfigEntry get entry => widget.entry;
|
||||
LoadConfigProvider get provider => widget.provider;
|
||||
|
||||
/// Le coût quotidien, **chiffré et non estimé** : le taux est constant depuis `+etm55`
|
||||
/// (cible ÷ jour, LM-1013-b-v), donc le total acheté vaut exactement la cible.
|
||||
String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble());
|
||||
@ -2246,13 +2272,24 @@ class _SectionObligation extends StatelessWidget {
|
||||
'minEnergyWhPerDay': entry.minEnergyWhPerDay,
|
||||
})),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// L'heure saisie est celle de la BOX, et la phrase le dit sans le fuseau — elle
|
||||
// reste vraie sans lui. Le NOM du fuseau est publié par `GetEnergyRatios`
|
||||
// (`timeZone`, vérifié en `+etm56` : `Europe/London`) ; il s'affichera ici quand le
|
||||
// lot « bilan journalier » aura branché cet appel, et pas avant : nommer un fuseau
|
||||
// qu'on n'a pas lu serait exactement la faute que cette phrase existe pour éviter.
|
||||
_Aide(t.obligationEcheanceAide('')),
|
||||
// ── L'HORLOGE, et seulement quand elle diverge ────────────────────────
|
||||
//
|
||||
// La phrase **qualifie une heure**, elle n'offre pas un choix : « Fuseau :
|
||||
// Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de CETTE
|
||||
// échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les
|
||||
// heures murales de l'installation — échéances, frontière de la journée de
|
||||
// reporting, et demain les plages tarifaires (LM-1005-b-iii).
|
||||
//
|
||||
// Conditionnel, et c'est la règle déjà appliquée deux fois cette semaine — ligne
|
||||
// des ratios instantanés, avertissement « partiel » du bilan : un texte permanent
|
||||
// cesse d'être lu le jour exact où il devient vrai.
|
||||
if (entry.dailyDeadline.isNotEmpty &&
|
||||
(_ratios?.fuseauDiffereDeLAppareil ?? false) &&
|
||||
_ratios?.timeZone != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_Aide(t.obligationEcheanceHorloge(
|
||||
entry.dailyDeadline, _ratios!.timeZone!)),
|
||||
],
|
||||
if (avertir) ...[
|
||||
const SizedBox(height: 12),
|
||||
_Aide(
|
||||
|
||||
@ -965,12 +965,22 @@ class NymeaService extends ChangeNotifier {
|
||||
final r = await _sendRequest('NymeaEnergy.GetEnergyRatios', {});
|
||||
final p = r['params'];
|
||||
if (p is! Map) return null;
|
||||
return EnergyRatiosSnapshot.fromJson(Map<String, dynamic>.from(p));
|
||||
return _ratios = EnergyRatiosSnapshot.fromJson(Map<String, dynamic>.from(p));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
EnergyRatiosSnapshot? _ratios;
|
||||
|
||||
/// Le dernier instantané lu, ou `null` tant que rien ne l'a demandé. Les écrans de
|
||||
/// configuration s'en servent pour **qualifier une heure**, pas pour afficher un taux.
|
||||
EnergyRatiosSnapshot? get energyRatios => _ratios;
|
||||
|
||||
/// Injecte un instantané — **tests uniquement**, même idiome que `adoptForTest`.
|
||||
// ignore: use_setters_to_change_properties
|
||||
void adoptRatiosForTest(EnergyRatiosSnapshot r) => _ratios = r;
|
||||
|
||||
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/l10n/app_localizations.dart';
|
||||
import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart';
|
||||
import 'package:etm_powersync_app/providers/load_config_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/schema_provider.dart';
|
||||
@ -47,7 +48,9 @@ List<Map<String, dynamic>> _charge({
|
||||
}
|
||||
];
|
||||
|
||||
Widget _harness(List<Map<String, dynamic>> config) => MultiProvider(
|
||||
Widget _harness(List<Map<String, dynamic>> config,
|
||||
{EnergyRatiosSnapshot? ratios}) =>
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(
|
||||
value: LoadConfigProvider()..adoptForTest(config)),
|
||||
@ -55,7 +58,10 @@ Widget _harness(List<Map<String, dynamic>> config) => MultiProvider(
|
||||
value: LoadTelemetryProvider()
|
||||
..adoptForTest({'degradedMode': false, 'loads': const []})),
|
||||
ChangeNotifierProvider.value(value: SchemaProvider()),
|
||||
ChangeNotifierProvider.value(value: NymeaService()),
|
||||
ChangeNotifierProvider.value(
|
||||
value: ratios == null
|
||||
? NymeaService()
|
||||
: (NymeaService()..adoptRatiosForTest(ratios))),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('fr'),
|
||||
@ -138,12 +144,52 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('l\'échéance est à l\'heure de l\'installation', () {
|
||||
testWidgets('et l\'écran le dit, plutôt que de choisir une horloge',
|
||||
group('l\'horloge se QUALIFIE, elle ne s\'offre pas en réglage', () {
|
||||
// « Fuseau : Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de
|
||||
// CETTE échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les
|
||||
// heures murales de l'installation — d'où une phrase qui dit l'heure lue.
|
||||
EnergyRatiosSnapshot _instantane({required bool divergent}) =>
|
||||
EnergyRatiosSnapshot.fromJson({
|
||||
'autonomyRate': 54.0,
|
||||
'selfConsumptionRate': 52.0,
|
||||
// Minuit de l'installation, en epoch. Rendu dans le fuseau de l'appareil, il tombe
|
||||
// sur 00:00 si et seulement si les deux partagent le même décalage.
|
||||
'periodStartedAt':
|
||||
DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000 +
|
||||
(divergent ? 3600 : 0),
|
||||
'measuredSince': DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000,
|
||||
'timeZone': divergent ? 'Europe/London' : 'Europe/Paris',
|
||||
});
|
||||
|
||||
Widget harnessAvec(EnergyRatiosSnapshot? r,
|
||||
{List<Map<String, dynamic>>? config}) =>
|
||||
_harness(config ?? _charge(), ratios: r);
|
||||
|
||||
testWidgets('fuseaux divergents : la phrase dit l\'heure ET l\'horloge',
|
||||
(tester) async {
|
||||
await _poser(tester, _harness(_charge()));
|
||||
expect(find.textContaining('Heure de l\'INSTALLATION'), findsOneWidget);
|
||||
await _poser(tester, harnessAvec(_instantane(divergent: true)));
|
||||
expect(find.textContaining('12:00 heure de la box (Europe/London)'),
|
||||
findsOneWidget);
|
||||
expect(find.textContaining('pas celle de cet appareil'), findsOneWidget);
|
||||
// Ce qu'on n'écrit surtout pas : un intitulé qui ressemble à un réglage.
|
||||
expect(find.textContaining('Fuseau :'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('fuseaux identiques : RIEN — un texte permanent cesse d\'être lu',
|
||||
(tester) async {
|
||||
await _poser(tester, harnessAvec(_instantane(divergent: false)));
|
||||
expect(find.textContaining('heure de la box'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('sans échéance saisie, rien à qualifier', (tester) async {
|
||||
await _poser(tester,
|
||||
harnessAvec(_instantane(divergent: true), config: _charge(echeance: '')));
|
||||
expect(find.textContaining('heure de la box'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('box qui ne publie pas la date : on se tait', (tester) async {
|
||||
await _poser(tester, harnessAvec(null));
|
||||
expect(find.textContaining('heure de la box'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user