fix(prévision PV): GetPvForecast — une date illisible est une erreur explicite (D4)

Décision de Patrick du 2026-10-13. Constaté le 2026-10-12 : `from`,
`to` ou `knownAt` illisible valait l'epoch 0 en silence ; un `to`
illisible rendait une fenêtre vide, indistinguable de « rien
d'enregistré ».

Les dates sont validées avant toute lecture. Illisible (y compris "") →
`energyError: EnergyErrorInvalidParameter` (énumération amont),
`fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields` (paramètres en
cause). Journal rendu par renderFr().

Schéma déclaré modifié, de façon additive : `energyError` (requis,
EnergyErrorNoError sur toute lecture), `o:fault`, `o:faultParams`.
`forecastPoints` reste requis, donc `[]` en erreur.

Test : testAnUnreadableForecastDateIsAnError (chaque date seule, les
trois ensemble, chaîne vide ; lectures valides sans motif). Le balayage
des getters vérifie `energyError` requis. Contre-épreuve : validation
neutralisée → échec (EnergyErrorNoError au lieu d'InvalidParameter).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 06:59:24 +02:00
parent b14db632ca
commit 4f20ca22ed
6 changed files with 111 additions and 9 deletions

View File

@ -1403,3 +1403,9 @@ restent l'état de leur date, et c'est ici qu'on lit ce qui les corrige.*
sur un `Uint` : le paramètre est déclaré `Variant` et sa forme contrôlée par le handler
(`null` ou entier, rien d'autre). Choix d'implémentation, signalé : désigner **aucun** site
retire aussi le plafond (invariant persisté « plafond ⇒ site »).
4. **Date illisible dans `GetPvForecast` = erreur explicite.** Elle devenait l'epoch 0 en silence,
et un `to` illisible rendait une fenêtre vide, indistinguable de « rien d'enregistré ». `from`,
`to`, `knownAt` illisibles → `energyError: EnergyErrorInvalidParameter` (énumération amont,
rien d'inventé) + `fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields`. Rien n'est lu dans
la table. Le schéma déclaré change, de façon additive : `energyError` (requis, toujours rendu),
`o:fault`, `o:faultParams`. `forecastPoints` reste requis, donc `[]` en erreur.

View File

@ -230,6 +230,12 @@ QString renderFr(const DecisionReason &reason, const QString &label)
"REFUSÉE ; regarder le plugin météo")
.arg(p.value(QStringLiteral("cause")).toString());
if (c == QLatin1String(DecisionCode::ForecastDateUnreadable))
// LE GESTE : c'est l'appel qui est faux, pas la table qui est vide.
return QStringLiteral("Date illisible (%1) — attendu un instant ISO 8601 avec décalage, "
"par exemple 2026-10-13T00:00:00Z")
.arg(p.value(QStringLiteral("fields")).toStringList().join(QStringLiteral(", ")));
if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild))
// LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas.
return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur "

View File

@ -320,6 +320,17 @@ constexpr const char *ForecastPayloadRefused = "FORECAST_PAYLOAD_REFUSED";
*/
constexpr const char *SpotMarketNotInBuild = "SPOT_MARKET_NOT_IN_BUILD";
/*!
* \brief Une date passée à \c GetPvForecast est ILLISIBLE.
* params : \c fields — la liste des paramètres en cause (\c from, \c to, \c knownAt).
*
* \note Rendu à côté de \c EnergyErrorInvalidParameter. Avant (constaté le 2026-10-12), une date
* illisible devenait l'epoch 0 en silence : un \c to illisible rendait une fenêtre vide,
* indistinguable de « rien d'enregistré ». Une erreur de l'appelant ne doit pas se lire comme
* un état de la table.
*/
constexpr const char *ForecastDateUnreadable = "FORECAST_DATE_UNREADABLE";
// --- SG-Ready (PAC) ---------------------------------------------------------
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";

View File

