// SPDX-License-Identifier: GPL-3.0-or-later // // Brief 4 T3 — plan d'un optimiseur : validation, conversion, substitution, comptabilité après // dispatch. Fonctions PURES (OptimizerPlan), éprouvées sans serveur ; le câblage dans l'arbitre // est éprouvé par la suite `socketscheduler`. #include "testloadmodel.h" #include "etm/scheduler/optimizerplan.h" #include "etm/types/decisionreason.h" #include #include #include #include namespace { const QDateTime kMaintenant = QDateTime::fromString(QStringLiteral("2026-06-08T13:00:00Z"), Qt::ISODate); QString iso(const QDateTime &t) { return t.toString(Qt::ISODate); } // Trois charges disponibles — un routeur à paliers, une PAC SG-Ready, une borne — et un // chauffe-eau indisponible (en défaut). SurplusContext contexte() { SurplusContext ctx; ctx.timestamp = kMaintenant; ctx.meter.exportW = 1000; LoadContext ecs; ecs.id = "ecs"; ecs.adapter = "relay-router"; ecs.label = "ECS"; ecs.priority = 1; ecs.telemetry.available = true; ecs.telemetry.currentPowerW = 500; LoadContext pac; pac.id = "pac"; pac.adapter = "sg-ready"; pac.label = "PAC"; pac.priority = 2; pac.declared.estimatedPowerW = {{3, 1500.0}, {4, 3000.0}}; pac.telemetry.available = true; LoadContext ve; ve.id = "ve"; ve.adapter = "evcharger"; ve.label = "VE"; ve.priority = 3; ve.declared.phases = 3; ve.telemetry.available = true; ve.telemetry.pluggedIn = true; LoadContext panne; panne.id = "panne"; panne.adapter = "etmvariableload"; panne.label = "Routeur"; panne.priority = 4; panne.telemetry.available = false; ctx.loads = {ecs, pac, ve, panne}; return ctx; } QVariantMap action(const QString &id, const QString &kind, const QVariantMap &champs) { QVariantMap a{{"loadId", id}, {"kind", kind}, {"reason", QStringLiteral("décision de test pour %1").arg(id)}}; for (auto it = champs.constBegin(); it != champs.constEnd(); ++it) a.insert(it.key(), it.value()); return a; } // Plan VALIDE de référence : un créneau courant qui couvre les trois charges disponibles. QVariantMap planValide() { QVariantList actions{ action("ecs", "setpoint", {{"powerW", 1500}, {"reasonCode", "SURPLUS_SETPOINT"}, {"reasonParams", QVariantMap{{"budgetW", 1600}, {"setpointW", 1500}}}}), action("pac", "state", {{"state", 3}}), action("ve", "setpoint", {{"currentA", 8}, {"phases", 3}, {"reasonCode", "EV_DEADLINE"}}), }; QVariantMap creneau{{"from", iso(kMaintenant.addSecs(-120))}, {"to", iso(kMaintenant.addSecs(900))}, {"actions", actions}, {"expected", QVariantMap{{"gridImportW", 0}}}}; QVariantMap suivant{{"from", iso(kMaintenant.addSecs(900))}, {"to", iso(kMaintenant.addSecs(1800))}, {"actions", QVariantList()}}; return {{"planId", "p-1"}, {"strategy", "test"}, {"validUntil", iso(kMaintenant.addSecs(1800))}, {"slots", QVariantList{creneau, suivant}}, {"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 12}}}}; } // Applique une modification au i-ème action du créneau 0. QVariantMap avecAction(int i, const std::function &f) { QVariantMap p = planValide(); QVariantList creneaux = p.value("slots").toList(); QVariantMap s0 = creneaux[0].toMap(); QVariantList acts = s0.value("actions").toList(); QVariantMap a = acts[i].toMap(); f(a); acts[i] = a; s0["actions"] = acts; creneaux[0] = s0; p["slots"] = creneaux; return p; } QVariantMap avecCreneau(int i, const std::function &f) { QVariantMap p = planValide(); QVariantList creneaux = p.value("slots").toList(); QVariantMap s = creneaux[i].toMap(); f(s); creneaux[i] = s; p["slots"] = creneaux; return p; } } // namespace void TestLoadModel::testOptimizerParamKeysMatchInterface() { // ANTI-DÉRIVE : les clés admises sont confrontées au TABLEAU d'INTERFACE.md, pas à une // copie. Une clé ajoutée d'un seul côté fait échouer ce test. QFile f(QStringLiteral(ETM_INTERFACE_MD)); QVERIFY2(f.open(QIODevice::ReadOnly), ETM_INTERFACE_MD); const QStringList lignes = QString::fromUtf8(f.readAll()).split('\n'); int vus = 0; for (const QString &code : DecisionCode::optimizerEmittableCodes()) { const QString tete = QStringLiteral("| `%1`").arg(code); QString ligne; for (const QString &l : lignes) if (l.startsWith(tete + QLatin1Char(' ')) || l.startsWith(tete + QStringLiteral(" *"))) ligne = l; QVERIFY2(!ligne.isEmpty(), qUtf8Printable(code)); const QString params = ligne.section(QLatin1Char('|'), 2, 2); QStringList documentees; auto it = QRegularExpression(QStringLiteral("`(?:o:)?(\\w+)`")).globalMatch(params); while (it.hasNext()) documentees << it.next().captured(1); QStringList admises = DecisionCode::optimizerParamKeys(code); documentees.sort(); admises.sort(); QVERIFY2(documentees == admises, qUtf8Printable(QStringLiteral("%1 : INTERFACE %2 / code %3") .arg(code, documentees.join(','), admises.join(',')))); ++vus; } QCOMPARE(vus, DecisionCode::optimizerEmittableCodes().size()); QVERIFY(!DecisionCode::optimizerParamKeys(DecisionCode::SurplusSetpoint).isEmpty()); // a mordu QVERIFY(DecisionCode::optimizerParamKeys(DecisionCode::DrawCap).isEmpty()); // hors liste } void TestLoadModel::testOptimizerPlanIsConverted() { const SurplusContext ctx = contexte(); const OptimizerPlan::Verdict v = OptimizerPlan::valider(planValide(), ctx, {}, false); QVERIFY2(v.issue == OptimizerPlan::Issue::Valide, qUtf8Printable(v.code + " " + v.detail)); QCOMPARE(v.plan.planId, QString("p-1")); QCOMPARE(v.plan.indexCourant, 0); QCOMPARE(v.plan.creneaux.count(), 2); // expected et diagnostics CONSERVÉS (app, traces) — ils ne commandent rien. QCOMPARE(v.plan.creneaux.at(0).expected.value("gridImportW").toInt(), 0); QCOMPARE(v.plan.diagnostics.value("status").toString(), QString("optimal")); const QList &a = v.plan.creneaux.at(0).actions; QCOMPARE(a.count(), 3); QCOMPARE(a[0].action.kind, LoadAction::Setpoint); QCOMPARE(a[0].action.powerW, 1500.0); QCOMPARE(a[0].action.reason.code, QString(DecisionCode::SurplusSetpoint)); QCOMPARE(a[0].action.reason.params.value("budgetW").toInt(), 1600); QCOMPARE(a[1].action.kind, LoadAction::State); QCOMPARE(a[1].action.state, 3); QCOMPARE(a[1].action.estimatedPowerW, 1500.0); QCOMPARE(a[1].action.reason.code, QString(DecisionCode::OptimizerDecision)); // sans code reçu QVERIFY(a[1].codeSubstitue); QCOMPARE(a[2].action.currentA, 8.0); QCOMPARE(a[2].action.phaseCount, 3u); QVERIFY(a[2].action.chargingEnabled); QCOMPARE(a[2].action.estimatedPowerW, 8 * 230.0 * 3); for (const OptimizerPlan::ActionRecue &ar : a) { // Q3 — un niveau UNIQUE « optimizer », jamais ceux d'un autre plan. QCOMPARE(ar.action.levels.count(), 1); QCOMPARE(ar.action.levels.first().level, QString("optimizer")); QVERIFY(OptimizerPlan::estActionOptimiseur(ar.action)); QVERIFY(!ar.action.force); // Invariant 12 — le texte va au journal, JAMAIS dans le motif. QVERIFY(ar.texte.startsWith("décision de test")); const QByteArray motif = QJsonDocument::fromVariant(ar.action.reason.toMap()).toJson(); QVERIFY2(!motif.contains("test"), motif.constData()); } } void TestLoadModel::testOptimizerPlanVerdicts_data() { QTest::addColumn("plan"); QTest::addColumn("capabilities"); QTest::addColumn("issue"); QTest::addColumn("code"); const int V = int(OptimizerPlan::Issue::Valide), A = int(OptimizerPlan::Issue::Abstention), E = int(OptimizerPlan::Issue::Expire), I = int(OptimizerPlan::Issue::Invalide); const QVariantMap sans; QTest::newRow("valide") << planValide() << sans << V << QString(); QTest::newRow("abstain") << QVariantMap{{"abstain", true}, {"reason", "recalcul"}} << sans << A << "abstain"; QTest::newRow("plan vide") << QVariantMap{{"slots", QVariantList()}} << sans << A << "empty-plan"; QTest::newRow("slots non liste") << QVariantMap{{"slots", "x"}} << sans << I << "malformed-plan"; QTest::newRow("aucun créneau courant") << avecCreneau(0, [](QVariantMap &s) { s["from"] = iso(kMaintenant.addSecs(60)); }) << sans << A << "no-current-slot"; QVariantMap expire = planValide(); expire["validUntil"] = iso(kMaintenant); QTest::newRow("expiré") << expire << sans << E << "expired"; QVariantMap sansDecalage = planValide(); sansDecalage["validUntil"] = "2026-06-08T14:00:00"; QTest::newRow("validUntil sans décalage") << sansDecalage << sans << I << "bad-time"; QTest::newRow("from ≥ to") << avecCreneau(1, [](QVariantMap &s) { s["to"] = s["from"]; }) << sans << I << "bad-time"; QTest::newRow("chevauchement") << avecCreneau(1, [](QVariantMap &s) { s["from"] = iso(kMaintenant.addSecs(600)); }) << sans << I << "slots-unordered"; // Q1 — option C : une charge disponible absente du créneau courant invalide le plan. QTest::newRow("incomplet (Q1)") << avecCreneau(0, [](QVariantMap &s) { QVariantList a = s["actions"].toList(); a.removeAt(1); s["actions"] = a; }) << sans << I << "incomplete-slot"; QTest::newRow("charge inconnue") << avecAction(0, [](QVariantMap &a) { a["loadId"] = "fantome"; }) << sans << I << "unknown-load"; QTest::newRow("charge inconnue dans un créneau FUTUR") << avecCreneau(1, [](QVariantMap &s) { s["actions"] = QVariantList{action("fantome", "setpoint", {{"powerW", 1}})}; }) << sans << I << "unknown-load"; QTest::newRow("doublon") << avecCreneau(0, [](QVariantMap &s) { QVariantList a = s["actions"].toList(); a.append(a.first()); s["actions"] = a; }) << sans << I << "duplicate-load"; QTest::newRow("kind inconnu") << avecAction(0, [](QVariantMap &a) { a["kind"] = "boost"; }) << sans << I << "unknown-kind"; QTest::newRow("kind state sur un routeur") << avecAction(0, [](QVariantMap &a) { a["kind"] = "state"; a["state"] = 3; }) << sans << I << "kind-mismatch"; QTest::newRow("kind setpoint sur SG-Ready") << avecAction(1, [](QVariantMap &a) { a["kind"] = "setpoint"; a["powerW"] = 1500; }) << sans << I << "kind-mismatch"; QTest::newRow("constraint (réservé)") << avecAction(0, [](QVariantMap &a) { a["kind"] = "constraint"; }) << sans << I << "kind-mismatch"; QTest::newRow("kind non annoncé") << planValide() << QVariantMap{{"kinds", QVariantList{"setpoint"}}} << I << "kind-not-announced"; QTest::newRow("kind annoncé") << planValide() << QVariantMap{{"kinds", QVariantList{"setpoint", "state"}}} << V << QString(); QTest::newRow("capabilities sans kinds") << planValide() << QVariantMap{{"maxHorizonH", 48}} << V << QString(); QTest::newRow("reason vide") << avecAction(0, [](QVariantMap &a) { a["reason"] = " \n "; }) << sans << I << "missing-reason"; QTest::newRow("reason absent") << avecAction(0, [](QVariantMap &a) { a.remove("reason"); }) << sans << I << "missing-reason"; QTest::newRow("powerW négatif") << avecAction(0, [](QVariantMap &a) { a["powerW"] = -5; }) << sans << I << "bad-value"; QTest::newRow("powerW texte") << avecAction(0, [](QVariantMap &a) { a["powerW"] = "1500"; }) << sans << I << "bad-value"; QTest::newRow("state 5") << avecAction(1, [](QVariantMap &a) { a["state"] = 5; }) << sans << I << "bad-value"; QTest::newRow("borne sans consigne") << avecAction(2, [](QVariantMap &a) { a.remove("currentA"); }) << sans << I << "bad-value"; QTest::newRow("borne phases 2") << avecAction(2, [](QVariantMap &a) { a["phases"] = 2; }) << sans << I << "bad-value"; QTest::newRow("borne en watts") << avecAction(2, [](QVariantMap &a) { a.remove("currentA"); a.remove("phases"); a["powerW"] = 4200; }) << sans << V << QString(); // Une action HORS BORNES n'est pas un plan invalide : l'adaptateur l'écrête au dispatch. QTest::newRow("hors bornes : écrêtée, pas refusée") << avecAction(0, [](QVariantMap &a) { a["powerW"] = 99999; }) << sans << V << QString(); } void TestLoadModel::testOptimizerPlanVerdicts() { QFETCH(QVariantMap, plan); QFETCH(QVariantMap, capabilities); QFETCH(int, issue); QFETCH(QString, code); const OptimizerPlan::Verdict v = OptimizerPlan::valider(plan, contexte(), capabilities, !capabilities.isEmpty()); QVERIFY2(int(v.issue) == issue && v.code == code, qUtf8Printable(QStringLiteral("obtenu %1 « %2 » — %3").arg(OptimizerPlan::nomIssue(v.issue), v.code, v.detail))); } void TestLoadModel::testOptimizerReasonIsFiltered() { QStringList supprimes; bool substitue = false; // Hors liste blanche : un optimiseur ne parle pas au nom de la sécurité. DecisionReason r = OptimizerPlan::motifFiltre("DEGRADED_L2", {{"x", 1}}, &supprimes, &substitue); QCOMPARE(r.code, QString(DecisionCode::OptimizerDecision)); QVERIFY(r.params.isEmpty()); QVERIFY(substitue); QCOMPARE(supprimes, QStringList{"x"}); // Inconnu du catalogue, et absent. QCOMPARE(OptimizerPlan::motifFiltre("MON_CODE", {}).code, QString(DecisionCode::OptimizerDecision)); QCOMPARE(OptimizerPlan::motifFiltre(QString(), {}).code, QString(DecisionCode::OptimizerDecision)); // Liste blanche : seules les clés DOCUMENTÉES, et des valeurs nombre / booléen / code. supprimes.clear(); r = OptimizerPlan::motifFiltre("ECO_FLOOR_GRID", {{"gridW", 800}, {"atMaximum", false}, {"regime", "measured"}, {"boundBy", "la limite du disjoncteur"}, // texte libre {"remainingWh", QVariantList{1}}, // ni nombre ni code {"commentaire", "ok"}}, // clé non documentée &supprimes, &substitue); QCOMPARE(r.code, QString(DecisionCode::EcoFloorGrid)); QVERIFY(!substitue); QCOMPARE(r.params, (QVariantMap{{"gridW", 800}, {"atMaximum", false}, {"regime", "measured"}})); supprimes.sort(); QCOMPARE(supprimes, (QStringList{"boundBy", "commentaire", "remainingWh"})); // Un paramètre texte dans une action reçue : supprimé, compté, jamais publié. const QVariantMap p = avecAction(0, [](QVariantMap &a) { a["reasonParams"] = QVariantMap{{"budgetW", 1600}, {"setpointW", "mille cinq cents"}}; }); const OptimizerPlan::Verdict v = OptimizerPlan::valider(p, contexte(), {}, false); QVERIFY(v.issue == OptimizerPlan::Issue::Valide); const OptimizerPlan::ActionRecue &ar = v.plan.creneaux.at(0).actions.at(0); QCOMPARE(ar.action.reason.params, (QVariantMap{{"budgetW", 1600}})); QCOMPARE(ar.parametresSupprimes, QStringList{"setpointW"}); // Texte assaini pour le journal : contrôles retirés, longueur bornée. QCOMPARE(OptimizerPlan::assainirTexte("a\nb\tc
d"), QString("a b c d")); // simplified() ne voit que les BLANCS : un échappement terminal ou une sonnerie passeraient. QCOMPARE(OptimizerPlan::assainirTexte(QStringLiteral("a\x1b[31mb\x07" "c")), QString("a [31mb c")); QCOMPARE(OptimizerPlan::assainirTexte(QString(500, 'x')).size(), OptimizerPlan::TexteMax + 1); QVERIFY(OptimizerPlan::assainirTexte(42).isEmpty()); } void TestLoadModel::testOptimizerForceAndReservedAreIgnored() { QVariantMap p = avecAction(0, [](QVariantMap &a) { a["force"] = true; a["source"] = "grid"; }); p = [&] { QVariantList creneaux = p["slots"].toList(); QVariantMap s0 = creneaux[0].toMap(); s0["expected"] = QVariantMap{{"batterySocPercent", 80}}; creneaux[0] = s0; p["slots"] = creneaux; return p; }(); const OptimizerPlan::Verdict v = OptimizerPlan::valider(p, contexte(), {}, false); QVERIFY(v.issue == OptimizerPlan::Issue::Valide); QVERIFY(!v.plan.creneaux.at(0).actions.at(0).action.force); // §6 : jamais forcé depuis le fil QStringList r = v.champsReserves; r.sort(); QCOMPARE(r, (QStringList{"action.force", "action.source", "expected.batterySocPercent"})); } void TestLoadModel::testOptimizerUnavailableLoadKeepsTheShadow() { const SurplusContext ctx = contexte(); // L'optimiseur commande aussi la charge INDISPONIBLE : le plan reste valide, l'action est // rejetée et l'ombre garde la main sur elle. const QVariantMap p = avecCreneau(0, [](QVariantMap &s) { QVariantList a = s["actions"].toList(); a.append(action("panne", "setpoint", {{"powerW", 2000}})); s["actions"] = a; }); const OptimizerPlan::Verdict v = OptimizerPlan::valider(p, ctx, {}, false); QVERIFY2(v.issue == OptimizerPlan::Issue::Valide, qUtf8Printable(v.code)); QCOMPARE(v.actionsSurIndisponibles, QStringList{"panne"}); // Plan ombre : une action par charge, dont LOAD_UNAVAILABLE pour la charge en défaut. Plan ombre; ombre.strategy = "rule-based"; ombre.planId = "ombre"; ombre.budget.surplusW = 777; // ne doit JAMAIS être publié sous l'optimiseur ombre.draw.committedW = 333; ombre.draw.capBorne = true; ombre.draw.authorisedW = 6000; Slot s; s.from = kMaintenant; s.to = kMaintenant.addSecs(60); for (const char *id : {"ecs", "pac", "ve"}) { LoadAction a; a.loadId = id; a.reason = {DecisionCode::SurplusInsufficient}; a.levels = {LoadAction::Level{"comfort", 0, LoadAction::Surplus, {}, a.reason, {}, false}}; s.actions.append(a); } LoadAction gelee; gelee.loadId = "panne"; gelee.reason = {DecisionCode::LoadUnavailable, {{"frozenW", 400}}}; gelee.estimatedPowerW = 400; gelee.counts = {{CountKey::BudgetAllocated, 400.0}}; s.actions.append(gelee); ombre.timeSlots = {s}; const Plan plan = OptimizerPlan::substituer(ombre, v.plan, ctx); QCOMPARE(plan.strategy, QString("optimizer")); QCOMPARE(plan.planId, QString("p-1")); const Slot &c = plan.timeSlots.first(); QCOMPARE(c.actions.count(), 4); QStringList ordre; for (const LoadAction &a : c.actions) ordre << a.loadId; QCOMPARE(ordre, (QStringList{"ecs", "pac", "ve", "panne"})); // l'ordre de priorité est gardé for (int i = 0; i < 3; ++i) QVERIFY(OptimizerPlan::estActionOptimiseur(c.actions.at(i))); QCOMPARE(c.actions.at(3).reason.code, QString(DecisionCode::LoadUnavailable)); // l'ombre QVERIFY(plan.waterfallEvIds.contains("ve")); // une charge, un commandeur : le proxy la lâche // Q4 — rien de l'ombre n'est gardé du budget ; l'autorisation, elle, vient du contexte. QCOMPARE(plan.budget.surplusW, 0.0); QCOMPARE(plan.draw.committedW, 0.0); QVERIFY(plan.draw.capBorne); QCOMPARE(plan.draw.authorisedW, 6000.0); } void TestLoadModel::testOptimizerFinaliseUsesTheAppliedAction() { const SurplusContext ctx = contexte(); // export 1000 W, l'ECS tire déjà 500 W const OptimizerPlan::Verdict v = OptimizerPlan::valider( avecAction(1, [](QVariantMap &a) { a["state"] = 4; }), ctx, {}, false); QVERIFY(v.issue == OptimizerPlan::Issue::Valide); Plan ombre; ombre.strategy = "rule-based"; Slot s; s.from = kMaintenant; s.to = kMaintenant.addSecs(60); for (const char *id : {"ecs", "pac", "ve"}) { LoadAction a; a.loadId = id; s.actions.append(a); } LoadAction gelee; gelee.loadId = "panne"; gelee.reason = {DecisionCode::LoadUnavailable}; gelee.counts = {{CountKey::BudgetAllocated, 200.0}}; gelee.estimatedPowerW = 200; s.actions.append(gelee); ombre.timeSlots = {s}; Plan plan = OptimizerPlan::substituer(ombre, v.plan, ctx); Slot slot = plan.timeSlots.first(); // Ce que les adaptateurs ont RENDU : l'ECS applique sa demande ; la PAC, verrouillée, reste // en 3 au lieu de 4 ; la borne n'est pas dispatchée (adaptateur disparu). QHash appliquees; LoadAction ecs = slot.actions.at(0); ecs.estimatedPowerW = 1500; appliquees.insert("ecs", ecs); LoadAction pac = slot.actions.at(1); pac.state = 3; pac.estimatedPowerW = 1500; appliquees.insert("pac", pac); const QList e = OptimizerPlan::finaliser(plan, slot, appliquees, {}, ctx); QCOMPARE(e.count(), 2); QCOMPARE(e.at(0).loadId, QString("pac")); QCOMPARE(e.at(0).demande, QString("état 4")); QCOMPARE(e.at(0).applique, QString("état 3")); QCOMPARE(e.at(1).loadId, QString("ve")); QVERIFY(!slot.actions.at(0).clamped); QVERIFY(slot.actions.at(1).clamped); QVERIFY(slot.actions.at(2).clamped); QCOMPARE(slot.actions.at(1).state, 3); // l'action publiée dit l'APPLIQUÉ // Q3/Q4 — budget = export net (1000) + recrédit de l'ECS (500) = 1500, servi dans l'ordre de // priorité : l'ECS (1500 appliqués) le prend en entier, la PAC (1500 appliqués en état 3) // achète tout, la charge gelée (rang 4) compte ses 200 W comme sous le rule-based. QCOMPARE(plan.budget.surplusW, 1000.0); QCOMPARE(plan.budget.recreditedW, 500.0); QCOMPARE(plan.budget.allocatedW, 1700.0); QCOMPARE(plan.budget.remainingW, -200.0); QCOMPARE(plan.budget.surplusW + plan.budget.recreditedW - plan.budget.allocatedW, plan.budget.remainingW); QCOMPARE(plan.draw.committedW, 1500.0); QCOMPARE(plan.draw.remainingW, plan.draw.authorisedW - 1500.0); const LoadAction &a0 = slot.actions.at(0); QCOMPARE(a0.counts, (QMap{{CountKey::BudgetAllocated, 1500.0}})); QCOMPARE(a0.funding, LoadAction::Surplus); QCOMPARE(slot.actions.at(1).counts, (QMap{{CountKey::DrawCommitted, 1500.0}})); QCOMPARE(slot.actions.at(1).funding, LoadAction::Grid); QCOMPARE(slot.actions.at(1).levels.first().funding, LoadAction::Grid); QCOMPARE(slot.actions.at(2).estimatedPowerW, 0.0); for (const LoadAction &a : slot.actions) { double somme = 0; for (double w : a.counts) somme += w; QCOMPARE(somme, a.estimatedPowerW); // Σ counts == estimatedPowerW, partout if (OptimizerPlan::estActionOptimiseur(a)) { QCOMPARE(a.levels.count(), 1); QCOMPARE(a.levels.first().targetW, a.estimatedPowerW); } } // Une charge DÉLESTÉE par L4 est laissée à L4 : ni recréditée, ni comptée, ni marquée. Plan plan2 = OptimizerPlan::substituer(ombre, v.plan, ctx); Slot slot2 = plan2.timeSlots.first(); OptimizerPlan::finaliser(plan2, slot2, appliquees, {"ecs"}, ctx); QCOMPARE(plan2.budget.recreditedW, 0.0); QVERIFY(!slot2.actions.at(0).clamped); // Le surplus que l'ECS délestée ne prend pas revient à la suivante : la PAC est servie à // 1000 W au surplus, et n'achète que le reliquat. QCOMPARE(slot2.actions.at(1).counts, (QMap{{CountKey::BudgetAllocated, 1000.0}, {CountKey::DrawCommitted, 500.0}})); }