feat(§10 étape D, 2/2): la passe éco existe, et le motif dit la trajectoire
Deux passes sur la même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les charges, la hiérarchie éco/confort classe les besoins et surplombe le rang. Une seule commande par charge et par cycle : le niveau éco ne commande pas, il est fusionné dans l'action de la passe confort. Émettre deux fois ferait commuter un relais en passe 1 puis le recommuter en passe 2. ECO_FLOOR_GRID rend lisible la TRAJECTOIRE. La cadence ne monte pas, elle fait un mur — 333 W à six heures, le plafond dès seize minutes — et « 1000 W achetés » seul ne permet pas de prévoir la facture. Avec le reste à livrer et le temps restant, si. Le motif porte aussi le régime (sur quoi on achète) et atMaximum (l'étalement a cessé). R6 est armée à DEUX endroits : le calcul ne conclut jamais « tenue » sous un régime non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas. Un invariant qui ne tient qu'à un seul endroit tient mal. L'autorisation de soutirage borne l'éco : le plancher éco est une raison d'acheter, pas un droit de faire disjoncter. Contre-épreuve : bornage retiré, 1000 W au lieu de 400. Et passe débranchée : un seul niveau au lieu de deux. Recrédit attribué à la première passe où la charge apparaît, par champ explicite — la discipline ne se teste pas, le champ si. Mesuré avant d'être asserté, encore : aucun scénario existant ne portait d'obligation, donc aucune référence. Et l'invariance reste utile là où elle a un sens — aucune charge existante ne déclare d'obligation, donc charging reste identique à la référence ligne à ligne dans une étape qui, par ailleurs, change le comportement. Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
parent
ca7a501797
commit
f2e99a7f50
@ -1,3 +1,36 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm42) trixie; urgency=medium
|
||||
|
||||
* §10 étape D (2/2) — LA PASSE ÉCO EXISTE. Deux passes sur la MÊME liste, MÊME ordre ; ce qui
|
||||
change est la BORNE — l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le
|
||||
confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les CHARGES,
|
||||
la hiérarchie éco/confort classe les BESOINS et surplombe le rang.
|
||||
* UNE SEULE COMMANDE PAR CHARGE ET PAR CYCLE (LM-1003). Le niveau éco ne commande pas : il est
|
||||
fusionné dans l'action de la passe confort. Émettre deux fois ferait commuter un relais en
|
||||
passe 1 puis le recommuter en passe 2 — du court-cycling fabriqué par l'arbitre lui-même.
|
||||
* ECO_FLOOR_GRID REND LISIBLE LA TRAJECTOIRE, PAS L'INSTANT. La cadence ne monte pas, elle fait
|
||||
un mur : 333 W à six heures de l'échéance, le plafond dès seize minutes. Un client qui lit
|
||||
« 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures. Le motif porte donc
|
||||
gridW, remainingWh, targetWh, remainingS, regime et atMaximum — combien, pour quelle
|
||||
obligation, à quelle échéance, sur quoi on achète, et si l'étalement a cessé.
|
||||
* R6 ARMÉE À DEUX ENDROITS : `computeEcoTarget()` ne conclut jamais « tenue » sous un régime
|
||||
non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas. Un invariant qui ne tient
|
||||
qu'à un seul endroit tient mal.
|
||||
* L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO : le plancher éco est une raison d'acheter, pas un
|
||||
droit de faire disjoncter. Contre-épreuve : bornage retiré, 1000 W au lieu de 400.
|
||||
* Recrédit attribué à la PREMIÈRE passe où la charge apparaît, par champ explicite (LM-1004) :
|
||||
la discipline ne se teste pas, le champ si. Le recréditer deux fois rendrait au budget de
|
||||
surplus une puissance que l'éco a financée au réseau.
|
||||
* MESURÉ AVANT D'ÊTRE ASSERTÉ, encore : deux niveaux, Σ targetW == estimatedPowerW,
|
||||
counts ventilés entre budget.allocatedW et draw.committedW. Aucun scénario existant ne
|
||||
portait d'obligation, donc aucune référence — les valeurs ont été relevées d'abord.
|
||||
* INVARIANCE PRÉSERVÉE LÀ OÙ ELLE A ENCORE UN SENS : aucune charge existante ne déclare
|
||||
d'obligation, donc la passe éco ne produit rien pour elles et charging reste IDENTIQUE à la
|
||||
référence ligne à ligne. C'est le garde-fou qui reste utile dans une étape qui, par ailleurs,
|
||||
change le comportement.
|
||||
* Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique, spotmarket 32/32.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 10:05:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm41) trixie; urgency=medium
|
||||
|
||||
* §10 étape D (1/2) — L'ÉTALEMENT UNIFORME, mesuré avant d'être asserté. `computeEcoTarget()`
|
||||
|
||||
@ -232,16 +232,104 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
// le surplus. C'est une marge de CYCLE : elle est prise une fois dans le contexte, jamais
|
||||
// relue, et chaque charge servie l'entame de ce qu'elle a été autorisée à prendre.
|
||||
DrawMargin margeRestante = ctx.drawMargin;
|
||||
|
||||
// ================= PASSE 1 — ÉCO ==========================================================
|
||||
//
|
||||
// MÊME liste, MÊME ordre. Ce qui change entre les deux passes n'est pas le tri, c'est la
|
||||
// BORNE : la passe éco est bornée par l'autorisation de SOUTIRAGE (on achète), la passe
|
||||
// confort par le reliquat de SURPLUS (on dépense ce qui est déjà là).
|
||||
//
|
||||
// L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les CHARGES, la
|
||||
// hiérarchie éco/confort classe les BESOINS et surplombe le rang. Un confort de rang 1 passe
|
||||
// donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme
|
||||
// de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver.
|
||||
QHash<QString, LoadAction::Level> niveauxEco;
|
||||
QSet<QString> recreditAttribue;
|
||||
|
||||
for (const LoadContext &lc : nonEvLoads) {
|
||||
const EcoProgress avancement = m_arbitrator->ecoProgress(lc, ctx.timestamp);
|
||||
const EcoTarget cible = computeEcoTarget(lc.needs, avancement,
|
||||
lc.declared.maxPowerW, ctx.timestamp);
|
||||
if (!cible.applicable)
|
||||
continue; // pas d'obligation : cette charge ne participe qu'au confort (R1)
|
||||
|
||||
LoadAction::Level eco;
|
||||
eco.level = QStringLiteral("eco");
|
||||
eco.hasProgress = true;
|
||||
eco.progress = avancement;
|
||||
|
||||
if (cible.tenue) {
|
||||
// R6 — `ECO_FLOOR_MET` ne peut PAS sortir sous un régime non mesuré, et
|
||||
// `computeEcoTarget()` ne conclut jamais « tenue » dans ce cas : la garde est ici
|
||||
// ET là-bas, parce qu'un invariant qui ne tient qu'à un seul endroit tient mal.
|
||||
Q_ASSERT(avancement.delivered);
|
||||
eco.targetW = 0;
|
||||
eco.counts[CountKey::BudgetAllocated] = 0;
|
||||
eco.reason = { DecisionCode::EcoFloorMet,
|
||||
{{"deliveredWh", qRound(avancement.deliveredWh)},
|
||||
{"targetWh", qRound(avancement.targetWh)}} };
|
||||
niveauxEco.insert(lc.id, eco);
|
||||
continue;
|
||||
}
|
||||
|
||||
// BORNÉE PAR L'AUTORISATION DE SOUTIRAGE. Le plancher éco est une raison d'acheter, pas
|
||||
// un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et
|
||||
// c'est le délestage qui rattrapera ce qui a déjà été engagé.
|
||||
double achatW = cible.watts;
|
||||
if (margeRestante.borne)
|
||||
achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts));
|
||||
if (achatW <= 0)
|
||||
continue; // rien d'achetable ce cycle : pas de niveau éco plutôt qu'un zéro forgé
|
||||
|
||||
eco.targetW = achatW;
|
||||
eco.funding = LoadAction::Grid;
|
||||
eco.counts[CountKey::DrawCommitted] = achatW;
|
||||
eco.reason = { DecisionCode::EcoFloorGrid,
|
||||
{{"gridW", qRound(achatW)},
|
||||
{"remainingWh", qRound(cible.remainingWh)},
|
||||
{"targetWh", qRound(avancement.targetWh)},
|
||||
{"remainingS", static_cast<int>(cible.remainingS)},
|
||||
{"regime", avancement.regime},
|
||||
{"atMaximum", cible.pointDeNonRetour}} };
|
||||
niveauxEco.insert(lc.id, eco);
|
||||
|
||||
// LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et le
|
||||
// champ le rend vérifiable plutôt que discipliné : la discipline ne se teste pas.
|
||||
recreditAttribue.insert(lc.id);
|
||||
|
||||
draw.committedW += achatW;
|
||||
if (margeRestante.borne)
|
||||
margeRestante.watts -= achatW;
|
||||
}
|
||||
|
||||
// ================= PASSE 2 — CONFORT ======================================================
|
||||
for (const LoadContext &lc : nonEvLoads) {
|
||||
if (lc.adapter == QStringLiteral("sg-ready"))
|
||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
||||
else
|
||||
slot.actions.append(emitSetpointAction(
|
||||
decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); // etmvariableload + relay-router + evcharger
|
||||
decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw,
|
||||
recreditAttribue.contains(lc.id)))); // etmvariableload + relay-router + evcharger
|
||||
|
||||
// Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles
|
||||
// consomment la même autorisation, qu'elles aient été bornées par elle ou non. Ne
|
||||
// décompter que les charges qu'on borne offrirait aux suivantes une marge déjà dépensée.
|
||||
// LM-1003 — UNE SEULE COMMANDE PAR CHARGE ET PAR CYCLE. Le niveau éco ne commande pas :
|
||||
// il est fusionné dans l'action que la passe confort vient de produire. Émettre deux
|
||||
// fois ferait commuter un relais en passe 1 puis le recommuter en passe 2 — du
|
||||
// court-cycling fabriqué par l'arbitre lui-même.
|
||||
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
|
||||
LoadAction &la = slot.actions.last();
|
||||
const LoadAction::Level eco = niveauxEco.take(lc.id);
|
||||
la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes
|
||||
la.powerW += eco.targetW;
|
||||
la.estimatedPowerW += eco.targetW;
|
||||
for (auto it = eco.counts.constBegin(); it != eco.counts.constEnd(); ++it)
|
||||
la.counts[it.key()] += it.value();
|
||||
if (eco.targetW > 0)
|
||||
la.chargingEnabled = true;
|
||||
}
|
||||
|
||||
if (margeRestante.borne && !slot.actions.isEmpty())
|
||||
margeRestante.watts -= slot.actions.last().estimatedPowerW;
|
||||
// 3g-2 — c'est ICI que le jeu se constitue, au moment où la décision est prise. Le
|
||||
@ -356,7 +444,8 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
|
||||
RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const LoadContext &lc,
|
||||
double &remainingSurplusW,
|
||||
DrawMargin &margeRestante,
|
||||
PlanBudget &budget, PlanDraw &draw) const
|
||||
PlanBudget &budget, PlanDraw &draw,
|
||||
bool recreditDejaAttribue) const
|
||||
{
|
||||
// Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle
|
||||
// est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir.
|
||||
@ -370,7 +459,11 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
|
||||
// la réserve ne mordait que sur les augmentations, et le motif publié — « aucune charge
|
||||
// n'est servie » — était faux. Attrapé par batteryLevelConsiderationStopCharging au moment
|
||||
// où les bornes ont commencé à exécuter réellement les décisions du waterfall.
|
||||
const double recreditW = m_reserveActive ? 0.0 : lc.telemetry.currentPowerW;
|
||||
// LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et une
|
||||
// seule fois. Le recréditer aussi ici offrirait deux fois la même puissance : une charge
|
||||
// servie en éco verrait sa consommation rendue au budget de surplus qu'elle ne finance pas.
|
||||
const double recreditW = (m_reserveActive || recreditDejaAttribue)
|
||||
? 0.0 : lc.telemetry.currentPowerW;
|
||||
const double budgetW = remainingSurplusW + recreditW;
|
||||
budget.recreditedW += recreditW;
|
||||
|
||||
|
||||
@ -83,6 +83,11 @@ private:
|
||||
*
|
||||
* \param lc Charge \c etmvariableload du SurplusContext.
|
||||
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite.
|
||||
* \param recreditDejaAttribue Vrai si la passe ÉCO a déjà crédité cette charge. Le recrédit
|
||||
* anti-clignotement s'attribue à la PREMIÈRE passe où la charge apparaît et une seule fois
|
||||
* (\rule{LM-1004}) — le recréditer deux fois rendrait au budget de surplus une puissance
|
||||
* que l'éco a financée au réseau. Champ EXPLICITE plutôt que discipline : la discipline ne
|
||||
* se teste pas.
|
||||
* \param[in,out] margeRestante Autorisation de SOUTIRAGE restante (W), consommée le long
|
||||
* de la cascade au même titre que le surplus. \c borne à faux = aucun plafond en vigueur,
|
||||
* ce qui n'est PAS une marge nulle : sans plafond la cascade n'est pas bornée, à marge
|
||||
@ -156,7 +161,8 @@ private:
|
||||
*/
|
||||
SetpointTarget decideSetpoint(const LoadContext &lc, double &remainingSurplusW,
|
||||
DrawMargin &margeRestante,
|
||||
PlanBudget &budget, PlanDraw &draw) const;
|
||||
PlanBudget &budget, PlanDraw &draw,
|
||||
bool recreditDejaAttribue = false) const;
|
||||
|
||||
/*!
|
||||
* \brief Forme la commande à partir d'une cible décidée — et forme le motif.
|
||||
|
||||
@ -6805,6 +6805,116 @@ void Simulation::testEcoProgressBaseAndRegime()
|
||||
#endif
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief [§10 étape D] La passe ÉCO achète, et le motif dit la TRAJECTOIRE.
|
||||
*
|
||||
* \par Deux passes, une commande
|
||||
* Même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco,
|
||||
* le reliquat de surplus pour le confort. Les deux niveaux sont publiés, mais **une seule
|
||||
* commande** part au matériel (\rule{LM-1003}) : émettre deux fois ferait commuter un relais en
|
||||
* passe 1 puis le recommuter en passe 2, du court-cycling fabriqué par l'arbitre lui-même.
|
||||
*
|
||||
* \par Ce que le motif doit permettre, et pourquoi
|
||||
* `ECO_FLOOR_GRID` est lu le matin où la facture monte. Les watts de l'instant n'y suffisent
|
||||
* pas : la cadence fait un mur — 333 W à six heures de l'échéance, le plafond dès seize minutes.
|
||||
* Un client qui lit « 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures ;
|
||||
* avec le RESTE à livrer et le TEMPS restant, il le peut.
|
||||
*/
|
||||
void Simulation::testEcoPassBuysAndSaysTheTrajectory()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
RuleBasedScheduler sched(arb);
|
||||
|
||||
auto planifier = [&](const QDateTime &at, bool mesurable, double livreWh,
|
||||
double margeW) -> LoadAction {
|
||||
SurplusContext ctx;
|
||||
ctx.timestamp = at;
|
||||
ctx.meter.importW = 500; // la maison ACHÈTE : aucun surplus
|
||||
ctx.drawMargin.borne = true;
|
||||
ctx.drawMargin.watts = margeW;
|
||||
ctx.drawMargin.source = DrawCapSource::Breaker;
|
||||
ctx.drawMargin.phase = 0;
|
||||
|
||||
LoadContext ecs;
|
||||
ecs.id = QStringLiteral("ecs-%1-%2").arg(mesurable).arg(qRound(margeW));
|
||||
ecs.label = "ECS";
|
||||
ecs.adapter = "etmvariableload";
|
||||
ecs.priority = 1;
|
||||
ecs.declared.maxPowerW = 3000;
|
||||
ecs.needs.minEnergyWhPerDay = 2000;
|
||||
ecs.needs.dailyDeadline = "07:00";
|
||||
ecs.telemetry.sessionMeasurable = mesurable;
|
||||
ecs.telemetry.sessionWh = livreWh;
|
||||
ctx.loads.append(ecs);
|
||||
|
||||
const Plan plan = sched.getPlan(ctx);
|
||||
return plan.timeSlots.first().actions.first();
|
||||
};
|
||||
|
||||
const QDateTime deuxHeuresAvant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
|
||||
|
||||
// 1. DEUX NIVEAUX, et l'éco d'abord — l'ordre des passes, pas celui des rangs.
|
||||
const LoadAction a = planifier(deuxHeuresAvant, true, 0, 5000);
|
||||
QCOMPARE(a.levels.count(), 2);
|
||||
QCOMPARE(a.levels.at(0).level, QString("eco"));
|
||||
QCOMPARE(a.levels.at(1).level, QString("comfort"));
|
||||
|
||||
// 2. UNE SEULE COMMANDE, dont la puissance est la SOMME des niveaux. C'est l'invariant qui
|
||||
// rend la charge utile vérifiable — et LM-1003 qui rend le matériel sûr.
|
||||
QCOMPARE(qRound(a.estimatedPowerW), 1000); // 2 kWh en 2 h
|
||||
double sommeNiveaux = 0;
|
||||
for (const LoadAction::Level &n : a.levels)
|
||||
sommeNiveaux += n.targetW;
|
||||
QCOMPARE(qRound(sommeNiveaux), qRound(a.estimatedPowerW));
|
||||
|
||||
// 3. LE FINANCEMENT VIT DANS LE NIVEAU (R2). L'éco est acheté, le confort ne l'est pas —
|
||||
// un `funding` par CHARGE serait indécidable ici, et c'est tout l'objet du changement.
|
||||
QCOMPARE(a.levels.at(0).funding, LoadAction::Grid);
|
||||
QCOMPARE(a.levels.at(1).funding, LoadAction::Surplus);
|
||||
QCOMPARE(qRound(a.counts.value(CountKey::DrawCommitted)), 1000);
|
||||
|
||||
// 4. LA TRAJECTOIRE, et pas seulement l'instant. Chacun de ces champs répond à une question
|
||||
// qu'un client se posera devant sa facture : combien, pour quoi, et jusqu'à quand.
|
||||
const QVariantMap p = a.levels.at(0).reason.params;
|
||||
QCOMPARE(a.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid));
|
||||
QCOMPARE(p.value("gridW").toInt(), 1000);
|
||||
QCOMPARE(p.value("remainingWh").toInt(), 2000);
|
||||
QCOMPARE(p.value("targetWh").toInt(), 2000);
|
||||
QCOMPARE(p.value("remainingS").toInt(), 7200);
|
||||
QCOMPARE(p.value("regime").toString(), QString("measured"));
|
||||
QCOMPARE(p.value("atMaximum").toBool(), false);
|
||||
|
||||
// …et le TEXTE le dit, sans quoi le motif ne serait lisible que par un programme.
|
||||
const QString texte = renderFr(a.levels.at(0).reason, "ECS");
|
||||
QVERIFY2(texte.contains("2000 Wh"), qUtf8Printable("motif: " + texte));
|
||||
QVERIFY2(texte.contains("2 h"), qUtf8Printable("motif: " + texte));
|
||||
|
||||
// 5. L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO. Le plancher éco est une raison d'acheter, pas
|
||||
// un droit de faire disjoncter : marge à 400 W, on n'achète que 400.
|
||||
const LoadAction borne = planifier(deuxHeuresAvant, true, 0, 400);
|
||||
QCOMPARE(qRound(borne.levels.at(0).targetW), 400);
|
||||
|
||||
// 6. R6 — SOUS `unmeasurable`, JAMAIS « tenue ». Même avec 3 kWh au compteur de session, on
|
||||
// ne sait pas ce qui a été livré POUR CETTE OBLIGATION : conclure serait affirmer sur ce
|
||||
// qu'on ignore, et une échéance qui s'affiche comme tenue sans l'être rassure à tort.
|
||||
const LoadAction inconnu = planifier(deuxHeuresAvant, false, 3000, 5000);
|
||||
QCOMPARE(inconnu.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid));
|
||||
QVERIFY2(inconnu.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMet),
|
||||
"ECO_FLOOR_MET est interdit sous un régime non mesuré (R6)");
|
||||
QCOMPARE(inconnu.levels.at(0).progress.regime, QString(ProgressRegime::Unmeasurable));
|
||||
QVERIFY2(!inconnu.levels.at(0).progress.delivered,
|
||||
"sous unmeasurable, deliveredWh n'a pas de sens et ne sera pas publié");
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testCountsSumToTarget()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
|
||||
@ -195,6 +195,7 @@ private slots:
|
||||
void testDrawCapBoundsTheCascade();
|
||||
void testDrawAuthorisationSurvivesTheBatteryReserve();
|
||||
void testEcoProgressBaseAndRegime();
|
||||
void testEcoPassBuysAndSaysTheTrajectory();
|
||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user