@ -333,7 +333,12 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"METEOROLOGICAL error, never the CONVERSION bias - shading, soiling, module "
"temperature and inverter clipping are as wrong at three hours as at "
"thirty-six, and all in the optimistic direction. 'Nearer forecast is more "
"reliable' must NOT be shown as 'nearer figure is more accurate'.";
"reliable' must NOT be shown as 'nearer figure is more accurate'. "
"An UNREADABLE date ('from', 'to' or 'knownAt') is an ERROR, never a window: "
"'energyError' is EnergyErrorInvalidParameter, 'fault' is "
"FORECAST_DATE_UNREADABLE with 'faultParams.fields' naming the parameters, "
"and 'forecastPoints' is empty and MEANS NOTHING - read 'energyError' first. "
"'energyError' is EnergyErrorNoError on every successful read.";
params.insert("from", enumValueName(String)); // ISO 8601 UTC
params.insert("to", enumValueName(String)); // ISO 8601 UTC
params.insert("o:knownAt", enumValueName(String)); // ISO 8601 UTC ; absent = maintenant
@ -346,6 +351,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
pointItem.insert("firstSeenAt", enumValueName(String)); // ISO 8601 UTC
pointItem.insert("lastSeenAt", enumValueName(String)); // ISO 8601 UTC
returns.insert("forecastPoints", QVariantList() << pointItem);
// Ajoutés le 2026-10-13 (D4) : une date illisible était lue comme l'epoch 0, en silence.
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
returns.insert("o:fault", enumValueName(String)); // FORECAST_DATE_UNREADABLE
returns.insert("o:faultParams", enumValueName(Variant)); // {fields: [from|to|knownAt]}
registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings);
// Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique
@ -1181,17 +1190,39 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap &params)
JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap &params)
{
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
ForecastStore *magasin = arb ? arb->forecastStore() : nullptr;
if (!magasin || !magasin->isValid())
return createReply({{"forecastPoints", QVariantList()}});
const QVariantMap succes{{"forecastPoints", QVariantList()},
{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}};
// LES DATES D'ABORD, avant toute lecture. Une date illisible devenait l'epoch 0 en silence :
// un `to` illisible rendait une fenêtre VIDE, indistinguable de « rien d'enregistré », et un
// `from` illisible l'élargissait au passé entier. C'est une erreur de l'APPELANT, et elle doit
// se lire comme telle — jamais comme un état de la table.
const QDateTime de = QDateTime::fromString(params.value("from").toString(), Qt::ISODate);
const QDateTime a = QDateTime::fromString(params.value("to").toString(), Qt::ISODate);
QDateTime connuA;
if (params.contains("knownAt"))
connuA = QDateTime::fromString(params.value("knownAt").toString(), Qt::ISODate);
QStringList illisibles;
if (!de.isValid()) illisibles << QStringLiteral("from");
if (!a.isValid()) illisibles << QStringLiteral("to");
if (params.contains("knownAt") && !connuA.isValid()) illisibles << QStringLiteral("knownAt");
if (!illisibles.isEmpty()) {
const QVariantMap motif{{QStringLiteral("fields"), illisibles}};
qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Prévision] GetPvForecast refusé : %1 (motif %2)")
.arg(renderFr({ DecisionCode::ForecastDateUnreadable, motif }))
.arg(QLatin1String(DecisionCode::ForecastDateUnreadable));
return createReply({{"forecastPoints", QVariantList()},
{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
{"fault", QLatin1String(DecisionCode::ForecastDateUnreadable)},
{"faultParams", motif}});
}
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
ForecastStore *magasin = arb ? arb->forecastStore() : nullptr;
if (!magasin || !magasin->isValid())
return createReply(succes);
QVariantList points;
for (const ForecastPoint &p : magasin->courbe(de, a, connuA)) {
@ -1204,10 +1235,11 @@ JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap &params)
{"firstSeenAt", p.firstSeenAt.toUTC().toString(Qt::ISODate)},
{"lastSeenAt", p.lastSeenAt.toUTC().toString(Qt::ISODate)}});
}
return createReply({{"forecastPoints", points}});
QVariantMap ret = succes;
ret.insert(QStringLiteral("forecastPoints"), points);
return createReply(ret);
#else
Q_UNUSED(params)
return createReply({{"forecastPoints", QVariantList()}});
return createReply(succes);
#endif
}

View File

@ -7356,6 +7356,52 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
#endif
}
void Simulation::testAnUnreadableForecastDateIsAnError()
{
// Décision du 2026-10-13 (§30.4). Constaté le 2026-10-12 : une date illisible devenait
// l'epoch 0, en silence — `to` illisible rendait une fenêtre vide, indistinguable de « rien
// d'enregistré ». Une erreur de l'APPELANT se lit désormais comme telle.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
const QString ok = QStringLiteral("2026-10-13T00:00:00Z");
const QString ko = QStringLiteral("pas-une-date");
auto lire = [&](const QVariantMap &p) {
return injectAndWait("NymeaEnergy.GetPvForecast", p).toMap();
};
// 1. Chaque date illisible, seule puis ensemble : erreur de paramètre, motif, champs nommés.
struct Cas { QVariantMap params; QStringList champs; };
const QList<Cas> cas {
{ {{"from", ko}, {"to", ok}}, {"from"} },
{ {{"from", ok}, {"to", ko}}, {"to"} },
{ {{"from", ok}, {"to", ok}, {"knownAt", ko}}, {"knownAt"} },
{ {{"from", ko}, {"to", ko}, {"knownAt", ko}}, {"from", "to", "knownAt"} },
{ {{"from", ""}, {"to", ok}}, {"from"} },
};
for (const Cas &c : cas) {
const QVariantMap r = lire(c.params);
// L'appel n'est PAS rejeté par la validation nymea : c'est le handler qui répond.
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
const QVariantMap rep = r.value("params").toMap();
QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorInvalidParameter"));
QCOMPARE(rep.value("fault").toString(), QString::fromLatin1(DecisionCode::ForecastDateUnreadable));
QCOMPARE(rep.value("faultParams").toMap().value("fields").toStringList(), c.champs);
QVERIFY(rep.value("forecastPoints").toList().isEmpty());
}
// 2. Dates lisibles — avec et sans `knownAt`, avec décalage explicite : aucune erreur, aucun
// motif. Une fenêtre vide y veut dire « rien d'enregistré », et seulement cela.
for (const QVariantMap &p : {QVariantMap{{"from", ok}, {"to", "2026-10-14T00:00:00+02:00"}},
QVariantMap{{"from", ok}, {"to", ok}, {"knownAt", ok}}}) {
const QVariantMap rep = lire(p).value("params").toMap();
QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
QVERIFY(!rep.contains("fault"));
QVERIFY(!rep.contains("faultParams"));
QVERIFY(rep.contains("forecastPoints"));
}
}
void Simulation::testEveryGetterReturnsWhatItDeclares()
{
// LE TROU QUE LE 2026-09-09 A OUVERT.

View File

@ -214,6 +214,7 @@ private slots:
//! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond.
void testTheSiteAcLimitTravelsWithTheDesignation();
void testTheSnapshotPublishesEachPlanesCorrections();
void testAnUnreadableForecastDateIsAnError();
//! 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.