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:
@@ -43,6 +43,7 @@ add_executable(radiantwave
|
||||
src/settings_menu.cpp
|
||||
src/system_settings_page.cpp
|
||||
src/system_name_page.cpp
|
||||
src/schedule_toggle_page.cpp
|
||||
src/wifi_setup_page.cpp
|
||||
src/audio_settings_page.cpp
|
||||
src/audio/audio_manager.cpp
|
||||
@@ -62,6 +63,9 @@ add_executable(radiantwave
|
||||
src/network/license_status.cpp
|
||||
src/network/system_name.cpp
|
||||
src/network/system_name_sync.cpp
|
||||
src/network/schedule_cache.cpp
|
||||
src/schedule/schedule_engine.cpp
|
||||
src/schedule/schedule_manager.cpp
|
||||
src/config/yaml_parser.cpp
|
||||
src/config/config_manager.cpp
|
||||
src/db/database_manager.cpp
|
||||
@@ -253,6 +257,21 @@ add_executable(test_system_name
|
||||
target_include_directories(test_system_name PRIVATE src)
|
||||
target_link_libraries(test_system_name PRIVATE GTest::gtest_main SQLite3::SQLite3)
|
||||
|
||||
# Schedule engine + cache tests (payload parsing, evaluation, offline cache)
|
||||
add_executable(test_schedule_engine
|
||||
tests/test_schedule_engine.cpp
|
||||
src/schedule/schedule_engine.cpp
|
||||
src/schedule/schedule_manager.cpp
|
||||
src/network/schedule_cache.cpp
|
||||
src/network/api_client.cpp
|
||||
src/network/json.cpp
|
||||
src/db/database_manager.cpp
|
||||
)
|
||||
target_include_directories(test_schedule_engine PRIVATE src)
|
||||
target_link_libraries(test_schedule_engine PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3)
|
||||
target_compile_definitions(test_schedule_engine PRIVATE
|
||||
RWT_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/docs/fixtures")
|
||||
|
||||
# TimeZone unit tests
|
||||
add_executable(test_timezone
|
||||
tests/test_timezone.cpp
|
||||
|
||||
+165
-2
@@ -2,6 +2,7 @@
|
||||
#include "network/json.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
@@ -27,10 +28,32 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
|
||||
return size * nmemb;
|
||||
}
|
||||
|
||||
// Collects response headers, capturing the ETag (case-insensitive).
|
||||
size_t headerCallback(char* buffer, size_t size, size_t nitems, void* userdata) {
|
||||
auto* etag = static_cast<std::string*>(userdata);
|
||||
const size_t len = size * nitems;
|
||||
std::string line(buffer, len);
|
||||
const std::string prefix = "etag:";
|
||||
if (line.size() >= prefix.size()) {
|
||||
std::string lowered = line.substr(0, prefix.size());
|
||||
for (char& c : lowered) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (lowered == prefix) {
|
||||
std::string value = line.substr(prefix.size());
|
||||
// Trim leading/trailing whitespace and CR/LF.
|
||||
size_t start = value.find_first_not_of(" \t\r\n");
|
||||
size_t end = value.find_last_not_of(" \t\r\n");
|
||||
*etag = (start == std::string::npos) ? "" : value.substr(start, end - start + 1);
|
||||
}
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
// Shared curl setup + perform. Returns a populated HttpResponse. `method` is
|
||||
// "GET", "POST", or "PUT"; GET sends no body.
|
||||
// "GET", "POST", or "PUT"; GET sends no body. `ifNoneMatch`, when non-empty,
|
||||
// is sent as an If-None-Match request header.
|
||||
HttpResponse performRequest(const std::string& url,
|
||||
const char* method, const std::string& jsonBody) {
|
||||
const char* method, const std::string& jsonBody,
|
||||
const std::string& ifNoneMatch = "") {
|
||||
HttpResponse response;
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
@@ -43,6 +66,8 @@ HttpResponse performRequest(const std::string& url,
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "RadiantWave-Kiosk/0.1");
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, headerCallback);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response.etag);
|
||||
|
||||
const bool sendsBody = method != nullptr && std::string(method) != "GET";
|
||||
struct curl_slist* headers = nullptr;
|
||||
@@ -56,6 +81,10 @@ HttpResponse performRequest(const std::string& url,
|
||||
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method);
|
||||
}
|
||||
}
|
||||
if (!ifNoneMatch.empty()) {
|
||||
const std::string header = "If-None-Match: " + ifNoneMatch;
|
||||
headers = curl_slist_append(headers, header.c_str());
|
||||
}
|
||||
if (headers) {
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
}
|
||||
@@ -112,6 +141,10 @@ HttpResponse httpPut(const std::string& path, const std::string& jsonBody) {
|
||||
return performRequest(urlFor(path), "PUT", jsonBody);
|
||||
}
|
||||
|
||||
HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch) {
|
||||
return performRequest(urlFor(path), "GET", "", ifNoneMatch);
|
||||
}
|
||||
|
||||
// ─── GET /frequencies ──────────────────────────────────────────────────
|
||||
|
||||
bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out) {
|
||||
@@ -343,6 +376,136 @@ bool fetchSchedule(const std::string& email, const std::string& license_key,
|
||||
return parseScheduleJson(response.body, out);
|
||||
}
|
||||
|
||||
// ─── GET /licenses/{key}/schedule (Phase 3) ────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
// Reads one target Block from a JSON object.
|
||||
TargetBlock parseTargetBlock(const radiantwave::json::JsonValue& entry) {
|
||||
TargetBlock block;
|
||||
block.id = entry.stringField("id");
|
||||
block.day = entry.stringField("day");
|
||||
block.start_minutes = static_cast<int>(entry.numberField("startMinutes"));
|
||||
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
|
||||
block.overnight = entry.boolField("overnight");
|
||||
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
|
||||
|
||||
const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio");
|
||||
block.dynamic_audio =
|
||||
(dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true;
|
||||
|
||||
const radiantwave::json::JsonValue* ids = entry.find("proclamationIds");
|
||||
if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& id : ids->array) {
|
||||
if (id.type == radiantwave::json::JsonValue::Type::String && !id.str.empty()) {
|
||||
block.proclamation_ids.push_back(id.str);
|
||||
}
|
||||
}
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
bool parseSchedulePayloadJson(const std::string& body, SchedulePayload& out) {
|
||||
radiantwave::json::JsonValue root;
|
||||
if (!radiantwave::json::parseDocument(body, root) ||
|
||||
root.type != radiantwave::json::JsonValue::Type::Object) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SchedulePayload parsed;
|
||||
parsed.version = root.stringField("version");
|
||||
parsed.type = root.stringField("type");
|
||||
|
||||
const radiantwave::json::JsonValue* daily = root.find("daily");
|
||||
if (daily && daily->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& entry : daily->array) {
|
||||
if (entry.type == radiantwave::json::JsonValue::Type::Object) {
|
||||
parsed.daily.push_back(parseTargetBlock(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radiantwave::json::JsonValue* weekly = root.find("weekly");
|
||||
if (weekly && weekly->type == radiantwave::json::JsonValue::Type::Object) {
|
||||
static const char* kDays[] = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"};
|
||||
for (const char* day : kDays) {
|
||||
const radiantwave::json::JsonValue* list = weekly->find(day);
|
||||
if (!list || list->type != radiantwave::json::JsonValue::Type::Array) continue;
|
||||
for (const auto& entry : list->array) {
|
||||
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
|
||||
TargetBlock block = parseTargetBlock(entry);
|
||||
block.day = day;
|
||||
parsed.weekly.push_back(std::move(block));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy `blocks` (for backward compatibility / older payloads).
|
||||
const radiantwave::json::JsonValue* blocks = root.find("blocks");
|
||||
if (blocks && blocks->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& entry : blocks->array) {
|
||||
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
|
||||
ScheduleBlock block;
|
||||
block.id = entry.stringField("id");
|
||||
if (block.id.empty()) continue;
|
||||
block.start_minutes = static_cast<int>(entry.numberField("startMinutes"));
|
||||
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
|
||||
block.overnight = entry.boolField("overnight");
|
||||
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
|
||||
const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio");
|
||||
block.dynamic_audio =
|
||||
(dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true;
|
||||
const radiantwave::json::JsonValue* ids = entry.find("proclamationIds");
|
||||
if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& id : ids->array) {
|
||||
if (id.type == radiantwave::json::JsonValue::Type::String && !id.str.empty()) {
|
||||
block.proclamation_ids.push_back(id.str);
|
||||
}
|
||||
}
|
||||
}
|
||||
parsed.legacy_blocks.push_back(std::move(block));
|
||||
}
|
||||
}
|
||||
|
||||
out = std::move(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
ScheduleFetchStatus fetchScheduleForLicense(const std::string& license_key,
|
||||
const std::string& etag,
|
||||
SchedulePayload& out,
|
||||
std::string& outEtag) {
|
||||
if (license_key.empty()) {
|
||||
return ScheduleFetchStatus::NetworkError;
|
||||
}
|
||||
|
||||
const std::string path = "/licenses/" + urlEncode(license_key) + "/schedule";
|
||||
HttpResponse response = httpGetConditional(path, etag);
|
||||
|
||||
if (!response.transportOk) {
|
||||
return ScheduleFetchStatus::NetworkError;
|
||||
}
|
||||
if (response.status == 304) {
|
||||
return ScheduleFetchStatus::NotModified;
|
||||
}
|
||||
if (response.status == 404) {
|
||||
return ScheduleFetchStatus::NotFound;
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return ScheduleFetchStatus::NetworkError;
|
||||
}
|
||||
|
||||
SchedulePayload parsed;
|
||||
if (!parseSchedulePayloadJson(response.body, parsed)) {
|
||||
return ScheduleFetchStatus::NetworkError;
|
||||
}
|
||||
out = std::move(parsed);
|
||||
outEtag = response.etag;
|
||||
return ScheduleFetchStatus::Ok;
|
||||
}
|
||||
|
||||
AccountResult createAccount(const std::string& email, const std::string& name) {
|
||||
AccountResult result;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ constexpr const char* PATH_SCHEDULE = "/schedule";
|
||||
struct HttpResponse {
|
||||
long status = 0; // HTTP status code; 0 if the request never completed
|
||||
std::string body; // raw response body (any status)
|
||||
std::string etag; // response ETag header, if any
|
||||
bool transportOk = false; // true when the request completed without a transport error
|
||||
};
|
||||
|
||||
@@ -31,6 +32,9 @@ HttpResponse httpGet(const std::string& path);
|
||||
HttpResponse httpPost(const std::string& path, const std::string& jsonBody);
|
||||
HttpResponse httpPut(const std::string& path, const std::string& jsonBody);
|
||||
|
||||
// GET with an optional If-None-Match header. Captures the response ETag.
|
||||
HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch);
|
||||
|
||||
// ─── GET /frequencies ──────────────────────────────────────────────────
|
||||
|
||||
struct Frequency {
|
||||
@@ -176,4 +180,54 @@ bool parseScheduleJson(const std::string& body, ScheduleResponse& out);
|
||||
bool fetchSchedule(const std::string& email, const std::string& license_key,
|
||||
ScheduleResponse& out);
|
||||
|
||||
// ─── GET /licenses/{key}/schedule (Phase 3, conditional) ───────────────
|
||||
|
||||
// One Block of the target payload. `day` is "" for daily schedules and
|
||||
// "mon".."sun" for weekly schedules. Times are wall-clock minutes.
|
||||
struct TargetBlock {
|
||||
std::string id;
|
||||
std::string day; // "" for daily
|
||||
int start_minutes = 0;
|
||||
int end_minutes = 0;
|
||||
bool overnight = false;
|
||||
int duration_minutes = 0;
|
||||
bool dynamic_audio = true;
|
||||
std::vector<std::string> proclamation_ids;
|
||||
};
|
||||
|
||||
// The target schedule payload (version/type/daily/weekly).
|
||||
struct SchedulePayload {
|
||||
std::string version;
|
||||
// "daily", "weekly", or "" for no schedule (type null).
|
||||
std::string type;
|
||||
std::vector<TargetBlock> daily;
|
||||
// Weekly blocks keyed by day (mon..sun); empty for daily/none.
|
||||
std::vector<TargetBlock> weekly;
|
||||
// Legacy `blocks` flattening carried for backward compatibility.
|
||||
std::vector<ScheduleBlock> legacy_blocks;
|
||||
|
||||
bool hasSchedule() const { return type == "daily" || type == "weekly"; }
|
||||
};
|
||||
|
||||
// Parse the target payload (with version/type/daily/weekly). `blocks` (legacy)
|
||||
// is also read so older responses still populate `legacy_blocks`. Returns false
|
||||
// on malformed input.
|
||||
bool parseSchedulePayloadJson(const std::string& body, SchedulePayload& out);
|
||||
|
||||
// Result of a conditional schedule fetch.
|
||||
enum class ScheduleFetchStatus {
|
||||
Ok, // 200; payload replaced
|
||||
NotModified, // 304; caller keeps its cached payload
|
||||
NotFound, // 404 unknown license
|
||||
NetworkError, // transport/other failure
|
||||
};
|
||||
|
||||
// Fetch a License Key's schedule with conditional GET. When `etag` is
|
||||
// non-empty it is sent as If-None-Match. On success `out` is replaced and
|
||||
// `outEtag` receives the response ETag. Never throws.
|
||||
ScheduleFetchStatus fetchScheduleForLicense(const std::string& license_key,
|
||||
const std::string& etag,
|
||||
SchedulePayload& out,
|
||||
std::string& outEtag);
|
||||
|
||||
} // namespace radiantwave::api
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "network/schedule_cache.h"
|
||||
#include "network/json.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <pwd.h>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace radiantwave::sync::schedule_cache {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string homeDir() {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home) {
|
||||
struct passwd* pw = getpwuid(getuid());
|
||||
if (pw) home = pw->pw_dir;
|
||||
}
|
||||
return home ? std::string(home) : std::string("/tmp");
|
||||
}
|
||||
|
||||
void ensureDir(const std::string& dir) {
|
||||
// Create the directory and any missing parents (e.g. ~/.local/radiantwave
|
||||
// when ~/.local does not exist yet).
|
||||
std::string path;
|
||||
for (size_t i = 0; i < dir.size(); ++i) {
|
||||
path += dir[i];
|
||||
if (dir[i] == '/' || i == dir.size() - 1) {
|
||||
if (path.size() > 1) {
|
||||
mkdir(path.c_str(), 0755);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal JSON string escaping for the cache writer.
|
||||
std::string escape(const std::string& in) {
|
||||
std::string out;
|
||||
out.reserve(in.size() + 2);
|
||||
for (char c : in) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default: out += c;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string blockJson(const api::TargetBlock& block, bool includeDay) {
|
||||
std::ostringstream os;
|
||||
os << "{\"id\":\"" << escape(block.id) << "\"";
|
||||
if (includeDay) {
|
||||
os << ",\"day\":\"" << escape(block.day) << "\"";
|
||||
}
|
||||
os << ",\"startMinutes\":" << block.start_minutes
|
||||
<< ",\"endMinutes\":" << block.end_minutes
|
||||
<< ",\"overnight\":" << (block.overnight ? "true" : "false")
|
||||
<< ",\"durationMinutes\":" << block.duration_minutes
|
||||
<< ",\"dynamicAudio\":" << (block.dynamic_audio ? "true" : "false")
|
||||
<< ",\"proclamationIds\":[";
|
||||
for (size_t i = 0; i < block.proclamation_ids.size(); ++i) {
|
||||
if (i > 0) os << ",";
|
||||
os << "\"" << escape(block.proclamation_ids[i]) << "\"";
|
||||
}
|
||||
os << "]}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
std::string serialize(const api::SchedulePayload& payload, const std::string& etag) {
|
||||
std::ostringstream os;
|
||||
os << "{\"version\":\"" << escape(payload.version)
|
||||
<< "\",\"type\":\"" << escape(payload.type)
|
||||
<< "\",\"etag\":\"" << escape(etag)
|
||||
<< "\",\"daily\":[";
|
||||
for (size_t i = 0; i < payload.daily.size(); ++i) {
|
||||
if (i > 0) os << ",";
|
||||
os << blockJson(payload.daily[i], false);
|
||||
}
|
||||
os << "],\"weekly\":[";
|
||||
for (size_t i = 0; i < payload.weekly.size(); ++i) {
|
||||
if (i > 0) os << ",";
|
||||
os << blockJson(payload.weekly[i], true);
|
||||
}
|
||||
os << "]}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string cachePath() {
|
||||
return homeDir() + "/.local/radiantwave/schedule_cache.json";
|
||||
}
|
||||
|
||||
bool write(const api::SchedulePayload& payload, const std::string& etag) {
|
||||
const std::string dir = homeDir() + "/.local/radiantwave";
|
||||
ensureDir(dir);
|
||||
|
||||
const std::string path = cachePath();
|
||||
const std::string tmp = path + ".tmp";
|
||||
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||
if (!out.is_open()) return false;
|
||||
out << serialize(payload, etag);
|
||||
out.flush();
|
||||
if (!out.good()) {
|
||||
out.close();
|
||||
std::remove(tmp.c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic replace: a same-filesystem rename is atomic on POSIX.
|
||||
if (std::rename(tmp.c_str(), path.c_str()) != 0) {
|
||||
std::remove(tmp.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool read(api::SchedulePayload& out, std::string& outEtag) {
|
||||
std::ifstream in(cachePath(), std::ios::binary);
|
||||
if (!in.is_open()) return false;
|
||||
|
||||
std::ostringstream buffer;
|
||||
buffer << in.rdbuf();
|
||||
const std::string text = buffer.str();
|
||||
if (text.empty()) return false;
|
||||
|
||||
// The cache stores the same version/type/daily/weekly shape as the API,
|
||||
// plus an "etag" field, so the API parser (which ignores unknown fields)
|
||||
// validates it directly.
|
||||
api::SchedulePayload parsed;
|
||||
if (!api::parseSchedulePayloadJson(text, parsed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
radiantwave::json::JsonValue root;
|
||||
if (!radiantwave::json::parseDocument(text, root)) {
|
||||
return false;
|
||||
}
|
||||
outEtag = root.stringField("etag");
|
||||
out = std::move(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool clear() {
|
||||
return std::remove(cachePath().c_str()) == 0 || true;
|
||||
}
|
||||
|
||||
} // namespace radiantwave::sync::schedule_cache
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "network/api_client.h"
|
||||
#include <string>
|
||||
|
||||
namespace radiantwave::sync {
|
||||
|
||||
// The on-disk schedule cache. Stores the target payload plus its ETag as a JSON
|
||||
// document. Writes are atomic (temp file + rename) so a crash or power loss
|
||||
// never leaves a partially written cache.
|
||||
namespace schedule_cache {
|
||||
|
||||
// Absolute path of the cache file (under ~/.local/radiantwave/).
|
||||
std::string cachePath();
|
||||
|
||||
// Serialize `payload`/`etag` to the cache file atomically. Returns false on
|
||||
// any I/O failure; the previous cache is left intact.
|
||||
bool write(const api::SchedulePayload& payload, const std::string& etag);
|
||||
|
||||
// Load the cache. Returns false (and leaves `out` untouched) when the file is
|
||||
// missing, unreadable, malformed, or semantically invalid.
|
||||
bool read(api::SchedulePayload& out, std::string& outEtag);
|
||||
|
||||
// Remove the cache file. Returns true when absent afterwards.
|
||||
bool clear();
|
||||
|
||||
} // namespace schedule_cache
|
||||
|
||||
} // namespace radiantwave::sync
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "schedule/schedule_engine.h"
|
||||
|
||||
namespace radiantwave::schedule {
|
||||
|
||||
namespace {
|
||||
|
||||
const char* dayKey(Weekday day) {
|
||||
switch (day) {
|
||||
case Weekday::Mon: return "mon";
|
||||
case Weekday::Tue: return "tue";
|
||||
case Weekday::Wed: return "wed";
|
||||
case Weekday::Thu: return "thu";
|
||||
case Weekday::Fri: return "fri";
|
||||
case Weekday::Sat: return "sat";
|
||||
case Weekday::Sun: return "sun";
|
||||
}
|
||||
return "mon";
|
||||
}
|
||||
|
||||
bool blockIsValid(const api::TargetBlock& block) {
|
||||
return !block.proclamation_ids.empty() &&
|
||||
block.start_minutes >= 0 && block.start_minutes <= 1440 &&
|
||||
block.end_minutes >= 0 && block.end_minutes <= 1440 &&
|
||||
block.start_minutes != block.end_minutes;
|
||||
}
|
||||
|
||||
void warnInvalid(const api::TargetBlock& block,
|
||||
const std::function<void(const std::string&)>& warn) {
|
||||
if (warn) {
|
||||
warn("Skipping invalid schedule block id=" + block.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Find a matching block among `candidates` at `minutes`, skipping invalid ones.
|
||||
bool match(const std::vector<api::TargetBlock>& candidates, int minutes,
|
||||
api::TargetBlock& out,
|
||||
const std::function<void(const std::string&)>& warn) {
|
||||
for (const auto& block : candidates) {
|
||||
if (!blockIsValid(block)) {
|
||||
warnInvalid(block, warn);
|
||||
continue;
|
||||
}
|
||||
if (minuteInBlock(minutes, block.start_minutes, block.end_minutes)) {
|
||||
out = block;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool minuteInBlock(int minutes, int startMinutes, int endMinutes) {
|
||||
if (startMinutes < endMinutes) {
|
||||
return minutes >= startMinutes && minutes < endMinutes;
|
||||
}
|
||||
// Midnight-crossing: [start, 1440) plus [0, end).
|
||||
return minutes >= startMinutes || minutes < endMinutes;
|
||||
}
|
||||
|
||||
Weekday weekdayFromIndex(int index) {
|
||||
if (index < 0) index = 0;
|
||||
return static_cast<Weekday>(index % 7);
|
||||
}
|
||||
|
||||
Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday,
|
||||
std::function<void(const std::string&)> warn) {
|
||||
Decision decision;
|
||||
if (minutes < 0) minutes = 0;
|
||||
minutes %= 1440;
|
||||
|
||||
if (payload.type == "daily") {
|
||||
decision.hasBlock = match(payload.daily, minutes, decision.block, warn);
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (payload.type == "weekly") {
|
||||
// Only blocks for today...
|
||||
std::vector<api::TargetBlock> today;
|
||||
const std::string todayKey = dayKey(weekday);
|
||||
for (const auto& block : payload.weekly) {
|
||||
if (block.day == todayKey) today.push_back(block);
|
||||
}
|
||||
if (match(today, minutes, decision.block, warn)) {
|
||||
decision.hasBlock = true;
|
||||
return decision;
|
||||
}
|
||||
|
||||
// ...or a crossing Block from yesterday that spills into today's early
|
||||
// hours. (Sunday's spill reaches Monday via the day rollover.)
|
||||
const int prevIndex = (static_cast<int>(weekday) + 6) % 7;
|
||||
const std::string prevKey = dayKey(static_cast<Weekday>(prevIndex));
|
||||
for (const auto& block : payload.weekly) {
|
||||
if (block.day != prevKey) continue;
|
||||
if (block.end_minutes >= block.start_minutes) continue; // not crossing
|
||||
if (!blockIsValid(block)) {
|
||||
warnInvalid(block, warn);
|
||||
continue;
|
||||
}
|
||||
if (minutes < block.end_minutes) {
|
||||
decision.block = block;
|
||||
decision.hasBlock = true;
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
return decision; // type null / unknown -> no active block
|
||||
}
|
||||
|
||||
} // namespace radiantwave::schedule
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include "network/api_client.h"
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace radiantwave::schedule {
|
||||
|
||||
// Weekday keys matching the wire format.
|
||||
enum class Weekday { Mon = 0, Tue, Wed, Thu, Fri, Sat, Sun };
|
||||
|
||||
// The result of evaluating a schedule at a wall-clock instant.
|
||||
struct Decision {
|
||||
bool hasBlock = false;
|
||||
api::TargetBlock block; // valid only when hasBlock
|
||||
};
|
||||
|
||||
// The active Block for `payload` at (minutes, weekday), or none. Implements the
|
||||
// contract: half-open [start, end), midnight-crossing Blocks belong to their
|
||||
// start day (weekly Sunday spills into Monday), and a weekly mode must also
|
||||
// consider the previous day's crossing Block. Invalid Blocks (empty
|
||||
// proclamation list / bad range) are skipped and treated as gaps.
|
||||
//
|
||||
// Wall-clock minutes are passed in, so DST transitions behave naturally (the
|
||||
// caller supplies the local wall-clock time in the Kiosk's timezone).
|
||||
Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday,
|
||||
std::function<void(const std::string&)> warn = nullptr);
|
||||
|
||||
// True when `minutes` falls in the half-open [start, end) block, accounting for
|
||||
// a midnight-crossing range.
|
||||
bool minuteInBlock(int minutes, int startMinutes, int endMinutes);
|
||||
|
||||
// Weekday from a 0=Monday..6=Sunday index (clamped).
|
||||
Weekday weekdayFromIndex(int index);
|
||||
|
||||
} // namespace radiantwave::schedule
|
||||
@@ -0,0 +1,160 @@
|
||||
#include "schedule/schedule_manager.h"
|
||||
#include "db/db_keys.h"
|
||||
#include "network/api_client.h"
|
||||
#include "network/schedule_cache.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace radiantwave::schedule {
|
||||
|
||||
ScheduleManager::ScheduleManager()
|
||||
: fetch_([](const std::string& licenseKey, const std::string& etag,
|
||||
api::SchedulePayload& out, std::string& outEtag) {
|
||||
return api::fetchScheduleForLicense(licenseKey, etag, out, outEtag);
|
||||
}) {
|
||||
}
|
||||
|
||||
ScheduleManager::~ScheduleManager() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
bool ScheduleManager::hasSchedule() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return payload_.hasSchedule();
|
||||
}
|
||||
|
||||
bool ScheduleManager::enabled() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return enabled_;
|
||||
}
|
||||
|
||||
Decision ScheduleManager::activeBlock(int minutes, Weekday weekday) const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!enabled_) {
|
||||
return Decision{};
|
||||
}
|
||||
return evaluate(payload_, minutes, weekday, nullptr);
|
||||
}
|
||||
|
||||
void ScheduleManager::setFetchForTesting(FetchFn fetch) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
fetch_ = std::move(fetch);
|
||||
}
|
||||
|
||||
void ScheduleManager::applyPayload(api::SchedulePayload payload, const std::string& etag) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
payload_ = std::move(payload);
|
||||
etag_ = etag;
|
||||
}
|
||||
// Cache the last good payload for offline use (atomic write).
|
||||
radiantwave::sync::schedule_cache::write(payload_, etag_);
|
||||
if (onChanged_) onChanged_();
|
||||
}
|
||||
|
||||
void ScheduleManager::loadCache() {
|
||||
api::SchedulePayload cached;
|
||||
std::string cachedEtag;
|
||||
if (radiantwave::sync::schedule_cache::read(cached, cachedEtag)) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
payload_ = std::move(cached);
|
||||
etag_ = std::move(cachedEtag);
|
||||
if (onChanged_) onChanged_();
|
||||
}
|
||||
}
|
||||
|
||||
void ScheduleManager::refreshOnce() {
|
||||
std::string licenseKey;
|
||||
std::string etag;
|
||||
FetchFn fetch;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
licenseKey = licenseKey_;
|
||||
etag = etag_;
|
||||
fetch = fetch_;
|
||||
}
|
||||
if (licenseKey.empty()) return;
|
||||
|
||||
api::SchedulePayload payload;
|
||||
std::string newEtag;
|
||||
const api::ScheduleFetchStatus status = fetch(licenseKey, etag, payload, newEtag);
|
||||
|
||||
if (status == api::ScheduleFetchStatus::Ok) {
|
||||
applyPayload(std::move(payload), newEtag);
|
||||
}
|
||||
// NotModified / NotFound / NetworkError: keep the current cache.
|
||||
}
|
||||
|
||||
void ScheduleManager::start(db::DatabaseManager& db, const std::string& timezone,
|
||||
std::function<void()> onChanged) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (started_) return;
|
||||
started_ = true;
|
||||
db_ = &db;
|
||||
timezone_ = timezone;
|
||||
onChanged_ = std::move(onChanged);
|
||||
licenseKey_ = db.getKV(db::KV_LICENSE_KEY);
|
||||
enabled_ = true; // config supplies the toggle via setEnabled()
|
||||
|
||||
// Offline first: load any cached payload immediately.
|
||||
loadCache();
|
||||
|
||||
stop_.store(false);
|
||||
worker_ = std::thread(&ScheduleManager::run, this);
|
||||
requested_.store(true);
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
void ScheduleManager::shutdown() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!started_) return;
|
||||
stop_.store(true);
|
||||
}
|
||||
cv_.notify_one();
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
started_ = false;
|
||||
}
|
||||
|
||||
void ScheduleManager::requestRefresh() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!started_) return;
|
||||
if (db_) {
|
||||
licenseKey_ = db_->getKV(db::KV_LICENSE_KEY);
|
||||
}
|
||||
requested_.store(true);
|
||||
}
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
void ScheduleManager::setEnabled(bool enabled) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
enabled_ = enabled;
|
||||
}
|
||||
|
||||
void ScheduleManager::setPayloadForTesting(api::SchedulePayload payload,
|
||||
const std::string& etag) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
payload_ = std::move(payload);
|
||||
etag_ = etag;
|
||||
}
|
||||
|
||||
void ScheduleManager::run() {
|
||||
while (true) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
cv_.wait_for(lock, REFRESH_INTERVAL, [this] {
|
||||
return stop_.load() || requested_.load();
|
||||
});
|
||||
if (stop_.load()) return;
|
||||
requested_.store(false);
|
||||
}
|
||||
refreshOnce();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace radiantwave::schedule
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include "db/database_manager.h"
|
||||
#include "network/api_client.h"
|
||||
#include "schedule/schedule_engine.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace radiantwave::schedule {
|
||||
|
||||
// Fetches the Schedule for the stored License Key with conditional GET, caches
|
||||
// the last payload to disk (atomic write), and exposes the active Block for
|
||||
// playback. Never blocks the UI: a background worker pulls on boot, on
|
||||
// network-up, and every 5 minutes.
|
||||
class ScheduleManager {
|
||||
public:
|
||||
ScheduleManager();
|
||||
~ScheduleManager();
|
||||
|
||||
ScheduleManager(const ScheduleManager&) = delete;
|
||||
ScheduleManager& operator=(const ScheduleManager&) = delete;
|
||||
|
||||
// Returns the active Block for the stored device state at `minutes`
|
||||
// (wall-clock) on `weekday`. Honors local enable/disable: when schedules
|
||||
// are off, always returns no block.
|
||||
Decision activeBlock(int minutes, Weekday weekday) const;
|
||||
|
||||
// True when a payload with an active type is loaded (from network or cache).
|
||||
bool hasSchedule() const;
|
||||
|
||||
// True while schedules are locally enabled (System Settings toggle).
|
||||
bool enabled() const;
|
||||
|
||||
// Background lifecycle.
|
||||
void start(db::DatabaseManager& db, const std::string& timezone,
|
||||
std::function<void()> onChanged = nullptr);
|
||||
|
||||
// Update the local enable flag (from config.yaml). When off, activeBlock()
|
||||
// returns none but the cache keeps updating.
|
||||
void setEnabled(bool enabled);
|
||||
|
||||
// Test/inspection hook: replace the loaded payload directly.
|
||||
void setPayloadForTesting(api::SchedulePayload payload, const std::string& etag);
|
||||
void shutdown();
|
||||
|
||||
// Request an immediate refresh (e.g. after a settings change). Non-blocking.
|
||||
void requestRefresh();
|
||||
|
||||
// Pull interval (5 minutes).
|
||||
static constexpr std::chrono::seconds REFRESH_INTERVAL{300};
|
||||
|
||||
// Injectable for tests: performs one fetch+cache cycle. The production
|
||||
// implementation uses api::fetchScheduleForLicense + schedule_cache.
|
||||
using FetchFn = std::function<api::ScheduleFetchStatus(
|
||||
const std::string& licenseKey, const std::string& etag,
|
||||
api::SchedulePayload& out, std::string& outEtag)>;
|
||||
|
||||
void setFetchForTesting(FetchFn fetch);
|
||||
|
||||
// Run one fetch cycle synchronously (used by tests and boot).
|
||||
void refreshOnce();
|
||||
|
||||
private:
|
||||
void run();
|
||||
void loadCache();
|
||||
void applyPayload(api::SchedulePayload payload, const std::string& etag);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
api::SchedulePayload payload_;
|
||||
std::string etag_;
|
||||
bool enabled_ = true;
|
||||
std::string licenseKey_;
|
||||
std::string timezone_;
|
||||
|
||||
FetchFn fetch_;
|
||||
|
||||
db::DatabaseManager* db_ = nullptr;
|
||||
std::function<void()> onChanged_;
|
||||
|
||||
std::thread worker_;
|
||||
std::condition_variable cv_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::atomic<bool> requested_{false};
|
||||
bool started_ = false;
|
||||
};
|
||||
|
||||
} // namespace radiantwave::schedule
|
||||
@@ -164,6 +164,38 @@ int currentLocalMinutes(const std::string& iana_code) {
|
||||
return tm_buf.tm_hour * 60 + tm_buf.tm_min;
|
||||
}
|
||||
|
||||
int currentLocalWeekday(const std::string& iana_code) {
|
||||
std::lock_guard<std::mutex> lock(g_tz_mutex);
|
||||
|
||||
const char* old_tz = std::getenv("TZ");
|
||||
std::string old_tz_str = old_tz ? old_tz : "";
|
||||
|
||||
if (!iana_code.empty()) {
|
||||
setenv("TZ", iana_code.c_str(), 1);
|
||||
} else {
|
||||
setenv("TZ", "UTC", 1);
|
||||
}
|
||||
tzset();
|
||||
|
||||
time_t now = std::time(nullptr);
|
||||
struct tm tm_buf;
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm_buf, &now);
|
||||
#else
|
||||
localtime_r(&now, &tm_buf);
|
||||
#endif
|
||||
|
||||
if (!old_tz_str.empty()) {
|
||||
setenv("TZ", old_tz_str.c_str(), 1);
|
||||
} else {
|
||||
unsetenv("TZ");
|
||||
}
|
||||
tzset();
|
||||
|
||||
// tm_wday: 0=Sunday .. 6=Saturday -> convert to 0=Monday .. 6=Sunday.
|
||||
return (tm_buf.tm_wday + 6) % 7;
|
||||
}
|
||||
|
||||
std::string getUtcTimestampIso() {
|
||||
time_t now = std::time(nullptr);
|
||||
struct tm gmt_buf;
|
||||
|
||||
@@ -27,6 +27,10 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c
|
||||
// timezone is empty.
|
||||
int currentLocalMinutes(const std::string& iana_code);
|
||||
|
||||
// Weekday right now in `iana_code`, 0=Monday .. 6=Sunday. Falls back to UTC when
|
||||
// the timezone is empty.
|
||||
int currentLocalWeekday(const std::string& iana_code);
|
||||
|
||||
// Gets current UTC timestamp as ISO string
|
||||
std::string getUtcTimestampIso();
|
||||
|
||||
|
||||
@@ -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