diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index c8875a6..f8dfab4 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -1204,3 +1204,71 @@ l'absence de mesure ont la même apparence. d'observation pendant laquelle un second plan existe. Le seul relevé exploitable à ce jour reste celui de 08:08 — deux plans, même `issuedAt`, même `t0`, somme correcte — et il a été obtenu par le chemin **forcé**, pas par la boucle horaire naturelle. + + +--- + +## 26. `GetForecastSnapshot` — la table ou la décision, jamais les deux + +**Deux méthodes, deux usages, et la phrase qui les sépare tient au contrat :** + +> **`GetPvForecast` sert la TABLE. `GetForecastSnapshot` sert la DÉCISION.** +> Un arbitre qui lit la table lirait **sa propre trace au lieu de son entrée** : il ne mesure +> plus, il se relit. + +Le snapshot lit **le canal**, directement, sans persistance. Écrit maintenant parce que la +distinction est nette maintenant — dans six semaines, le premier consommateur prendrait +`GetPvForecast` sans y penser, et le piège se serait refermé sur lui. + +### 26.1 Les deux absences, tranchées + +| état | réponse | +|---|---| +| aucun candidat, aucun site | **silence** — ni courbe, ni motif | +| site désignable mais non désigné | `fault: MISSING_FORECAST_SITE` + `candidates` | +| charge utile **refusée** (version, unité, forme) | `fault: FORECAST_PAYLOAD_REFUSED` + `cause` | +| canal muet | ni courbe, ni motif | +| plans de runs différents | ni courbe, ni motif — on ne somme pas un mélange | + +**Dans tous les cas, `forecastPoints` est ABSENT — jamais une liste de zéros.** *Une prévision +manquante et une prévision nulle sont deux affirmations différentes*, et c'est le motif qui les +sépare. + +Et **deux motifs, deux gestes** : configurer d'un côté, **regarder le producteur** de l'autre. +Les confondre enverrait chercher un réglage là où c'est le plugin météo qui parle mal. + +### 26.2 Dater le CONTENU, pas seulement la lecture + +`capturedAt` est **toujours** rendu : il date la **réponse**, et distingue « le moteur n'a pas +répondu » de « le moteur a répondu, et il n'a rien ». + +Mais il ne suffit pas. **Un producteur FIGÉ se lirait comme une prévision stable** — l'instrument +dont le silence ressemble à un résultat, sous sa forme **la plus coûteuse** : celui qui le lit +décide d'**acheter**. + +D'où `contentChangedAt` : **quand le contenu courant est apparu**. Il vient du magasin — non pas +comme entrée, mais parce qu'il **survit au redémarrage**. Le garder en mémoire l'aurait remis à +zéro à chaque relance, c'est-à-dire qu'un producteur figé aurait **paru frais après le premier +redémarrage venu** — la protection annulée par le geste le plus banal. + +Il est **omis** quand le magasin n'a jamais vu ce contenu, plutôt que deviné. + +### 26.3 Un mécanisme entier était exclu du banc d'essai par une accolade + +En écrivant le test de frontière, `arb->forecastStore()` s'est révélé **nul**. Le magasin était +créé à l'intérieur d'un `#ifndef ENERGY_SIMULATION` — par **accident de placement**, ajouté à la +suite du battement de télémétrie qui, lui, est bien un déclencheur d'horloge murale. + +**Conséquence : toute la prévision était absente du binaire de test.** Aucune suite ne pouvait +l'exercer à travers l'arbitre, et le balayage des getters voyait `GetPvForecast` répondre +correctement… **par sa branche « magasin absent »**. + +> **Un mécanisme entier hors du banc d'essai, et tous les voyants au vert.** Le test qui l'a +> trouvé ne le cherchait pas : il voulait vérifier une frontière, et il a buté sur l'absence de +> ce qu'il devait franchir. + +Corrigé : le magasin est créé **hors garde** — il n'est pas un déclencheur d'horloge murale et +rien ne justifiait qu'il y soit. **Sa purge, si**, et elle y reste. + +*(La suite de simulation passe de 69 à 71 tests, et deux d'entre eux exercent enfin du code qui +tournait en production sans jamais être exécuté ici.)* diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index dacd4d5..cb3afef 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -53,6 +53,23 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, // et reste testable par injection (symétrique de simulationCallUpdate). Seuls les // DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en // simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130). + // L'HISTORIQUE DES PRÉVISIONS PV — HORS de la garde de simulation, et c'est un correctif. + // + // Il y était, par accident de placement : ajouté à la suite du battement de télémétrie, il + // s'est retrouvé sous `#ifndef ENERGY_SIMULATION`. Conséquence — TOUTE la prévision était + // absente du binaire de test, et aucune suite ne pouvait l'exercer à travers l'arbitre. Le + // balayage des getters voyait `GetPvForecast` répondre correctement… par sa branche + // « magasin absent ». Un mécanisme entier exclu du banc d'essai par une accolade. + // + // Le magasin n'est PAS un déclencheur d'horloge murale : rien ne justifiait qu'il y soit. + // Sa purge, si — elle reste dans la garde ci-dessous. + m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); + { + EnergySettings reglages; + m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString(); + m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen")); + } + #ifndef ENERGY_SIMULATION m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat) // Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4). @@ -73,17 +90,10 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat); m_telemetryHeartbeat->start(); - // L'historique des prévisions PV. Créé ici parce que sa DURÉE DE VIE est celle de l'arbitre - // — pas parce qu'il décide : il n'entre dans aucun plan, ne pondère aucune échéance. - // La purge tourne une fois par jour ; une table qui ne dit pas quand elle purge finit par - // le décider toute seule un jour de disque plein. - m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); - { - EnergySettings reglages; - m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString(); - m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen")); - } - if (m_forecastStore->isValid()) { + if (m_forecastStore && m_forecastStore->isValid()) { + // La PURGE reste sous la garde : c'est un déclencheur d'horloge murale, comme le + // watchdog et le battement. Le MAGASIN, lui, n'en est pas un — il est créé plus bas, + // hors garde. m_forecastPurge = new QTimer(this); m_forecastPurge->setInterval(24 * 3600 * 1000); connect(m_forecastPurge, &QTimer::timeout, this, @@ -2319,6 +2329,74 @@ bool EnergyArbitrator::setForecastSiteId(const QString &siteId) return true; } +ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap &faultParams) const +{ + fault.clear(); + faultParams.clear(); + ForecastRevision vide; + if (!m_tm) + return vide; + + // AUCUN SITE DÉSIGNÉ — même règle que MISSING_FORECAST_SITE, et pour la même raison : le + // motif ne sort que s'il y a un GESTE possible. Sans candidat, l'absence de désignation + // n'est pas un manque, c'est une installation sans prévision. + if (m_forecastSiteId.isEmpty()) { + if (!forecastSiteCandidates().isEmpty()) { + fault = QString::fromLatin1(DecisionCode::MissingForecastSite); + faultParams.insert(QStringLiteral("candidates"), forecastSiteCandidates().count()); + } + return vide; + } + + // LA COURBE VIENT DU CANAL, pas de la table. C'est toute la frontière. + QList plans; + QString refus; + for (Thing *thing : m_tm->configuredThings()) { + if (thing->parentId().toString() != m_forecastSiteId + || !thing->thingClass().hasStateType("forecastCurveTransport")) + continue; + const QVariantMap charge = thing->stateValue("forecastCurveTransport").toMap(); + if (charge.isEmpty()) + continue; // rien publié : une ABSENCE, pas une erreur + QString r; + const ForecastRevision rev = ForecastStore::depuisTransport(charge, r); + if (!r.isEmpty()) { refus = r; continue; } + plans.append(rev); + } + + // UNE CHARGE UTILE REFUSÉE N'EST PAS UNE PRÉVISION NULLE, et le motif existe pour que + // l'écran ne puisse pas les confondre. Un plan refusé interdit aussi la somme : additionner + // le reste publierait une partie du toit sous le nom du tout. + if (!refus.isEmpty()) { + fault = QString::fromLatin1(DecisionCode::ForecastPayloadRefused); + faultParams.insert(QStringLiteral("cause"), refus); + return vide; + } + if (plans.isEmpty()) + return vide; // le canal se tait : absence, sans motif ni courbe + + const QDateTime run = plans.first().issuedAt; + for (const ForecastRevision &r : plans) + if (r.issuedAt != run) + return vide; // plans de runs différents : on ne somme pas + + ForecastRevision site; + site.siteId = m_forecastSiteId; + site.issuedAt = run; + site.model = plans.first().model; + QMap somme; + for (const ForecastRevision &r : plans) + for (const ForecastPoint &p : r.points) + somme[p.targetAt.toSecsSinceEpoch()] += p.wh; + for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) { + ForecastPoint p; + p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc()); + p.wh = it.value(); + site.points.append(p); + } + return site; +} + void EnergyArbitrator::lireCourbePrevision() { if (!m_forecastStore || !m_forecastStore->isValid() || !m_tm) diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 31e9cde..5af3bed 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -12,6 +12,7 @@ #include "types/drawcap.h" #include "types/derogation.h" #include "forecast/forecaststore.h" +#include "types/decisionreason.h" #include "types/ecoprogress.h" #include "types/ecotarget.h" @@ -103,6 +104,22 @@ public: */ QStringList forecastSiteCandidates() const; + /*! + * \brief La prévision COURANTE, lue DIRECTEMENT sur le canal — l'entrée, pas la trace. + * \param[out] fault Code de motif, vide si tout va bien. + * \param[out] faultParams Paramètres du motif. + * \return La révision telle que le producteur la publie ; \c points vide si rien n'est + * lisible. **Une absence, jamais une courbe de zéros.** + * + * \par Pourquoi elle ne lit PAS la table + * \c GetPvForecast sert la TABLE — l'historique, ses révisions, sa reproductibilité. + * Celle-ci sert la DÉCISION. **Un arbitre qui lit la table lirait sa propre trace au lieu + * de son entrée : il ne mesure plus, il se relit.** La distinction est écrite maintenant + * parce qu'elle est nette maintenant ; dans six semaines, le premier consommateur prendrait + * \c GetPvForecast sans y penser. + */ + ForecastRevision forecastSnapshot(QString &fault, QVariantMap &faultParams) const; + /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, * dont il hérite (justification : AGENTS.md, « 3b-iii »). diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 0988dfe..b9a35b4 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -224,6 +224,12 @@ QString renderFr(const DecisionReason &reason, const QString &label) "disponibles dans la configuration").arg(n); } + if (c == QLatin1String(DecisionCode::ForecastPayloadRefused)) + // LE GESTE : ce n'est pas une configuration à faire, c'est un producteur à regarder. + return QStringLiteral("Prévision illisible (%1) — la courbe n'est pas absente, elle est " + "REFUSÉE ; regarder le plugin météo") + .arg(p.value(QStringLiteral("cause")).toString()); + // --- Sécurité et cycle de vie ------------------------------------------ if (c == QLatin1String(DecisionCode::DegradedL2)) return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 25fe1ce..5441420 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -295,6 +295,19 @@ constexpr const char *DerogationEnded = "DEROGATION_ENDED"; */ constexpr const char *MissingForecastSite = "MISSING_FORECAST_SITE"; +/*! + * \brief Le canal de prévision publie, mais sa charge utile est ILLISIBLE. + * params : \c cause — le motif de refus (\c VERSION_x, \c UNIT_x, \c MALFORMED, \c EMPTY). + * + * \note Distinct de \c MISSING_FORECAST_SITE : là, personne n'a désigné ; ici, quelqu'un publie + * quelque chose qu'on ne sait pas lire. **Deux causes, deux gestes** — configurer d'un côté, + * regarder le producteur de l'autre. + * + * \note Et distinct d'une prévision NULLE : une courbe absente n'est pas une courbe à zéro. Le + * motif existe précisément pour que l'écran ne puisse pas confondre les deux. + */ +constexpr const char *ForecastPayloadRefused = "FORECAST_PAYLOAD_REFUSED"; + // --- SG-Ready (PAC) --------------------------------------------------------- //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 631a9e0..3fc8d88 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -367,6 +367,36 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("o:fault", enumValueName(String)); // MISSING_FORECAST_SITE registerMethod("GetForecastSite", description, params, returns, Types::PermissionScopeControlThings); + // GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre qui lit la + // table lirait sa propre trace au lieu de son entrée : il ne mesure plus, il se relit. + params.clear(); returns.clear(); + description = "The CURRENT PV forecast, read straight from the channel - never from the " + "stored table. GetPvForecast serves the TABLE (history, revisions, " + "reproducible reads); this one serves the DECISION. An arbiter reading the " + "table would read its own trace instead of its input: it would no longer " + "measure, it would re-read itself. " + "'capturedAt' is when this reply was built. 'contentChangedAt' is when the " + "current content FIRST APPEARED - and it is the one that matters: without it, " + "a FROZEN provider reads as a stable forecast, which is the costliest form of " + "'silence that looks like a result' since whoever reads it decides to BUY. It " + "is omitted when the store has never seen this content, rather than guessed. " + "'forecastPoints' is ABSENT when nothing is readable - never a curve of zeros; " + "a missing forecast and a forecast of zero are different statements. 'fault' " + "carries MISSING_FORECAST_SITE (nobody designated a site, and one exists) or " + "FORECAST_PAYLOAD_REFUSED with its cause (the channel publishes something we " + "cannot read). Two faults, two gestures: configure, or look at the provider."; + QVariantMap snapPoint; + snapPoint.insert("targetAt", enumValueName(String)); + snapPoint.insert("forecastWh", enumValueName(Double)); + returns.insert("capturedAt", enumValueName(String)); + returns.insert("o:contentChangedAt", enumValueName(String)); + returns.insert("o:issuedAt", enumValueName(String)); + returns.insert("o:model", enumValueName(String)); + returns.insert("o:forecastPoints", QVariantList() << snapPoint); + returns.insert("o:fault", enumValueName(String)); + returns.insert("o:faultParams", enumValueName(Variant)); + registerMethod("GetForecastSnapshot", description, params, returns, Types::PermissionScopeControlThings); + params.clear(); returns.clear(); description = "Designate the site whose PV forecast is kept. ALL the PV planes under that " "site are SUMMED - not chosen between: a site's production is physically the " @@ -1160,6 +1190,60 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap ¶ms) return createReply(ret); } +JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + QVariantMap ret; + // `capturedAt` est TOUJOURS rendu : il date la réponse, pas la prévision. C'est ce qui + // permet de distinguer « le moteur n'a pas répondu » de « le moteur a répondu, et il n'a + // rien ». + ret.insert(QStringLiteral("capturedAt"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); +#ifdef ETM_ARBITRATOR + EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); + if (!arb) + return createReply(ret); + QString fault; + QVariantMap faultParams; + const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams); + if (!fault.isEmpty()) { + ret.insert(QStringLiteral("fault"), fault); + if (!faultParams.isEmpty()) + ret.insert(QStringLiteral("faultParams"), faultParams); + return createReply(ret); // et AUCUN forecastPoints : l'absence est le message + } + if (rev.points.isEmpty()) + return createReply(ret); // le canal se tait : ni courbe, ni motif + if (rev.issuedAt.isValid()) + ret.insert(QStringLiteral("issuedAt"), rev.issuedAt.toUTC().toString(Qt::ISODate)); + if (!rev.model.isEmpty()) + ret.insert(QStringLiteral("model"), rev.model); + + // DATER LE CONTENU, pas seulement la lecture. Sans cela, un producteur FIGÉ se lirait comme + // une prévision stable — l'instrument dont le silence ressemble à un résultat, sous sa forme + // la plus coûteuse puisque celui qui le lit décide d'ACHETER. + // + // La date vient du magasin, qui sait quand ce contenu est apparu, et elle SURVIT au + // redémarrage. Le garder en mémoire l'aurait remise à zéro à chaque relance — c'est-à-dire + // qu'un producteur figé aurait paru frais après le premier redémarrage venu. + // La table n'est pas lue comme ENTRÉE : elle ne fournit pas la courbe, seulement sa date. + if (ForecastStore *magasin = arb->forecastStore()) { + if (magasin->isValid() && !rev.points.isEmpty()) { + const QList> revs = magasin->revisions(rev.points.first().targetAt); + for (const auto &r : revs) + if (qAbs(r.second - rev.points.first().wh) <= 0.001) + ret.insert(QStringLiteral("contentChangedAt"), r.first.toUTC().toString(Qt::ISODate)); + } + } + QVariantList pts; + for (const ForecastPoint &p : rev.points) + pts.append(QVariantMap{{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)}, + {"forecastWh", p.wh}}); + ret.insert(QStringLiteral("forecastPoints"), pts); +#endif + return createReply(ret); +} + JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap ¶ms) { #ifdef ETM_ARBITRATOR diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 4221a46..775730e 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -128,6 +128,9 @@ public: Q_INVOKABLE JsonReply *GetForecastSite(const QVariantMap ¶ms); //! \brief Désigne ce site. \param params \c o:forecastSiteThingId (vide = aucun). //! \return \c energyError — \c ThingNotFound si l'identifiant n'est pas celui d'un Thing. + //! \brief La prévision COURANTE, lue sur le canal — l'entrée d'une décision, pas la trace. + //! \param params Aucun. \return \c capturedAt, et la courbe OU un motif, jamais des zéros. + Q_INVOKABLE JsonReply *GetForecastSnapshot(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *SetForecastSite(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *SetLoadDerogation(const QVariantMap ¶ms); //! \brief Lève une dérogation avant son terme et rend le mode mémorisé. diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 84af2dd..4ce24a7 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7010,6 +7010,63 @@ void Simulation::testAModeWriteEndsTheDerogationAndKeepsTheChoice() #endif } +void Simulation::testTheSnapshotServesTheChannelNeverTheTable() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre qui lirait + // la table lirait sa propre trace au lieu de son entrée : il ne mesurerait plus, il se + // relirait. Ce test tient la frontière, et les deux absences qui l'accompagnent. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + + QString motif; QVariantMap params; + + // 1. AUCUN CANDIDAT, AUCUN SITE — SILENCE. Ni courbe, ni motif : une installation sans + // plugin météo n'a rien à désigner, et lui annoncer un manque serait un avertissement + // qu'aucun geste ne peut lever. + QVERIFY(arb->setForecastSiteId(QString())); + const ForecastRevision muet = arb->forecastSnapshot(motif, params); + QVERIFY2(muet.points.isEmpty(), "sans canal, aucune courbe"); + QVERIFY2(motif.isEmpty(), "et AUCUN motif : le silence ne porte que l'ignorance"); + + // 2. LA RÉPONSE RPC PORTE TOUJOURS capturedAt, même vide. C'est ce qui distingue « le moteur + // n'a pas répondu » de « le moteur a répondu, et il n'a rien » — deux états qu'une + // réponse vide confondrait. + const QVariantMap rep = injectAndWait("NymeaEnergy.GetForecastSnapshot") + .toMap().value("params").toMap(); + QVERIFY2(rep.contains(QStringLiteral("capturedAt")), + "capturedAt date la RÉPONSE, pas la prévision — il est toujours là"); + QVERIFY2(!rep.contains(QStringLiteral("forecastPoints")), + "une courbe absente est ABSENTE, jamais une liste de zéros"); + + // 3. CONTRE-ÉPREUVE de la frontière — la table peut contenir une courbe SANS que le + // snapshot en rende une. C'est le cœur : le snapshot lit le canal, pas la table. Si un + // jour quelqu'un le rebranche sur le magasin, cette ligne tombera. + ForecastStore *magasin = arb->forecastStore(); + QVERIFY(magasin && magasin->isValid()); + ForecastRevision fabriquee; + fabriquee.siteId = QStringLiteral("site-fantome"); + fabriquee.issuedAt = QDateTime::currentDateTimeUtc(); + ForecastPoint p; + p.targetAt = QDateTime::currentDateTimeUtc().addSecs(3600); + p.wh = 4242; + fabriquee.points.append(p); + QVERIFY2(magasin->enregistrer(fabriquee), "la table accepte bien cette courbe"); + QVERIFY2(!magasin->courbe(p.targetAt.addSecs(-60), p.targetAt.addSecs(60)).isEmpty(), + "et la table la rend — la contre-épreuve n'est donc pas vide de sens"); + + const ForecastRevision apres = arb->forecastSnapshot(motif, params); + QVERIFY2(apres.points.isEmpty(), + "le snapshot ne doit RIEN rendre : la table n'est pas son entrée"); + QVERIFY(motif.isEmpty()); +#endif +} + void Simulation::testEveryGetterReturnsWhatItDeclares() { // LE TROU QUE LE 2026-09-09 A OUVERT. diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 7aa748a..6d19108 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -211,6 +211,8 @@ private slots: void testDerogationTakesTheHeadButNotTheSafety(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); + //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro. + void testTheSnapshotServesTheChannelNeverTheTable(); void testAModeWriteEndsTheDerogationAndKeepsTheChoice(); // ── §12 / LM-1209 ──────────────────────────────────────────────────────