Add schedule sync and schedule-aware playback

This commit is contained in:
NeilClack
2026-09-11 19:14:55 -05:00
parent a043ec84d8
commit f37beaf07e
23 changed files with 893 additions and 32 deletions
+52
View File
@@ -113,6 +113,58 @@ TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) {
EXPECT_FALSE(parseCreateAccountJson("[]", account));
}
TEST(ApiClientTest, ParseScheduleJson) {
const std::string body =
R"({"email":"jane@example.com","blocks":[
{"id":"b1","startTime":"06:00","startMinutes":360,"endTime":"08:00",
"endMinutes":480,"overnight":false,"durationMinutes":120,
"dynamicAudio":false,"proclamationIds":["p1","p2"]},
{"id":"b2","startTime":"21:00","startMinutes":1260,"endTime":"09:00",
"endMinutes":540,"overnight":true,"durationMinutes":720,
"dynamicAudio":true,"proclamationIds":[]}]})";
ScheduleResponse out;
EXPECT_TRUE(parseScheduleJson(body, out));
EXPECT_EQ(out.email, "jane@example.com");
ASSERT_EQ(out.blocks.size(), 2u);
EXPECT_EQ(out.blocks[0].id, "b1");
EXPECT_EQ(out.blocks[0].start_minutes, 360);
EXPECT_EQ(out.blocks[0].end_minutes, 480);
EXPECT_FALSE(out.blocks[0].overnight);
EXPECT_EQ(out.blocks[0].duration_minutes, 120);
EXPECT_FALSE(out.blocks[0].dynamic_audio);
ASSERT_EQ(out.blocks[0].proclamation_ids.size(), 2u);
EXPECT_EQ(out.blocks[0].proclamation_ids[0], "p1");
EXPECT_EQ(out.blocks[0].proclamation_ids[1], "p2");
EXPECT_EQ(out.blocks[1].id, "b2");
EXPECT_TRUE(out.blocks[1].overnight);
EXPECT_TRUE(out.blocks[1].dynamic_audio);
EXPECT_TRUE(out.blocks[1].proclamation_ids.empty());
}
TEST(ApiClientTest, ParseScheduleDefaultsDynamicAudioTrue) {
const std::string body =
R"({"email":"a@b.com","blocks":[
{"id":"b1","startMinutes":0,"endMinutes":60,"overnight":false,
"durationMinutes":60,"proclamationIds":["p1"]}]})";
ScheduleResponse out;
EXPECT_TRUE(parseScheduleJson(body, out));
ASSERT_EQ(out.blocks.size(), 1u);
EXPECT_TRUE(out.blocks[0].dynamic_audio);
}
TEST(ApiClientTest, ParseScheduleRejectsMalformed) {
ScheduleResponse out;
EXPECT_FALSE(parseScheduleJson("not json", out));
EXPECT_FALSE(parseScheduleJson("[1,2,3]", out)); // array, not object
// Missing id blocks are skipped, not fatal.
EXPECT_TRUE(parseScheduleJson(R"({"blocks":[{"startMinutes":0}]})", out));
EXPECT_TRUE(out.blocks.empty());
}
// ─── Offline: error message extraction ────────────────────────────────
TEST(ApiClientTest, ExtractErrorMessage) {
+3
View File
@@ -44,6 +44,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
original.audio.volume = 60;
original.audio.dynamic_audio = false;
original.proclamation.scroll_speed = 1.8f;
original.proclamation.schedule_active = true;
std::string serialized = serializeConfigToYaml(original);
AppConfig parsed = parseConfigFromYaml(serialized);
@@ -53,6 +54,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
EXPECT_EQ(parsed.audio.volume, original.audio.volume);
EXPECT_EQ(parsed.audio.dynamic_audio, original.audio.dynamic_audio);
EXPECT_FLOAT_EQ(parsed.proclamation.scroll_speed, original.proclamation.scroll_speed);
EXPECT_TRUE(parsed.proclamation.schedule_active);
}
TEST(ConfigManagerTest, DefaultGeneration) {
@@ -68,6 +70,7 @@ TEST(ConfigManagerTest, DefaultGeneration) {
EXPECT_EQ(cfg.audio.volume, 80);
EXPECT_EQ(cfg.audio.dynamic_audio, true);
EXPECT_EQ(cfg.proclamation.font_size, 32);
EXPECT_FALSE(cfg.proclamation.schedule_active);
EXPECT_TRUE(std::filesystem::exists(temp_path));
std::filesystem::remove(temp_path);
+48
View File
@@ -347,6 +347,54 @@ TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) {
EXPECT_EQ(fx.db.getAudioFiles().size(), 0u);
}
TEST(DatabaseManagerTest, ReplaceAndReadScheduleBlocks) {
DbFixture fx("test_rw_db_schedule.db");
ScheduleBlockRecord morning;
morning.id = "b1";
morning.start_minutes = 360;
morning.end_minutes = 480;
morning.overnight = false;
morning.duration_minutes = 120;
morning.dynamic_audio = false;
morning.proclamation_ids = {"p2", "p1"};
ScheduleBlockRecord night;
night.id = "b2";
night.start_minutes = 1260;
night.end_minutes = 540;
night.overnight = true;
night.duration_minutes = 720;
night.dynamic_audio = true;
night.proclamation_ids = {"p3"};
ASSERT_TRUE(fx.db.replaceScheduleBlocks({morning, night}));
auto rows = fx.db.getScheduleBlocks();
ASSERT_EQ(rows.size(), 2u);
EXPECT_EQ(rows[0].id, "b1");
EXPECT_EQ(rows[0].start_minutes, 360);
EXPECT_EQ(rows[0].end_minutes, 480);
EXPECT_FALSE(rows[0].overnight);
EXPECT_EQ(rows[0].duration_minutes, 120);
EXPECT_FALSE(rows[0].dynamic_audio);
// Play order is preserved, not sorted.
ASSERT_EQ(rows[0].proclamation_ids.size(), 2u);
EXPECT_EQ(rows[0].proclamation_ids[0], "p2");
EXPECT_EQ(rows[0].proclamation_ids[1], "p1");
EXPECT_EQ(rows[1].id, "b2");
EXPECT_TRUE(rows[1].overnight);
EXPECT_TRUE(rows[1].dynamic_audio);
ASSERT_EQ(rows[1].proclamation_ids.size(), 1u);
EXPECT_EQ(rows[1].proclamation_ids[0], "p3");
// A re-sync replaces the whole schedule.
ASSERT_TRUE(fx.db.replaceScheduleBlocks({}));
EXPECT_EQ(fx.db.getScheduleBlocks().size(), 0u);
}
TEST(DatabaseManagerTest, PalettesSeededAndRead) {
DbFixture fx("test_rw_db_palette.db");
+72
View File
@@ -17,6 +17,7 @@ using radiantwave::scroller::StreamColor;
using radiantwave::scroller::buildStreams;
using radiantwave::scroller::kDefaultRepeatCount;
using radiantwave::scroller::kSelectedRepeatCount;
using radiantwave::scroller::minuteInScheduleBlock;
const std::string kDefaultId = "default";
@@ -185,6 +186,77 @@ TEST(ScrollerStreamsTest, MissingDefaultStillStreamsSelected)
EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount);
}
TEST(ScrollerStreamsTest, MinuteInScheduleBlockDaytime)
{
// 06:00 (360) to 08:00 (480), half-open.
EXPECT_FALSE(minuteInScheduleBlock(359, 360, 480, false));
EXPECT_TRUE(minuteInScheduleBlock(360, 360, 480, false));
EXPECT_TRUE(minuteInScheduleBlock(479, 360, 480, false));
EXPECT_FALSE(minuteInScheduleBlock(480, 360, 480, false));
}
TEST(ScrollerStreamsTest, MinuteInScheduleBlockOvernight)
{
// 21:00 (1260) to 09:00 (540) wraps past midnight.
EXPECT_TRUE(minuteInScheduleBlock(1260, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(1439, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(0, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(539, 1260, 540, true));
EXPECT_FALSE(minuteInScheduleBlock(540, 1260, 540, true));
EXPECT_FALSE(minuteInScheduleBlock(1259, 1260, 540, true));
}
TEST(ScrollerStreamsTest, ScheduledIdsDriveOrderRegardlessOfSelection)
{
ProclamationRecord p1;
p1.id = "one";
p1.title = "One";
p1.content = "x";
p1.selected = false;
ProclamationRecord p2;
p2.id = "two";
p2.title = "Two";
p2.content = "y";
p2.selected = false;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "z";
// Explicit order two -> one, interleaved with the default. Unknown ids are
// skipped; the `selected` flag is ignored.
std::vector<std::string> scheduled = {"two", "missing", "one"};
auto streams = buildStreams({p1, p2, def}, {}, palette(), {}, kDefaultId, &scheduled);
ASSERT_EQ(streams.size(), 4u);
EXPECT_EQ(streams[0].name, "Two");
EXPECT_EQ(streams[1].name, "Default");
EXPECT_EQ(streams[2].name, "One");
EXPECT_EQ(streams[3].name, "Default");
}
TEST(ScrollerStreamsTest, EmptyScheduleFallsBackToDefault)
{
ProclamationRecord p1;
p1.id = "one";
p1.title = "One";
p1.content = "x";
p1.selected = true;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "y";
std::vector<std::string> scheduled; // empty block
auto streams = buildStreams({p1, def}, {}, palette(), {}, kDefaultId, &scheduled);
ASSERT_EQ(streams.size(), 1u);
EXPECT_EQ(streams[0].name, "Default");
}
TEST(ScrollerStreamsTest, SplitsGluedSentencePunctuation)
{
ProclamationRecord p1;
+18
View File
@@ -26,6 +26,24 @@ TEST(TimeZoneTest, UtcTimestampFormat) {
EXPECT_NE(iso.find("UTC"), std::string::npos);
}
TEST(TimeZoneTest, CurrentLocalMinutesInRangeAndMatchesUtc) {
int minutes = currentLocalMinutes("UTC");
EXPECT_GE(minutes, 0);
EXPECT_LT(minutes, 1440);
// Cross-check against the UTC wall clock. The two calls happen within the
// same second, so a minute-boundary rollover is vanishingly unlikely.
time_t now = std::time(nullptr);
struct tm gmt_buf;
gmtime_r(&now, &gmt_buf);
EXPECT_EQ(minutes, gmt_buf.tm_hour * 60 + gmt_buf.tm_min);
// A timezone that is known to differ from UTC must still be in range.
int ny = currentLocalMinutes("America/New_York");
EXPECT_GE(ny, 0);
EXPECT_LT(ny, 1440);
}
TEST(TimeZoneTest, FormatUtcToTimezone) {
// 2026-07-24 12:00:00 UTC
// Fixed epoch timestamp: 1784808000