powersync-energy-plugin-etm/tests/auto/scheduler/testscheduler.cpp

682 lines
28 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "testscheduler.h"
#include <QSignalSpy>
#include <QDir>
#include <QFile>
#include <QCoreApplication>
#include <algorithm>
// Typical winter day electricity prices (EUR/kWh), hours 00:0023:00
// Cheap night: 0006, morning peak: 0709, midday dip: 1014, evening peak: 1620
const double TestScheduler::winterPrices[24] = {
0.05, 0.05, 0.04, 0.04, 0.04, 0.05, // 0005 cheap night
0.12, 0.16, 0.14, 0.11, 0.09, 0.08, // 0611 morning peak → midday dip
0.07, 0.07, 0.08, 0.09, 0.14, 0.16, // 1217 midday → evening peak
0.18, 0.17, 0.15, 0.12, 0.10, 0.08 // 1823 evening peak → falling
};
TestScheduler::TestScheduler(QObject *parent)
: QObject(parent)
{
}
// ---------------------------------------------------------------------------
// Helper: build 24h winter forecast starting at 'start' (hourly slots)
// ---------------------------------------------------------------------------
QList<EnergyTimeSlot> TestScheduler::buildWinterForecast(const QDateTime &start) const
{
// Solar bell curve peaking at noon (~4 kW, 0 during night)
static const double solarW[24] = {
0, 0, 0, 0, 0, 0, // 0005: no sun
200, 600, 1200, 2400, 3600, 4000, // 0611: rising
4200, 4000, 3600, 2800, 1600, 600, // 1217: peak then falling
0, 0, 0, 0, 0, 0 // 1823: no sun
};
static const double baseW = 1200.0; // constant base load
QList<EnergyTimeSlot> forecast;
for (int h = 0; h < 24; ++h) {
EnergyTimeSlot slot;
slot.start = start.addSecs(h * 3600);
slot.end = start.addSecs((h + 1) * 3600);
// Index by actual UTC hour so solar peaks at noon and prices reflect time-of-day
int utcHour = slot.start.toUTC().time().hour();
slot.solarForecastW = solarW[utcHour];
slot.baseConsumptionW = baseW;
slot.electricityPrice = winterPrices[utcHour];
slot.electricitySellPrice = 0.04;
forecast.append(slot);
}
return forecast;
}
bool TestScheduler::allActiveSlotsHaveReason(const QList<EnergyTimeSlot> &timeline) const
{
foreach (const EnergyTimeSlot &slot, timeline) {
// An "active" slot is one that has any non-zero allocation
bool active = slot.allocatedToEV > 0
|| slot.allocatedToHP > 0
|| slot.allocatedToDHW > 0
|| qAbs(slot.allocatedToBattery) > 0;
if (active && slot.decisionReason.isEmpty())
return false;
// All slots (even passive ones) must have a reason — enforced by fillMissingReasons
if (slot.decisionReason.isEmpty())
return false;
}
return true;
}
int TestScheduler::countSlotsInCheapestN(const QList<EnergyTimeSlot> &timeline,
const QDateTime &deadline,
int n) const
{
// Find all eligible slots before deadline
QList<double> prices;
foreach (const EnergyTimeSlot &slot, timeline) {
if (slot.start < deadline && slot.electricityPrice > 0)
prices.append(slot.electricityPrice);
}
std::sort(prices.begin(), prices.end());
if (n > prices.size())
n = prices.size();
double threshold = (n > 0) ? prices.at(n - 1) : 999.0;
int charged = 0;
foreach (const EnergyTimeSlot &slot, timeline) {
if (slot.allocatedToEV > 0 && slot.start < deadline) {
if (slot.electricityPrice <= threshold)
charged++;
}
}
return charged;
}
// ---------------------------------------------------------------------------
// Test 1 — RuleBasedStrategy, typical winter day
// ---------------------------------------------------------------------------
void TestScheduler::testRuleBasedStrategy_winterDay()
{
// Scenario: EV plugged in at 18:00, deadline next day 07:00, SOC 40%, target 80%
// → Charging should be scheduled in cheap night slots 00:0006:00 and/or 23:00
// Start the forecast at "today 18:00 UTC"
QDateTime base = QDateTime(QDate::currentDate(), QTime(18, 0, 0), Qt::UTC);
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
// EV load: Shiftable, SmartCharging, 7400W (typical 32A/1ph), SOC 40→80%
FlexibleLoad evLoad;
evLoad.thingId = ThingId::createThingId();
evLoad.displayName = QStringLiteral("Test EV");
evLoad.type = LoadType::Shiftable;
evLoad.source = LoadSource::SmartCharging;
evLoad.minPowerW = 1400;
evLoad.maxPowerW = 7400;
evLoad.currentValue = 40;
evLoad.targetValue = 80;
evLoad.priority = 0.7;
evLoad.deadline = base.addSecs(13 * 3600); // 07:00 next day
SchedulerConfig config;
config.chargePriceThreshold = 0.08;
config.solarSurplusThresholdW = 200.0;
RuleBasedStrategy strategy;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {evLoad}, config);
// Verify: 24 slots returned
QCOMPARE(result.size(), 24);
// Verify: all slots have a non-empty decisionReason
QVERIFY2(allActiveSlotsHaveReason(result),
"Some active slots are missing a decisionReason");
// Verify: EV is scheduled somewhere
int evChargingSlots = 0;
foreach (const EnergyTimeSlot &slot, result)
if (slot.allocatedToEV > 0)
evChargingSlots++;
QVERIFY2(evChargingSlots > 0, "EV was never scheduled for charging");
// Verify: at least half of charged slots fall within the cheapest hours before deadline
int cheapSlots = countSlotsInCheapestN(result, evLoad.deadline, 6);
QVERIFY2(cheapSlots >= evChargingSlots / 2,
QString("Only %1/%2 EV slots fall in the 6 cheapest hours")
.arg(cheapSlots).arg(evChargingSlots).toUtf8());
// Verify: decisionReason for EV slots is not generic fallback
foreach (const EnergyTimeSlot &slot, result) {
if (slot.allocatedToEV > 0) {
QVERIFY2(!slot.decisionReason.isEmpty(), "EV charging slot has no reason");
QVERIFY2(!slot.decisionRules.isEmpty(), "EV charging slot has no rules");
}
}
}
// ---------------------------------------------------------------------------
// Test 2 — Manual override is respected
// ---------------------------------------------------------------------------
void TestScheduler::testManualOverrideRespected()
{
QDateTime base = QDateTime(QDate::currentDate(), QTime(18, 0, 0), Qt::UTC);
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
// Override slot at 23:00 (index 5 from 18h = hour 5)
QDateTime overrideStart = base.addSecs(5 * 3600); // 23:00
for (int i = 0; i < forecast.size(); ++i) {
if (forecast.at(i).start == overrideStart) {
forecast[i].manualOverride = true;
forecast[i].overrideReason = QStringLiteral("Départ annulé — ne pas charger ce soir");
forecast[i].allocatedToEV = 0;
forecast[i].decisionReason = QStringLiteral("Décision manuelle : Départ annulé");
forecast[i].decisionRules = QStringList() << QStringLiteral("ManualOverride");
break;
}
}
FlexibleLoad evLoad;
evLoad.thingId = ThingId::createThingId();
evLoad.displayName = QStringLiteral("Test EV");
evLoad.type = LoadType::Shiftable;
evLoad.source = LoadSource::SmartCharging;
evLoad.minPowerW = 1400;
evLoad.maxPowerW = 7400;
evLoad.currentValue = 40;
evLoad.targetValue = 80;
evLoad.priority = 0.7;
evLoad.deadline = base.addSecs(13 * 3600);
SchedulerConfig config;
config.chargePriceThreshold = 0.08;
RuleBasedStrategy strategy;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {evLoad}, config);
// Verify: overridden slot is unchanged
foreach (const EnergyTimeSlot &slot, result) {
if (slot.start == overrideStart) {
QVERIFY2(slot.manualOverride, "Manual override flag was cleared by Scheduler");
QCOMPARE(slot.allocatedToEV, 0.0);
QVERIFY2(slot.decisionRules.contains("ManualOverride"),
"ManualOverride rule was removed");
}
}
// Verify: other slots are still computed (EV is charged elsewhere)
int evChargingSlots = 0;
foreach (const EnergyTimeSlot &slot, result) {
if (slot.start != overrideStart && slot.allocatedToEV > 0)
evChargingSlots++;
}
QVERIFY2(evChargingSlots > 0,
"No EV charging scheduled in non-overridden slots");
}
// ---------------------------------------------------------------------------
// Test 3 — Fallback when all prices = 0 (no tariff data)
// ---------------------------------------------------------------------------
void TestScheduler::testFallbackEmptyTariffData()
{
QDateTime base = QDateTime(QDate::currentDate(), QTime(0, 0, 0), Qt::UTC);
// Build forecast with all prices = 0 (TariffManager returned nothing)
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
for (int i = 0; i < forecast.size(); ++i)
forecast[i].electricityPrice = 0.0;
// Critical HP load (inflexible, priority ≥ 0.9)
FlexibleLoad criticalHP;
criticalHP.thingId = ThingId::createThingId();
criticalHP.displayName = QStringLiteral("Critical Heating");
criticalHP.type = LoadType::Inflexible;
criticalHP.source = LoadSource::HeatPump;
criticalHP.minPowerW = 2000;
criticalHP.maxPowerW = 2000;
criticalHP.currentPowerW= 2000;
criticalHP.priority = 0.95;
SchedulerConfig config;
config.chargePriceThreshold = 0.08; // No slot qualifies (all prices = 0)
RuleBasedStrategy strategy;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {criticalHP}, config);
// Verify: plan is non-empty
QCOMPARE(result.size(), 24);
// Verify: critical HP is allocated in every slot
foreach (const EnergyTimeSlot &slot, result) {
QVERIFY2(slot.allocatedToHP >= criticalHP.currentPowerW,
QString("Critical HP not allocated in slot %1")
.arg(slot.start.toString("hh:mm")).toUtf8());
}
// Verify: all slots have a reason (no crashes, no empty reasons)
QVERIFY2(allActiveSlotsHaveReason(result),
"Some slots are missing decisionReason when tariff data is empty");
}
// ---------------------------------------------------------------------------
// Test 4 — Hot strategy swap (RuleBasedStrategy → AIStrategy stub)
// ---------------------------------------------------------------------------
void TestScheduler::testHotStrategySwap()
{
QDateTime base = QDateTime(QDate::currentDate(), QTime(0, 0, 0), Qt::UTC);
QList<EnergyTimeSlot> forecast = buildWinterForecast(base);
FlexibleLoad evLoad;
evLoad.thingId = ThingId::createThingId();
evLoad.displayName = QStringLiteral("Test EV");
evLoad.type = LoadType::Shiftable;
evLoad.source = LoadSource::SmartCharging;
evLoad.minPowerW = 1400;
evLoad.maxPowerW = 7400;
evLoad.currentValue = 40;
evLoad.targetValue = 80;
evLoad.priority = 0.7;
evLoad.deadline = base.addSecs(13 * 3600);
SchedulerConfig config;
config.chargePriceThreshold = 0.08;
// Step 1: compute with RuleBasedStrategy
RuleBasedStrategy ruleStrategy;
QList<EnergyTimeSlot> ruleResult = ruleStrategy.computeSchedule(forecast, {evLoad}, config);
QCOMPARE(ruleResult.size(), 24);
// Step 2: swap to AIStrategy (stub)
AIStrategy aiStrategy;
QVERIFY2(!aiStrategy.isAvailable(), "AI strategy should report unavailable");
QSignalSpy unavailableSpy(&aiStrategy, &ISchedulingStrategy::strategyUnavailable);
QList<EnergyTimeSlot> aiResult = aiStrategy.computeSchedule(forecast, {evLoad}, config);
// Verify: AI returns a plan of correct size (no crash)
QCOMPARE(aiResult.size(), 24);
// Verify: AI emitted strategyUnavailable signal
QVERIFY2(unavailableSpy.count() > 0, "AIStrategy did not emit strategyUnavailable");
// Verify: every AI slot has a non-empty decisionReason
foreach (const EnergyTimeSlot &slot, aiResult) {
QVERIFY2(!slot.decisionReason.isEmpty(),
"AI stub returned slot without decisionReason");
}
// Verify: AI slot reason contains "model" keyword (fallback message)
const EnergyTimeSlot &firstSlot = aiResult.first();
QVERIFY2(firstSlot.decisionReason.contains("IA", Qt::CaseInsensitive)
|| firstSlot.decisionReason.contains("AI", Qt::CaseInsensitive)
|| firstSlot.decisionReason.contains("modèle", Qt::CaseInsensitive)
|| firstSlot.decisionReason.contains("model", Qt::CaseInsensitive),
"AI stub reason does not mention model/fallback");
// Verify: AI result has AIModelNotLoaded rule
QVERIFY2(firstSlot.decisionRules.contains("AIModelNotLoaded"),
"AI stub missing AIModelNotLoaded rule tag");
}
// ---------------------------------------------------------------------------
// Test 5 — ManualStrategy: slot configured → allocations applied exactly
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_basicSlot()
{
// Slot: today 22:00 UTC → tomorrow 06:00 UTC, ev=2000W, battery=1000W
QDate today = QDate::currentDate();
QDateTime slotStart = QDateTime(today, QTime(22, 0, 0), Qt::UTC);
QDateTime slotEnd = QDateTime(today.addDays(1), QTime(6, 0, 0), Qt::UTC);
ManualSlotConfig cfg;
cfg.start = slotStart;
cfg.end = slotEnd;
cfg.label = QStringLiteral("Recharge VE nuit");
cfg.repeating = false;
cfg.powerAllocations.insert(LoadSource::SmartCharging, 2000.0);
cfg.powerAllocations.insert(LoadSource::Battery, 1000.0);
ManualStrategy strategy;
strategy.setManualSlot(cfg);
// Build a 2-slot forecast covering 22:00 and 23:00
QList<EnergyTimeSlot> forecast;
for (int h = 0; h < 2; ++h) {
EnergyTimeSlot s;
s.start = slotStart.addSecs(h * 3600);
s.end = slotStart.addSecs((h + 1) * 3600);
forecast.append(s);
}
SchedulerConfig config;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
QCOMPARE(result.size(), 2);
// Both slots fall within 22:0006:00 → allocations must match exactly
foreach (const EnergyTimeSlot &slot, result) {
QCOMPARE(slot.allocatedToEV, 2000.0);
QCOMPARE(slot.allocatedToBattery, 1000.0);
QVERIFY2(!slot.decisionReason.isEmpty(), "decisionReason must not be empty");
QVERIFY2(slot.decisionReason.contains("manuel", Qt::CaseInsensitive) ||
slot.decisionReason.contains("Recharge VE nuit"),
"decisionReason should mention manual or label");
QVERIFY2(slot.decisionRules.contains("ManualSlot"),
"decisionRules must contain ManualSlot");
}
}
// ---------------------------------------------------------------------------
// Test 6 — ManualStrategy: no config → fallback with safe reason
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_noConfig_fallback()
{
// No manual slot configured at all
ManualStrategy strategy;
QDateTime base = QDateTime(QDate::currentDate(), QTime(10, 0, 0), Qt::UTC);
QList<EnergyTimeSlot> forecast;
for (int h = 0; h < 3; ++h) {
EnergyTimeSlot s;
s.start = base.addSecs(h * 3600);
s.end = base.addSecs((h + 1) * 3600);
forecast.append(s);
}
SchedulerConfig config;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
QCOMPARE(result.size(), 3);
foreach (const EnergyTimeSlot &slot, result) {
QCOMPARE(slot.allocatedToEV, 0.0);
QCOMPARE(slot.allocatedToBattery, 0.0);
QVERIFY2(!slot.decisionReason.isEmpty(),
"decisionReason must never be empty");
QVERIFY2(slot.decisionReason.contains("Aucune configuration",
Qt::CaseInsensitive) ||
slot.decisionRules.contains("ManualDefault"),
"Slot without config must report ManualDefault");
}
}
// ---------------------------------------------------------------------------
// Test 7 — ManualStrategy: expired slot is ignored, reason set correctly
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_expiredSlot()
{
QDate today = QDate::currentDate();
QDateTime slotStart = QDateTime(today, QTime(14, 0, 0), Qt::UTC);
QDateTime slotEnd = QDateTime(today, QTime(16, 0, 0), Qt::UTC);
ManualSlotConfig cfg;
cfg.start = slotStart;
cfg.end = slotEnd;
cfg.label = QStringLiteral("Créneau dépassé");
cfg.repeating = false;
// Expiry = yesterday → already expired
cfg.expiresAt = QDateTime::currentDateTimeUtc().addDays(-1);
cfg.powerAllocations.insert(LoadSource::SmartCharging, 5000.0);
ManualStrategy strategy;
strategy.setManualSlot(cfg);
QList<EnergyTimeSlot> forecast;
EnergyTimeSlot s;
s.start = slotStart;
s.end = slotStart.addSecs(3600);
forecast.append(s);
SchedulerConfig config;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
QCOMPARE(result.size(), 1);
const EnergyTimeSlot &slot = result.first();
// Expired slot: EV must NOT be charged
QCOMPARE(slot.allocatedToEV, 0.0);
// Reason must clearly state expiry
QVERIFY2(slot.decisionReason.contains("expiré", Qt::CaseInsensitive),
"decisionReason must mention expiry");
QVERIFY2(slot.decisionRules.contains("ExpiredSlot"),
"decisionRules must contain ExpiredSlot");
}
// ---------------------------------------------------------------------------
// Test 8 — ManualStrategy: repeating weekly slot applied to next recurrence
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_repeatingSlot()
{
// Find the most recent Monday as our anchor
QDate anchor = QDate::currentDate();
while (anchor.dayOfWeek() != 1) // Qt: 1 = Monday
anchor = anchor.addDays(-1);
ManualSlotConfig cfg;
cfg.start = QDateTime(anchor, QTime(22, 0, 0), Qt::UTC);
cfg.end = QDateTime(anchor.addDays(1), QTime(6, 0, 0), Qt::UTC); // overnight
cfg.label = QStringLiteral("Recharge hebdomadaire");
cfg.repeating = true;
// No expiresAt — repeats indefinitely
cfg.powerAllocations.insert(LoadSource::SmartCharging, 3000.0);
ManualStrategy strategy;
strategy.setManualSlot(cfg);
// Next Monday 23:00 — should match the repeating window Mon 22:00 → Tue 06:00
QDate nextMonday = anchor.addDays(7);
QDateTime testSlot = QDateTime(nextMonday, QTime(23, 0, 0), Qt::UTC);
QList<EnergyTimeSlot> forecast;
EnergyTimeSlot s;
s.start = testSlot;
s.end = testSlot.addSecs(3600);
forecast.append(s);
SchedulerConfig config;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
QCOMPARE(result.size(), 1);
QVERIFY2(result.first().allocatedToEV > 0,
"Repeating slot must be applied to next weekly recurrence");
QCOMPARE(result.first().allocatedToEV, 3000.0);
QVERIFY2(result.first().decisionReason.contains("Recharge hebdomadaire"),
"decisionReason must contain the slot label");
}
// ---------------------------------------------------------------------------
// Test 9 — ManualStrategy: JSON round-trip (persistence simulation)
// ---------------------------------------------------------------------------
void TestScheduler::testManualStrategy_persistence()
{
QDate today = QDate::currentDate();
ManualSlotConfig original;
original.start = QDateTime(today, QTime(22, 0, 0), Qt::UTC);
original.end = QDateTime(today.addDays(1), QTime(6, 0, 0), Qt::UTC);
original.label = QStringLiteral("Recharge VE nuit");
original.repeating = false;
original.expiresAt = QDateTime::currentDateTimeUtc().addDays(7); // future
original.powerAllocations.insert(LoadSource::SmartCharging, 2000.0);
original.powerAllocations.insert(LoadSource::Battery, 1000.0);
// Serialize → deserialize
QVariantMap json = original.toJson();
ManualSlotConfig restored = ManualSlotConfig::fromJson(json);
QCOMPARE(restored.start, original.start);
QCOMPARE(restored.end, original.end);
QCOMPARE(restored.label, original.label);
QCOMPARE(restored.repeating, original.repeating);
QCOMPARE(restored.expiresAt, original.expiresAt);
QCOMPARE(restored.powerAllocations.value(LoadSource::SmartCharging), 2000.0);
QCOMPARE(restored.powerAllocations.value(LoadSource::Battery), 1000.0);
// Verify the restored config is functional: apply it in a new strategy instance
ManualStrategy strategy;
strategy.setManualSlot(restored);
QList<EnergyTimeSlot> forecast;
EnergyTimeSlot s;
s.start = original.start;
s.end = original.start.addSecs(3600);
forecast.append(s);
SchedulerConfig config;
QList<EnergyTimeSlot> result = strategy.computeSchedule(forecast, {}, config);
QCOMPARE(result.size(), 1);
QCOMPARE(result.first().allocatedToEV, 2000.0);
QCOMPARE(result.first().allocatedToBattery, 1000.0);
QVERIFY2(result.first().decisionRules.contains("ManualSlot"),
"Restored slot must produce ManualSlot rule after round-trip");
}
// ---------------------------------------------------------------------------
// Test A — LoadAdapterRegistry::detectAdapterType (pure static, no ThingManager)
// ---------------------------------------------------------------------------
void TestScheduler::testDetectAdapterType()
{
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::EVCharger,
QStringList() << QStringLiteral("evcharger")),
QStringLiteral("evcharger"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::HeatPump,
QStringList() << QStringLiteral("heating")),
QStringLiteral("sgready"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::HeatPump,
QStringList() << QStringLiteral("relay")),
QStringLiteral("relay"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::DHW,
QStringList() << QStringLiteral("relay")),
QStringLiteral("relay"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::Battery,
QStringList() << QStringLiteral("energystorage")),
QStringLiteral("battery"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::SolarMeter,
QStringList() << QStringLiteral("energymeter")),
QStringLiteral("readonly"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::GridMeter,
QStringList() << QStringLiteral("energymeter")),
QStringLiteral("readonly"));
// Incompatible interface returns empty
QVERIFY(LoadAdapterRegistry::detectAdapterType(LoadRole::Battery,
QStringList() << QStringLiteral("relay")).isEmpty());
}
// ---------------------------------------------------------------------------
// Test B — AdapterSettings round-trip
// ---------------------------------------------------------------------------
void TestScheduler::testAdapterSettings_roundTrip()
{
// Use a temp file to avoid polluting any production settings
const QString tmpPath = QDir::tempPath()
+ QStringLiteral("/nymea_adapter_test_%1.conf")
.arg(QCoreApplication::applicationPid());
// Override path via environment variable
qputenv("NYMEA_ADAPTER_SETTINGS", tmpPath.toUtf8());
{
AdapterSettings s;
QVariantMap params;
params.insert(QStringLiteral("thingId"), QStringLiteral("11111111-1111-1111-1111-111111111111"));
params.insert(QStringLiteral("adapterType"), QStringLiteral("relay"));
params.insert(QStringLiteral("nominalPowerW"), 2000.0);
params.insert(QStringLiteral("enabled"), true);
s.setAssignment(LoadRole::DHW, params);
} // destructor flushes (already saved by setAssignment)
{
AdapterSettings s2;
const QList<QVariantMap> loaded = s2.assignments();
QCOMPARE(loaded.size(), 1);
QCOMPARE(loaded.first().value(QStringLiteral("role")).toString(),
QStringLiteral("DHW"));
QCOMPARE(loaded.first().value(QStringLiteral("adapterType")).toString(),
QStringLiteral("relay"));
QCOMPARE(loaded.first().value(QStringLiteral("nominalPowerW")).toDouble(), 2000.0);
}
// Cleanup
QFile::remove(tmpPath);
qputenv("NYMEA_ADAPTER_SETTINGS", QByteArray());
}
// ---------------------------------------------------------------------------
// Test C — SchedulerManager with null registry does not crash
// ---------------------------------------------------------------------------
void TestScheduler::testSchedulerManager_nullRegistry_noCrash()
{
// SchedulerManager(nullptr registry) must not crash on construction or forceRecompute().
// It will still compute a 24-slot timeline (stub forecast, default strategy).
SchedulerManager mgr(nullptr, nullptr, nullptr, nullptr);
// forceRecompute is called via singleShot in the constructor; give the event loop a tick
QCoreApplication::processEvents();
// The manager builds a 24-slot forecast even with all null managers.
// The registry being nullptr means applyCurrentSlot() is a no-op — verify no crash.
QVERIFY2(mgr.planHealth() == QLatin1String("ok") ||
mgr.planHealth() == QLatin1String("no_forecast") ||
mgr.planHealth() == QLatin1String("degraded"),
"planHealth() must return a valid string");
// forceRecompute() again — must not crash with null registry
mgr.forceRecompute();
QVERIFY(true); // reached here = no crash
}
// ---------------------------------------------------------------------------
// Test D — LoadRole string serialization round-trip
// ---------------------------------------------------------------------------
void TestScheduler::testLoadRole_stringRoundTrip()
{
const QList<LoadRole> roles = {
LoadRole::EVCharger,
LoadRole::DHW,
LoadRole::HeatPump,
LoadRole::Battery,
LoadRole::SolarMeter,
LoadRole::GridMeter
};
foreach (LoadRole role, roles) {
const QString str = loadRoleToString(role);
const LoadRole restored = loadRoleFromString(str);
QVERIFY2(!str.isEmpty(), "loadRoleToString returned empty string");
QCOMPARE(restored, role);
}
}
// ---------------------------------------------------------------------------
// Test runner
// ---------------------------------------------------------------------------
QTEST_MAIN(TestScheduler)