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
|
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
|
(`null` ou entier, rien d'autre). Choix d'implémentation, signalé : désigner **aucun** site
|
||||||
retire aussi le plafond (invariant persisté « plafond ⇒ 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")
|
"REFUSÉE ; regarder le plugin météo")
|
||||||
.arg(p.value(QStringLiteral("cause")).toString());
|
.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))
|
if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild))
|
||||||
// LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas.
|
// 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 "
|
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";
|
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) ---------------------------------------------------------
|
// --- SG-Ready (PAC) ---------------------------------------------------------
|
||||||
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
|
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
|
||||||
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";
|
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 "
|
"METEOROLOGICAL error, never the CONVERSION bias - shading, soiling, module "
|
||||||
"temperature and inverter clipping are as wrong at three hours as at "
|
"temperature and inverter clipping are as wrong at three hours as at "
|
||||||
"thirty-six, and all in the optimistic direction. 'Nearer forecast is more "
|
"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("from", enumValueName(String)); // ISO 8601 UTC
|
||||||
params.insert("to", enumValueName(String)); // ISO 8601 UTC
|
params.insert("to", enumValueName(String)); // ISO 8601 UTC
|
||||||
params.insert("o:knownAt", enumValueName(String)); // ISO 8601 UTC ; absent = maintenant
|
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("firstSeenAt", enumValueName(String)); // ISO 8601 UTC
|
||||||
pointItem.insert("lastSeenAt", enumValueName(String)); // ISO 8601 UTC
|
pointItem.insert("lastSeenAt", enumValueName(String)); // ISO 8601 UTC
|
||||||
returns.insert("forecastPoints", QVariantList() << pointItem);
|
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);
|
registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings);
|
||||||
|
|
||||||
// Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique
|
// 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)
|
JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
#ifdef ETM_ARBITRATOR
|
const QVariantMap succes{{"forecastPoints", QVariantList()},
|
||||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
|
{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}};
|
||||||
ForecastStore *magasin = arb ? arb->forecastStore() : nullptr;
|
|
||||||
if (!magasin || !magasin->isValid())
|
|
||||||
return createReply({{"forecastPoints", QVariantList()}});
|
|
||||||
|
|
||||||
|
// 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 de = QDateTime::fromString(params.value("from").toString(), Qt::ISODate);
|
||||||
const QDateTime a = QDateTime::fromString(params.value("to").toString(), Qt::ISODate);
|
const QDateTime a = QDateTime::fromString(params.value("to").toString(), Qt::ISODate);
|
||||||
QDateTime connuA;
|
QDateTime connuA;
|
||||||
if (params.contains("knownAt"))
|
if (params.contains("knownAt"))
|
||||||
connuA = QDateTime::fromString(params.value("knownAt").toString(), Qt::ISODate);
|
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;
|
QVariantList points;
|
||||||
for (const ForecastPoint &p : magasin->courbe(de, a, connuA)) {
|
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)},
|
{"firstSeenAt", p.firstSeenAt.toUTC().toString(Qt::ISODate)},
|
||||||
{"lastSeenAt", p.lastSeenAt.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
|
#else
|
||||||
Q_UNUSED(params)
|
return createReply(succes);
|
||||||
return createReply({{"forecastPoints", QVariantList()}});
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7356,6 +7356,52 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
|
|||||||
#endif
|
#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()
|
void Simulation::testEveryGetterReturnsWhatItDeclares()
|
||||||
{
|
{
|
||||||
// LE TROU QUE LE 2026-09-09 A OUVERT.
|
// 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.
|
//! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond.
|
||||||
void testTheSiteAcLimitTravelsWithTheDesignation();
|
void testTheSiteAcLimitTravelsWithTheDesignation();
|
||||||
void testTheSnapshotPublishesEachPlanesCorrections();
|
void testTheSnapshotPublishesEachPlanesCorrections();
|
||||||
|
void testAnUnreadableForecastDateIsAnError();
|
||||||
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||||
void testEveryGetterReturnsWhatItDeclares();
|
void testEveryGetterReturnsWhatItDeclares();
|
||||||
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
//! 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