Files
radiantwave/tests/test_schedule_engine.cpp
T
NeilClack 9b57e48969 Fix ScheduleManager start() self-deadlock on cached schedule
start() held mutex_ and then called loadCache(), which re-locks the same
non-recursive mutex. When a schedule cache file exists and parses (the
normal case for a provisioned kiosk), this self-deadlocks and blocks
SDL_AppInit before the first frame, leaving only a black screen and the
compositor cursor.

Narrow the lock to the member setup so loadCache() can take the lock
itself. Add a regression test that starts with a cached payload present.
2026-10-07 08:43:13 -05:00

377 lines
14 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();
}
// Regression: start() must not deadlock when a cache file is present. It used
// to hold mutex_ and then call loadCache(), which re-locked the same
// non-recursive mutex (self-deadlock), blocking SDL_AppInit before the first
// frame was ever presented — the kiosk sat on a black screen.
TEST(ScheduleManagerTest, StartWithCachedPayloadDoesNotDeadlock) {
const std::string dir = tempCacheDir();
setenv("HOME", dir.c_str(), 1);
SchedulePayload p = dailyPayload();
ASSERT_TRUE(radiantwave::sync::schedule_cache::write(p, "\"cached\""));
const std::string dbPath =
(std::filesystem::temp_directory_path() / "rw_schedule_start_test.db")
.string();
std::filesystem::remove(dbPath);
radiantwave::db::DatabaseManager db;
ASSERT_TRUE(db.initialize(dbPath));
radiantwave::schedule::ScheduleManager manager;
manager.start(db, "UTC");
EXPECT_TRUE(manager.hasSchedule());
manager.shutdown();
db.close();
std::filesystem::remove(dbPath);
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");
}