// 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)