diff --git a/energyplugin/etm/scheduler/socketscheduler.cpp b/energyplugin/etm/scheduler/socketscheduler.cpp index 0124a51..093032b 100644 --- a/energyplugin/etm/scheduler/socketscheduler.cpp +++ b/energyplugin/etm/scheduler/socketscheduler.cpp @@ -50,6 +50,7 @@ SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, Fou return; // tentative échouée pendant une panne déjà notée m_perteNotee = true; oublierCache(QStringLiteral("connexion perdue")); + m_codeReception.clear(); // plus rien ne sera reçu sur cette connexion : `no-plan` noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode()); }); // Handshake accepté : nom, version et capacités ont changé — l'état publié se recompose. @@ -69,7 +70,12 @@ Plan SocketScheduler::getPlan(const SurplusContext &ctx) envoyer(ctx); if (!m_aCache) { - noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache")); + // Pourquoi il n'y a pas de plan : le verdict de la dernière réponse non appliquée + // (abstention, plan expiré ou refusé à la réception), sinon « rien reçu ». + if (m_codeReception.isEmpty()) + noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache")); + else + noterCycle(false, m_codeReception, QStringLiteral("aucun plan en cache — dernière réponse non appliquée")); return ombre; } // Rejugé sur le contexte DE CE CYCLE : maintenant, charges, disponibilités ont pu changer @@ -172,6 +178,9 @@ void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result) break; } + // Le verdict d'une réponse non appliquée devient le code de repli publié (fallbackCode), + // jusqu'à l'issue de la requête suivante ; un plan valide l'efface. + m_codeReception = v.issue == OptimizerPlan::Issue::Valide ? QString() : v.code; switch (v.issue) { case OptimizerPlan::Issue::Valide: { m_cache = result; @@ -220,6 +229,7 @@ void SocketScheduler::onPlanEchoue(quint64 id, const QString &code) emit reponseTracee(id, QVariantMap{{QStringLiteral("error"), QVariantMap{{QStringLiteral("code"), code}}}}, m_chronoRequete.elapsed()); emit echecRequete(code); + m_codeReception.clear(); // aucune réponse utilisable : le repli, s'il vient, dit `no-plan` noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code); } diff --git a/energyplugin/etm/scheduler/socketscheduler.h b/energyplugin/etm/scheduler/socketscheduler.h index 96cd52a..c1374b7 100644 --- a/energyplugin/etm/scheduler/socketscheduler.h +++ b/energyplugin/etm/scheduler/socketscheduler.h @@ -104,7 +104,15 @@ public: /*! \brief \return Vrai si le plan du dernier cycle était celui de l'optimiseur. */ bool optimiseurAuDernierCycle() const { return m_optimiseurAuCycle; } - /*! \brief \return Code du dernier cycle rendu au rule-based (vide si optimiseur). */ + /*! + * \brief Code du dernier cycle rendu au rule-based — \c GetPlan.fallbackCode. + * + * Plan en cache rejugé non applicable : le code de ce verdict (\c expired, + * \c no-current-slot…). Pas de plan en cache : le verdict de la dernière réponse NON + * appliquée (abstention, expiré ou refusé à la réception), gardé jusqu'à l'issue de la requête + * suivante ; \c no-plan si rien n'a été reçu (aucune réponse, délai, connexion perdue). + * \return Le code ; vide si l'optimiseur a commandé. + */ QString dernierCodeDeRepli() const { return m_codeCycle; } /*! @@ -180,6 +188,7 @@ private: bool m_optimiseurAuCycle = false; QString m_codeCycle = QStringLiteral("no-plan"); bool m_cycleNote = false; + QString m_codeReception; // verdict de la dernière réponse non appliquée, vide sinon OptimizerPlan::PlanRecu m_planApplique; QString m_dernierPlanRecu; // planId de la dernière réponse valide annoncée QHash m_motifVu; // loadId → dernière ligne de motif annoncée diff --git a/tests/auto/refoptimizer/testrefoptimizer.cpp b/tests/auto/refoptimizer/testrefoptimizer.cpp index af4d104..08e0957 100644 --- a/tests/auto/refoptimizer/testrefoptimizer.cpp +++ b/tests/auto/refoptimizer/testrefoptimizer.cpp @@ -624,6 +624,10 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow_data() QTest::addColumn("redemarrer"); QTest::addColumn("lignes"); QTest::addColumn("notifications"); + // GetPlan.fallbackCode après chacun des quatre cycles (vide = absent : l'optimiseur commande). + // Le verdict d'une réponse non appliquée reste le code de repli jusqu'à l'issue de la requête + // suivante ; `no-plan` ne dit que « rien reçu » (délai dépassé, connexion perdue). + QTest::addColumn("replis"); const QStringList abstention{"optimizer|vivant|0|", "rules|vivant|0|", "rules|vivant|0|", "optimizer|vivant|0|"}; const QStringList unePause{"attendu|vivant|rules", "attendu|vivant|optimizer"}; @@ -633,29 +637,37 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow_data() }; const QStringList mortPuisVivant{"attendu|vivant|rules", "attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}; - QTest::newRow("abstain") << "abstain" << false << abstention << unePause; - QTest::newRow("expired") << "expired" << false << abstention << unePause; - QTest::newRow("invalid") << "invalid" << false << invalide("malformed-plan") << mortPuisVivant; - QTest::newRow("unknown-load") << "unknown-load" << false << invalide("unknown-load") << mortPuisVivant; - QTest::newRow("forbidden-kind") << "forbidden-kind" << false << invalide("kind-mismatch") << mortPuisVivant; - QTest::newRow("missing-load") << "missing-load" << false << invalide("incomplete-slot") << mortPuisVivant; + auto verdict = [](const QString &code) { return QStringList{"", code, code, ""}; }; + QTest::newRow("abstain") << "abstain" << false << abstention << unePause << verdict("abstain"); + QTest::newRow("expired") << "expired" << false << abstention << unePause << verdict("expired"); + QTest::newRow("invalid") << "invalid" << false << invalide("malformed-plan") << mortPuisVivant + << verdict("malformed-plan"); + QTest::newRow("unknown-load") << "unknown-load" << false << invalide("unknown-load") << mortPuisVivant + << verdict("unknown-load"); + QTest::newRow("forbidden-kind") << "forbidden-kind" << false << invalide("kind-mismatch") << mortPuisVivant + << verdict("kind-mismatch"); + QTest::newRow("missing-load") << "missing-load" << false << invalide("incomplete-slot") << mortPuisVivant + << verdict("incomplete-slot"); // slow (12 s > 10 s) : le premier délai dépassé garde le plan en cache, le second tue. QTest::newRow("slow") << "slow" << false << QStringList{"optimizer|vivant|1|timeout", "rules|mort|2|timeout", "rules|vivant|0|timeout", "optimizer|vivant|0|timeout"} - << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}; + << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"} + << QStringList{"", "", "no-plan", ""}; // drop : la connexion fermée tue tout de suite (§7 « socket fermé »), l'échec de la requête // en vol est compté (`disconnected`). La reconnexion qui suit n'est pas une nouvelle perte : // le dernier code reste celui de l'incident. QTest::newRow("drop") << "drop" << false << QStringList{"rules|mort|1|disconnected", "rules|mort|2|disconnected", "rules|vivant|0|disconnected", "optimizer|vivant|0|disconnected"} - << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}; + << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"} + << QStringList{"", "no-plan", "no-plan", ""}; // wrong-major : au handshake suivant (optimiseur redémarré) ; aucune requête ne part. QTest::newRow("wrong-major") << "wrong-major" << true << QStringList{"rules|mort|0|major-mismatch", "rules|mort|0|major-mismatch", "rules|vivant|0|major-mismatch", "optimizer|vivant|0|major-mismatch"} - << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}; + << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"} + << QStringList{"no-plan", "no-plan", "no-plan", ""}; } void TestRefOptimizer::testEachFaultModeGivesItsRow() @@ -664,6 +676,7 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow() QFETCH(bool, redemarrer); QFETCH(QStringList, lignes); QFETCH(QStringList, notifications); + QFETCH(QStringList, replis); monter(Banc::Complet); if (QTest::currentTestFailed()) return; @@ -684,6 +697,7 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow() QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged); QStringList obtenu; + QStringList replisObtenus; ecrireMode(mode); if (redemarrer) { ref.arreter(); @@ -716,13 +730,19 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow() cycle(t); } obtenu << ligneStatut(); + const QVariantMap publie = rpc("GetPlan"); + // fallbackCode n'accompagne que le rule-based ; l'optimiseur qui commande n'en a pas. + QCOMPARE(publie.value("strategy").toString() == "rules", publie.contains("fallbackCode")); + replisObtenus << publie.value("fallbackCode").toString(); } QCOMPARE(obtenu, lignes); QCOMPARE(triplets(spy), notifications); - // Le plan publié dit qu'aucun plan d'optimiseur ne commande, et pourquoi — au cycle repli. + // Le plan publié dit qu'aucun plan d'optimiseur ne commande, et pourquoi. + QCOMPARE(replisObtenus, replis); if (mode == "slow") QVERIFY2(m_updateMaxMs < 250, qPrintable(QStringLiteral("update() a duré %1 ms").arg(m_updateMaxMs))); - qInfo().noquote() << "[mode]" << mode << obtenu.join(" ; ") << "— update() max" << m_updateMaxMs << "ms"; + qInfo().noquote() << "[mode]" << mode << obtenu.join(" ; ") << "— fallbackCode" << replisObtenus.join(" ; ") + << "— update() max" << m_updateMaxMs << "ms"; } void TestRefOptimizer::testAProtocol10OptimizerIsAccepted() diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index 36490b2..54bc29e 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -473,13 +473,15 @@ void TestSocketScheduler::testRefusedPlanFallsBackToRules_data() QTest::addColumn("cas"); QTest::addColumn("codeJuge"); QTest::addColumn("codeCycle"); - QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "no-plan"; - QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "no-plan"; - QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "no-plan"; - QTest::newRow("reason vide") << "reason" << "missing-reason" << "no-plan"; - QTest::newRow("expiré à la réception") << "expire" << "expired" << "no-plan"; + // codeCycle = GetPlan.fallbackCode : le verdict de la réponse non appliquée, gardé jusqu'à + // l'issue de la requête suivante (correctifs de fin de brief 4, point 3). + QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "incomplete-slot"; + QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "kind-not-announced"; + QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "unknown-load"; + QTest::newRow("reason vide") << "reason" << "missing-reason" << "missing-reason"; + QTest::newRow("expiré à la réception") << "expire" << "expired" << "expired"; QTest::newRow("expiré à l'application") << "expire-tard" << "" << "expired"; - QTest::newRow("abstention") << "abstention" << "abstain" << "no-plan"; + QTest::newRow("abstention") << "abstention" << "abstain" << "abstain"; } void TestSocketScheduler::testRefusedPlanFallsBackToRules() @@ -518,6 +520,9 @@ void TestSocketScheduler::testRefusedPlanFallsBackToRules() cycle(t0.addSecs(60), -3000); QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), codeCycle); + const QVariantMap publie = rpc("GetPlan"); + QCOMPARE(publie.value("strategy").toString(), QString("rules")); + QCOMPARE(publie.value("fallbackCode").toString(), codeCycle); // Jamais appliqué EN PARTIE : aucune charge ne porte le niveau de l'optimiseur. for (const QVariantMap &t : telemetrie()) QVERIFY2(t.value("decision").toMap().value("level").toString() != "optimizer", @@ -758,7 +763,7 @@ void TestSocketScheduler::testANewInvalidPlanReplacesTheCache() QCOMPARE(m_codesJuges.last(), QString("incomplete-slot")); cycle(t0.addSecs(120), -3000); QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); - QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan")); + QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("incomplete-slot")); } void TestSocketScheduler::testConnectionLossDropsThePlan() @@ -1032,7 +1037,7 @@ void TestSocketScheduler::testStatusFollowsTheFallbackTable() m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; }; cycle(t0.addSecs(120), -3000); cycle(t0.addSecs(180), -3000); - QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan")); + QCOMPARE(s->dernierCodeDeRepli(), QString("abstain")); } else if (cas == "expire") { m_repondre = nullptr; cycle(t0.addSecs(200), -3000); // le plan reçu à t0 + 60 valait jusqu'à t0 + 150