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.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
#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();
|
||||
}
|
||||
Reference in New Issue
Block a user