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:
parent
b14db632ca
commit
4f20ca22ed
@ -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.
|
||||
|
||||
@ -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 "
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 ¶ms)
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms)
|
||||
{
|
||||
#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 ¶ms)
|
||||
{"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
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user