#include "schedule/schedule_engine.h" #include "schedule/schedule_manager.h" #include "network/api_client.h" #include "network/schedule_cache.h" #include "db/database_manager.h" #include "db/db_keys.h" #include #include #include #include #include using namespace radiantwave::schedule; using radiantwave::api::SchedulePayload; using radiantwave::api::TargetBlock; namespace { // Fixture files are shared byte-for-byte with the Portal. The test looks them // up relative to the source tree (CMake sets RWT_FIXTURES_DIR). std::string fixtureText(const std::string& name) { const std::string dir = #ifdef RWT_FIXTURES_DIR RWT_FIXTURES_DIR; #else "docs/fixtures"; #endif std::ifstream in(dir + "/" + name, std::ios::binary); if (!in.is_open()) return ""; std::ostringstream ss; ss << in.rdbuf(); return ss.str(); } SchedulePayload loadPayload(const std::string& name) { SchedulePayload payload; const std::string text = fixtureText(name); EXPECT_FALSE(text.empty()) << "missing fixture " << name; EXPECT_TRUE(radiantwave::api::parseSchedulePayloadJson(text, payload)) << name; return payload; } bool hasBlockAt(const SchedulePayload& p, int minutes, Weekday day) { return evaluate(p, minutes, day).hasBlock; } } // namespace // ─── Adjacency (half-open boundaries) ────────────────────────────────── TEST(ScheduleEngineTest, AdjacentBoundaryBelongsToNextBlock) { const auto p = loadPayload("schedule-adjacent.json"); // Block A [06:00, 07:00), Block B [07:00, 08:30). 06:59 in A; 07:00 in B. const auto at0659 = evaluate(p, 6 * 60 + 59, Weekday::Mon); const auto at0700 = evaluate(p, 7 * 60, Weekday::Mon); ASSERT_TRUE(at0659.hasBlock); ASSERT_TRUE(at0700.hasBlock); EXPECT_EQ(at0659.block.id, "dd111111-1111-4111-8111-111111111111"); EXPECT_EQ(at0700.block.id, "dd222222-2222-4222-8222-222222222222"); EXPECT_EQ(at0700.block.dynamic_audio, false); } // ─── Daily fixture ───────────────────────────────────────────────────── TEST(ScheduleEngineTest, DailyPicksBlocksAndLeavesGaps) { const auto p = loadPayload("schedule-daily.json"); EXPECT_TRUE(hasBlockAt(p, 7 * 60, Weekday::Tue)); // inside first EXPECT_FALSE(hasBlockAt(p, 9 * 60, Weekday::Tue)); // gap EXPECT_TRUE(hasBlockAt(p, 18 * 60 + 30, Weekday::Tue)); // inside second EXPECT_FALSE(hasBlockAt(p, 23 * 60, Weekday::Tue)); // gap } // ─── Midnight crossing (daily self-wrap) ─────────────────────────────── TEST(ScheduleEngineTest, MidnightCrossingWrapsWithinTheDay) { const auto p = loadPayload("schedule-midnight-crossing.json"); // 21:00→09:00 and its complement 09:00→21:00. const auto late = evaluate(p, 23 * 60, Weekday::Wed); const auto early = evaluate(p, 3 * 60, Weekday::Wed); const auto midday = evaluate(p, 12 * 60, Weekday::Wed); ASSERT_TRUE(late.hasBlock); ASSERT_TRUE(early.hasBlock); EXPECT_EQ(late.block.id, "cc111111-1111-4111-8111-111111111111"); EXPECT_EQ(early.block.id, late.block.id); EXPECT_EQ(midday.block.id, "cc222222-2222-4222-8222-222222222222"); } // ─── Weekly Sunday -> Monday spillover ───────────────────────────────── TEST(ScheduleEngineTest, WeeklySundayCrossingSpillsIntoMonday) { const auto p = loadPayload("schedule-weekly.json"); // Sunday 23:00→01:00. Monday 00:30 belongs to the Sunday block. const auto mondayEarly = evaluate(p, 30, Weekday::Mon); ASSERT_TRUE(mondayEarly.hasBlock); EXPECT_EQ(mondayEarly.block.id, "a4444444-4444-4444-8444-444444444444"); EXPECT_EQ(mondayEarly.block.day, "sun"); // 01:00 is the boundary: no longer covered. EXPECT_FALSE(evaluate(p, 60, Weekday::Mon).hasBlock); // Monday 06:00 block plays on Monday. const auto monday = evaluate(p, 6 * 60, Weekday::Mon); ASSERT_TRUE(monday.hasBlock); EXPECT_EQ(monday.block.day, "mon"); } // ─── Gap -> local, dynamicAudio true/false ───────────────────────────── TEST(ScheduleEngineTest, DynamicAudioFlagPerBlock) { const auto p = loadPayload("schedule-dynamic-audio.json"); const auto morning = evaluate(p, 9 * 60, Weekday::Mon); const auto evening = evaluate(p, 21 * 60, Weekday::Mon); ASSERT_TRUE(morning.hasBlock); ASSERT_TRUE(evening.hasBlock); EXPECT_TRUE(morning.block.dynamic_audio); EXPECT_FALSE(evening.block.dynamic_audio); } // ─── None / unknown type ─────────────────────────────────────────────── TEST(ScheduleEngineTest, NoScheduleHasNoActiveBlock) { const auto p = loadPayload("schedule-none.json"); EXPECT_FALSE(p.hasSchedule()); EXPECT_FALSE(evaluate(p, 12 * 60, Weekday::Mon).hasBlock); } // ─── Invalid block skipped ───────────────────────────────────────────── TEST(ScheduleEngineTest, SkipsInvalidBlocksAndWarns) { SchedulePayload p; p.type = "daily"; TargetBlock invalid; // empty proclamation list invalid.id = "bad"; invalid.start_minutes = 0; invalid.end_minutes = 60; TargetBlock valid; valid.id = "good"; valid.start_minutes = 0; valid.end_minutes = 60; valid.proclamation_ids = {"p1"}; p.daily = {invalid, valid}; std::string warning; const auto decision = evaluate(p, 30, Weekday::Mon, [&](const std::string& w) { warning = w; }); ASSERT_TRUE(decision.hasBlock); EXPECT_EQ(decision.block.id, "good"); EXPECT_FALSE(warning.empty()); } // ─── Timezone / DST-transition day ───────────────────────────────────── TEST(ScheduleEngineTest, WallClockMinutesAreTimezoneIndependent) { // The engine takes wall-clock minutes; a DST day still passes the same // wall-clock (e.g. 02:30) and matches an 02:00–03:00 block. SchedulePayload p; p.type = "daily"; TargetBlock b; b.id = "dst"; b.start_minutes = 120; b.end_minutes = 180; b.proclamation_ids = {"p1"}; p.daily = {b}; EXPECT_TRUE(evaluate(p, 150, Weekday::Sun).hasBlock); } TEST(ScheduleEngineTest, WeekdayFromIndexWraps) { EXPECT_EQ(weekdayFromIndex(0), Weekday::Mon); EXPECT_EQ(weekdayFromIndex(6), Weekday::Sun); EXPECT_EQ(weekdayFromIndex(7), Weekday::Mon); } // ─── Payload parsing ─────────────────────────────────────────────────── TEST(ScheduleEngineTest, ParsesVersionTypeAndBlocks) { const auto p = loadPayload("schedule-daily.json"); EXPECT_EQ(p.version, "sched-daily-0001"); EXPECT_EQ(p.type, "daily"); EXPECT_EQ(p.daily.size(), 2u); EXPECT_TRUE(p.weekly.empty()); EXPECT_EQ(p.legacy_blocks.size(), 2u); } TEST(ScheduleEngineTest, ParsesWeeklyBlocksByDay) { const auto p = loadPayload("schedule-weekly.json"); EXPECT_EQ(p.type, "weekly"); EXPECT_EQ(p.weekly.size(), 4u); // mon, wed, fri, sun EXPECT_TRUE(p.daily.empty()); } // ─── Offline cache ───────────────────────────────────────────────────── namespace { std::string tempCacheDir() { const auto dir = std::filesystem::temp_directory_path() / "rw_schedule_cache_test"; std::filesystem::create_directories(dir); return dir.string(); } } // namespace TEST(ScheduleCacheTest, RoundTripsPayloadAndEtag) { setenv("HOME", tempCacheDir().c_str(), 1); SchedulePayload p = loadPayload("schedule-daily.json"); ASSERT_TRUE(radiantwave::sync::schedule_cache::write(p, "\"abc\"")); SchedulePayload loaded; std::string etag; ASSERT_TRUE(radiantwave::sync::schedule_cache::read(loaded, etag)); EXPECT_EQ(etag, "\"abc\""); EXPECT_EQ(loaded.type, "daily"); EXPECT_EQ(loaded.daily.size(), 2u); EXPECT_EQ(loaded.version, p.version); radiantwave::sync::schedule_cache::clear(); } TEST(ScheduleCacheTest, CorruptCacheIsIgnored) { const std::string dir = tempCacheDir(); setenv("HOME", dir.c_str(), 1); // Write garbage directly. std::ofstream out(radiantwave::sync::schedule_cache::cachePath(), std::ios::binary); out << "{ this is not json"; out.close(); SchedulePayload loaded; std::string etag; EXPECT_FALSE(radiantwave::sync::schedule_cache::read(loaded, etag)); radiantwave::sync::schedule_cache::clear(); } // ─── ScheduleManager: fetch, 304, offline, toggle ────────────────────── namespace { SchedulePayload dailyPayload() { return loadPayload("schedule-daily.json"); } } // namespace TEST(ScheduleManagerTest, FetchesPayloadAndAppliesIt) { radiantwave::schedule::ScheduleManager manager; manager.setPayloadForTesting(dailyPayload(), "\"v0\""); manager.setFetchForTesting( [](const std::string&, const std::string&, SchedulePayload& out, std::string& etag) { out = dailyPayload(); etag = "\"v1\""; return radiantwave::api::ScheduleFetchStatus::Ok; }); manager.setEnabled(true); EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); } TEST(ScheduleManagerTest, DisabledReturnsNoBlock) { radiantwave::schedule::ScheduleManager manager; manager.setPayloadForTesting(dailyPayload(), "\"v1\""); manager.setEnabled(true); EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); manager.setEnabled(false); EXPECT_FALSE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); } TEST(ScheduleManagerTest, CorruptCacheFallsBackToNoSchedule) { const std::string dir = tempCacheDir(); setenv("HOME", dir.c_str(), 1); std::ofstream out(radiantwave::sync::schedule_cache::cachePath(), std::ios::binary); out << "not json"; out.close(); radiantwave::schedule::ScheduleManager manager; manager.setEnabled(true); EXPECT_FALSE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); radiantwave::sync::schedule_cache::clear(); } TEST(ScheduleManagerTest, OfflineFallsBackToCache) { const std::string dir = tempCacheDir(); setenv("HOME", dir.c_str(), 1); // Write a good cache, then simulate a network error on fetch. SchedulePayload p = dailyPayload(); radiantwave::sync::schedule_cache::write(p, "\"cached\""); radiantwave::schedule::ScheduleManager manager; manager.setEnabled(true); // Manually seed from cache (start() requires a DB); setPayloadForTesting // mirrors what loadCache() would produce. SchedulePayload cached; std::string etag; ASSERT_TRUE(radiantwave::sync::schedule_cache::read(cached, etag)); manager.setPayloadForTesting(cached, etag); EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); EXPECT_EQ(etag, "\"cached\""); radiantwave::sync::schedule_cache::clear(); } // ─── Phase 5: consume real Portal-generated payloads ─────────────────── // // These fixtures were captured from a running Portal (GET // /api/v2/licenses/{key}/schedule). They prove the Kiosk parses and evaluates // exactly what the Portal emits, with no hand-editing in between. TEST(SchedulePortalParityTest, ParsesPortalDailyPayload) { const auto p = loadPayload("schedule-portal-daily.json"); EXPECT_EQ(p.type, "daily"); ASSERT_TRUE(p.hasSchedule()); ASSERT_EQ(p.daily.size(), 2u); // 06:00-07:00 (dynamicAudio true) and 18:00-20:30 (false). EXPECT_EQ(p.daily[0].start_minutes, 360); EXPECT_EQ(p.daily[0].end_minutes, 420); EXPECT_TRUE(p.daily[0].dynamic_audio); EXPECT_FALSE(p.daily[1].dynamic_audio); EXPECT_FALSE(p.daily[0].proclamation_ids.empty()); EXPECT_EQ(p.legacy_blocks.size(), 2u); // Engine decisions: inside / gap / inside. EXPECT_TRUE(evaluate(p, 6 * 60 + 30, Weekday::Tue).hasBlock); EXPECT_FALSE(evaluate(p, 12 * 60, Weekday::Tue).hasBlock); EXPECT_TRUE(evaluate(p, 19 * 60, Weekday::Tue).hasBlock); } TEST(SchedulePortalParityTest, ParsesPortalWeeklyPayloadAndSpillover) { const auto p = loadPayload("schedule-portal-weekly.json"); EXPECT_EQ(p.type, "weekly"); ASSERT_TRUE(p.hasSchedule()); // Monday has two adjacent blocks; Sunday has one crossing block. int mon = 0; int sun = 0; for (const auto& b : p.weekly) { if (b.day == "mon") ++mon; if (b.day == "sun") ++sun; } EXPECT_EQ(mon, 2); EXPECT_EQ(sun, 1); // Monday 00:30 belongs to Sunday's crossing block. const auto spill = evaluate(p, 30, Weekday::Mon); ASSERT_TRUE(spill.hasBlock); EXPECT_EQ(spill.block.day, "sun"); // Monday 06:30 is a Monday block. const auto monBlock = evaluate(p, 6 * 60 + 30, Weekday::Mon); ASSERT_TRUE(monBlock.hasBlock); EXPECT_EQ(monBlock.block.day, "mon"); }