Add schedule sync and schedule-aware playback
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user