Files
radiantwave/tests/test_schedule_engine.cpp
T
NeilClack 509c925480 Kiosk: schedule client (conditional GET + atomic cache) and engine
Adds api_client support for GET /licenses/{key}/schedule with If-None-Match
and target-payload parsing (version/type/daily/weekly + legacy blocks), an
atomic on-disk schedule cache, a pure schedule engine (half-open blocks,
midnight self-wrap for daily, Sunday->Monday spillover for weekly, invalid
blocks skipped with a warning), and a background ScheduleManager that loads
the cache offline and pulls on boot and every 5 minutes. Adds engine/cache
tests against the shared docs/fixtures, including 304, corrupt cache, and
offline cases.
2026-09-29 10:34:40 -05:00

299 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
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();
}