fix(prévision PV): l'identifiant du site est normalisé, avec ou sans accolades
SetForecastSite stockait l'identifiant tel que reçu ; forecastSnapshot() et lireCourbePrevision() le comparent en chaîne à parentId().toString(), qui a des accolades. Un UUID envoyé sans elles était accepté, puis le snapshot restait muet et la table vide. Normalisation par ThingId(...).toString() (siteIdCanonique) à l'écriture (setForecastSiteId) ET à la relecture des réglages (relireReglagesPrevision, extraite du constructeur) : une valeur persistée sans accolades par une version antérieure est relue correctement après mise à jour. Test testTheForecastSiteIdIsMatchedWhateverItsBraces : forme à accolades, sans accolades, majuscules (RPC), arbitre direct, valeur persistée relue, plus une contre-épreuve sur un autre site. Contre-épreuves vérifiées : sans la normalisation, le test échoue à la première forme sans accolades ; sans la normalisation à la relecture seule, il échoue sur la valeur persistée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c8da4ab2a0
commit
9c440367fa
@ -64,15 +64,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||
// 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"));
|
||||
bool ok = false;
|
||||
const int plafond = reglages.value(QStringLiteral("forecastAcLimitW")).toInt(&ok);
|
||||
if (ok && plafond >= AcLimitMinW && plafond <= AcLimitMaxW)
|
||||
m_forecastAcLimitW = plafond;
|
||||
}
|
||||
relireReglagesPrevision();
|
||||
|
||||
#ifndef ENERGY_SIMULATION
|
||||
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
|
||||
@ -2316,8 +2308,34 @@ void EnergyArbitrator::signalerSiteManquant()
|
||||
.arg(QLatin1String(DecisionCode::MissingForecastSite));
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional<int> acLimitW)
|
||||
QString EnergyArbitrator::siteIdCanonique(const QString &siteId)
|
||||
{
|
||||
// LA FORME DE `parentId().toString()` — accolades, minuscules —, parce que c'est à elle que
|
||||
// le site est comparé, EN CHAÎNE, dans `forecastSnapshot()` et `lireCourbePrevision()`.
|
||||
// Constaté le 2026-10-13 : un UUID reçu sans accolades était accepté (`ThingId` le lit),
|
||||
// stocké tel quel, et ne correspondait ensuite à aucun plan — snapshot muet, table vide.
|
||||
// Ce qui n'est pas un UUID est rendu tel quel : il ne correspondra à rien, comme avant.
|
||||
const ThingId id(siteId);
|
||||
return id.isNull() ? siteId : id.toString();
|
||||
}
|
||||
|
||||
void EnergyArbitrator::relireReglagesPrevision()
|
||||
{
|
||||
EnergySettings reglages;
|
||||
// NORMALISÉE À LA RELECTURE AUSSI : une version antérieure a pu persister la forme sans
|
||||
// accolades. La réécrire n'est pas nécessaire — la prochaine désignation le fera.
|
||||
m_forecastSiteId = siteIdCanonique(reglages.value(QStringLiteral("forecastSiteId")).toString());
|
||||
m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen"));
|
||||
m_forecastAcLimitW.reset();
|
||||
bool ok = false;
|
||||
const int plafond = reglages.value(QStringLiteral("forecastAcLimitW")).toInt(&ok);
|
||||
if (ok && plafond >= AcLimitMinW && plafond <= AcLimitMaxW)
|
||||
m_forecastAcLimitW = plafond;
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::setForecastSiteId(const QString &siteIdRecu, std::optional<int> acLimitW)
|
||||
{
|
||||
const QString siteId = siteIdCanonique(siteIdRecu);
|
||||
if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId))))
|
||||
return false;
|
||||
// Un plafond est une propriété d'un SITE : sans site, il n'aurait pas d'objet. Et ZÉRO n'est
|
||||
|
||||
@ -108,9 +108,30 @@ public:
|
||||
* \c SetForecastSite, lui, lit « absent = inchangé » : c'est le handler qui fusionne.
|
||||
* \return Faux si l'identifiant n'est pas celui d'un Thing existant, si le plafond est hors
|
||||
* de [\c AcLimitMinW, \c AcLimitMaxW], ou s'il est donné sans site. Rien n'est alors modifié.
|
||||
* \note L'identifiant est NORMALISÉ (\c siteIdCanonique) avant contrôle, stockage et
|
||||
* persistance : avec ou sans accolades, majuscules ou non, c'est le même site, et
|
||||
* \c forecastSiteId() le rend toujours sous la forme \c {uuid} en minuscules.
|
||||
*/
|
||||
bool setForecastSiteId(const QString &siteId, std::optional<int> acLimitW = std::nullopt);
|
||||
|
||||
/*!
|
||||
* \brief Forme canonique d'un identifiant de site : celle de \c ThingId::toString().
|
||||
* \param siteId UUID avec ou sans accolades, casse indifférente ; ou vide.
|
||||
* \return \c {uuid} en minuscules ; vide si \p siteId est vide ; \p siteId inchangé s'il
|
||||
* n'est pas un UUID (il ne désignera alors aucun Thing).
|
||||
* \note Le site est comparé EN CHAÎNE à \c parentId().toString() : toute valeur stockée
|
||||
* dans \c m_forecastSiteId passe par ici, à l'écriture comme à la relecture.
|
||||
*/
|
||||
static QString siteIdCanonique(const QString &siteId);
|
||||
|
||||
/*!
|
||||
* \brief Relit la désignation persistée (site, choix exprimé, plafond AC).
|
||||
* \note Appelée par le constructeur. Exposée pour le banc : c'est ainsi qu'il prouve qu'une
|
||||
* valeur persistée par une version antérieure (UUID sans accolades) est relue normalisée,
|
||||
* sans redémarrer un serveur dont le harnais efface les réglages.
|
||||
*/
|
||||
void relireReglagesPrevision();
|
||||
|
||||
/*!
|
||||
* \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe.
|
||||
* \return Liste de ThingId, vide s'il n'y en a aucun.
|
||||
|
||||
@ -7281,9 +7281,8 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
|
||||
tOuest->setParentId(ThingId());
|
||||
arb->setForecastSiteId(QString());
|
||||
});
|
||||
// L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. ⚠️ Sans accolades,
|
||||
// la désignation est acceptée mais le snapshot reste muet : `forecastSnapshot()` compare des
|
||||
// CHAÎNES (défaut préexistant, signalé dans le rapport des décisions du 2026-10-13).
|
||||
// L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. La forme sans
|
||||
// accolades est tenue à part, par `testTheForecastSiteIdIsMatchedWhateverItsBraces`.
|
||||
const QString siteId = ThingId(site).toString();
|
||||
|
||||
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||||
@ -7356,6 +7355,88 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testTheForecastSiteIdIsMatchedWhateverItsBraces()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// Constaté le 2026-10-13 (rapport des décisions, écart n° 1) : `SetForecastSite` stockait
|
||||
// l'identifiant TEL QUE REÇU, puis le comparait EN CHAÎNE à `parentId().toString()`, qui a
|
||||
// des accolades. Un UUID envoyé sans elles était accepté — et le snapshot restait MUET, la
|
||||
// table vide. Une désignation qui a l'air faite et ne fait rien.
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site
|
||||
const QUuid plan = addPvPlane(QStringLiteral("Toit"));
|
||||
Thing *tPlan = tm->findConfiguredThing(ThingId(plan));
|
||||
QVERIFY(tPlan && !site.isNull());
|
||||
tPlan->setParentId(ThingId(site));
|
||||
const auto detacher = qScopeGuard([&]() {
|
||||
tPlan->setParentId(ThingId());
|
||||
arb->setForecastSiteId(QString());
|
||||
});
|
||||
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||||
tPlan->setStateValue("forecastCurveTransport",
|
||||
QVariantMap{{"v", 2}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
|
||||
{"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600},
|
||||
{"model", "meteofrance_seamless"}, {"p", QVariantList{1500, 1200}},
|
||||
{"corrections", QVariantList{}}});
|
||||
|
||||
const QString canonique = ThingId(site).toString(); // {uuid}, minuscules
|
||||
QVERIFY(canonique.startsWith('{'));
|
||||
const QString nu = site.toString(QUuid::WithoutBraces);
|
||||
auto snapshot = [&]() {
|
||||
return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
|
||||
};
|
||||
auto courbeLue = [&](const char *forme) {
|
||||
QString motif; QVariantMap params;
|
||||
const ForecastRevision rev = arb->forecastSnapshot(motif, params);
|
||||
QVERIFY2(motif.isEmpty(), qUtf8Printable(QStringLiteral("%1 : motif %2").arg(forme, motif)));
|
||||
QVERIFY2(rev.points.count() == 2, forme);
|
||||
QCOMPARE(rev.siteId, canonique);
|
||||
const QVariantMap snap = snapshot();
|
||||
QVERIFY2(snap.value("forecastPoints").toList().count() == 2,
|
||||
qUtf8Printable(QStringLiteral("%1 : %2").arg(forme,
|
||||
QString::fromUtf8(QJsonDocument::fromVariant(snap).toJson()))));
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap()
|
||||
.value("forecastSiteThingId").toString(), canonique);
|
||||
QCOMPARE(arb->forecastSiteId(), canonique);
|
||||
};
|
||||
|
||||
// 1. PAR LE RPC, avec puis sans accolades, puis en majuscules : le même site, la même courbe,
|
||||
// et la désignation est rendue — et persistée — sous UNE seule forme.
|
||||
for (const QString &forme : {canonique, nu, nu.toUpper()}) {
|
||||
const QVariantMap r = injectAndWait("NymeaEnergy.SetForecastSite",
|
||||
{{"forecastSiteThingId", forme}}).toMap();
|
||||
QCOMPARE(r.value("params").toMap().value("energyError").toString(),
|
||||
QStringLiteral("EnergyErrorNoError"));
|
||||
courbeLue(qPrintable(forme));
|
||||
QCOMPARE(EnergySettings().value("forecastSiteId").toString(), canonique);
|
||||
}
|
||||
|
||||
// 2. Par l'arbitre directement, sans accolades : même normalisation, même chemin.
|
||||
QVERIFY(arb->setForecastSiteId(nu));
|
||||
courbeLue("setForecastSiteId sans accolades");
|
||||
|
||||
// 3. UNE VALEUR PERSISTÉE PAR UNE VERSION ANTÉRIEURE, sans accolades, puis relue : c'est le
|
||||
// cas d'une box mise à jour, dont le réglage a été écrit avant le correctif.
|
||||
QVERIFY(arb->setForecastSiteId(QString()) && arb->forecastSiteId().isEmpty());
|
||||
EnergySettings().setValue(QStringLiteral("forecastSiteId"), nu);
|
||||
arb->relireReglagesPrevision();
|
||||
courbeLue("valeur persistée sans accolades");
|
||||
|
||||
// 4. Contre-épreuve : un AUTRE site, sous quelque forme que ce soit, ne lit pas ce plan.
|
||||
// Le plan lui-même tient lieu d'autre site : un Thing existant, parent d'aucun plan.
|
||||
QVERIFY(arb->setForecastSiteId(plan.toString(QUuid::WithoutBraces)));
|
||||
QString motif; QVariantMap params;
|
||||
QVERIFY(arb->forecastSnapshot(motif, params).points.isEmpty());
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testAnUnreadableForecastDateIsAnError()
|
||||
{
|
||||
// Décision du 2026-10-13 (§30.4). Constaté le 2026-10-12 : une date illisible devenait
|
||||
|
||||
@ -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 testTheForecastSiteIdIsMatchedWhateverItsBraces();
|
||||
void testAnUnreadableForecastDateIsAnError();
|
||||
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||
void testEveryGetterReturnsWhatItDeclares();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user