// 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 .
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "testscheduler.h"
#include
#include
#include
#include
#include
// Typical winter day electricity prices (EUR/kWh), hours 00:00–23:00
// Cheap night: 00–06, morning peak: 07–09, midday dip: 10–14, evening peak: 16–20
const double TestScheduler::winterPrices[24] = {
0.05, 0.05, 0.04, 0.04, 0.04, 0.05, // 00–05 cheap night
0.12, 0.16, 0.14, 0.11, 0.09, 0.08, // 06–11 morning peak → midday dip
0.07, 0.07, 0.08, 0.09, 0.14, 0.16, // 12–17 midday → evening peak
0.18, 0.17, 0.15, 0.12, 0.10, 0.08 // 18–23 evening peak → falling
};
TestScheduler::TestScheduler(QObject *parent)
: QObject(parent)
{
}
// ---------------------------------------------------------------------------
// Helper: build 24h winter forecast starting at 'start' (hourly slots)
// ---------------------------------------------------------------------------
QList 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, // 00–05: no sun
200, 600, 1200, 2400, 3600, 4000, // 06–11: rising
4200, 4000, 3600, 2800, 1600, 600, // 12–17: peak then falling
0, 0, 0, 0, 0, 0 // 18–23: no sun
};
static const double baseW = 1200.0; // constant base load
QList 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 &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 &timeline,
const QDateTime &deadline,
int n) const
{
// Find all eligible slots before deadline
QList 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:00–06: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 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 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 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 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 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 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 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 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 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 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 result = strategy.computeSchedule(forecast, {}, config);
QCOMPARE(result.size(), 2);
// Both slots fall within 22:00–06: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 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 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 forecast;
EnergyTimeSlot s;
s.start = slotStart;
s.end = slotStart.addSecs(3600);
forecast.append(s);
SchedulerConfig config;
QList 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 forecast;
EnergyTimeSlot s;
s.start = testSlot;
s.end = testSlot.addSecs(3600);
forecast.append(s);
SchedulerConfig config;
QList 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 forecast;
EnergyTimeSlot s;
s.start = original.start;
s.end = original.start.addSecs(3600);
forecast.append(s);
SchedulerConfig config;
QList 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 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 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)