initial commit
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
#include "network/nm_connection.h"
|
||||
#include "network/nm_types.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
using namespace radiantwave::network;
|
||||
|
||||
// ─── Open Network Tests ────────────────────────────────────────────────
|
||||
|
||||
TEST(ConnectionBuilderTest, OpenNetworkNoSecurity) {
|
||||
auto settings = buildConnectionSettings("MyWiFi", "", SecurityType::Open);
|
||||
|
||||
// Should have connection, wireless, ipv4, ipv6 sections
|
||||
EXPECT_EQ(settings.count("connection"), 1u);
|
||||
EXPECT_EQ(settings.count("802-11-wireless"), 1u);
|
||||
EXPECT_EQ(settings.count("ipv4"), 1u);
|
||||
EXPECT_EQ(settings.count("ipv6"), 1u);
|
||||
|
||||
// Should NOT have security section
|
||||
EXPECT_EQ(settings.count("802-11-wireless-security"), 0u);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, OpenNetworkConnectionId) {
|
||||
auto settings = buildConnectionSettings("MyWiFi", "", SecurityType::Open);
|
||||
auto& conn = settings["connection"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(conn["id"]), "MyWiFi");
|
||||
EXPECT_EQ(std::get<std::string>(conn["type"]), "802-11-wireless");
|
||||
EXPECT_EQ(std::get<bool>(conn["autoconnect"]), true);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, OpenNetworkWirelessMode) {
|
||||
auto settings = buildConnectionSettings("MyWiFi", "", SecurityType::Open);
|
||||
auto& wireless = settings["802-11-wireless"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(wireless["mode"]), "infrastructure");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, OpenNetworkIpSettings) {
|
||||
auto settings = buildConnectionSettings("MyWiFi", "", SecurityType::Open);
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(settings["ipv4"]["method"]), "auto");
|
||||
EXPECT_EQ(std::get<std::string>(settings["ipv6"]["method"]), "auto");
|
||||
}
|
||||
|
||||
// ─── SSID Handling Tests ───────────────────────────────────────────────
|
||||
|
||||
TEST(ConnectionBuilderTest, SSIDIsByteArray) {
|
||||
auto settings = buildConnectionSettings("MyWiFi", "", SecurityType::Open);
|
||||
auto& wireless = settings["802-11-wireless"];
|
||||
|
||||
auto ssid = std::get<std::vector<uint8_t>>(wireless["ssid"]);
|
||||
EXPECT_EQ(ssid.size(), 6u);
|
||||
EXPECT_EQ(std::string(ssid.begin(), ssid.end()), "MyWiFi");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, SSIDWithSpaces) {
|
||||
auto settings = buildConnectionSettings("My WiFi Network", "", SecurityType::Open);
|
||||
auto& wireless = settings["802-11-wireless"];
|
||||
|
||||
auto ssid = std::get<std::vector<uint8_t>>(wireless["ssid"]);
|
||||
EXPECT_EQ(std::string(ssid.begin(), ssid.end()), "My WiFi Network");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, SSIDWithUnicode) {
|
||||
auto settings = buildConnectionSettings("Café WiFi", "", SecurityType::Open);
|
||||
auto& wireless = settings["802-11-wireless"];
|
||||
|
||||
auto ssid = std::get<std::vector<uint8_t>>(wireless["ssid"]);
|
||||
std::string ssid_str(ssid.begin(), ssid.end());
|
||||
EXPECT_EQ(ssid_str, "Café WiFi");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, SSIDWithJapanese) {
|
||||
auto settings = buildConnectionSettings("日本語ネットワーク", "", SecurityType::Open);
|
||||
auto& wireless = settings["802-11-wireless"];
|
||||
|
||||
auto ssid = std::get<std::vector<uint8_t>>(wireless["ssid"]);
|
||||
std::string ssid_str(ssid.begin(), ssid.end());
|
||||
EXPECT_EQ(ssid_str, "日本語ネットワーク");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, EmptySSIDFails) {
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings("", "", SecurityType::Open),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, SSIDTooLongFails) {
|
||||
// SSIDs max 32 bytes
|
||||
std::string long_ssid(33, 'A');
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings(long_ssid, "", SecurityType::Open),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, SSIDWithNullByteFails) {
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings(std::string("My\0WiFi", 7), "", SecurityType::Open),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
// ─── WPA2 Tests ────────────────────────────────────────────────────────
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA2HasSecuritySection) {
|
||||
auto settings = buildConnectionSettings("SecureNet", "password123", SecurityType::WPA2);
|
||||
EXPECT_EQ(settings.count("802-11-wireless-security"), 1u);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA2KeyMgmt) {
|
||||
auto settings = buildConnectionSettings("SecureNet", "password123", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["key-mgmt"]), "wpa-psk");
|
||||
EXPECT_EQ(std::get<std::string>(sec["auth-alg"]), "open");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA2Password) {
|
||||
auto settings = buildConnectionSettings("SecureNet", "password123", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), "password123");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA2EmptyPasswordFails) {
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings("SecureNet", "", SecurityType::WPA2),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA2ShortPasswordFails) {
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings("SecureNet", "short", SecurityType::WPA2),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA2LongPasswordFails) {
|
||||
// Max 63 characters
|
||||
std::string long_pass(64, 'a');
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings("SecureNet", long_pass, SecurityType::WPA2),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA2MaxPassword) {
|
||||
std::string max_pass(63, 'a');
|
||||
EXPECT_NO_THROW(
|
||||
buildConnectionSettings("SecureNet", max_pass, SecurityType::WPA2)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── WPA3 Tests ────────────────────────────────────────────────────────
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA3KeyMgmt) {
|
||||
auto settings = buildConnectionSettings("SecureNet3", "password123", SecurityType::WPA3);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["key-mgmt"]), "sae");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA3Password) {
|
||||
auto settings = buildConnectionSettings("SecureNet3", "password123", SecurityType::WPA3);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), "password123");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WPA3EmptyPasswordFails) {
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings("SecureNet3", "", SecurityType::WPA3),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
// ─── WEP Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
TEST(ConnectionBuilderTest, WEPKeyMgmt) {
|
||||
auto settings = buildConnectionSettings("OldNet", "weppass", SecurityType::WEP);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["key-mgmt"]), "none");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WEPPassword) {
|
||||
auto settings = buildConnectionSettings("OldNet", "weppass", SecurityType::WEP);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["wep-key0"]), "weppass");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WEPKeyType) {
|
||||
auto settings = buildConnectionSettings("OldNet", "weppass", SecurityType::WEP);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<uint32_t>(sec["wep-key-type"]), 1u);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, WEPEmptyPasswordFails) {
|
||||
EXPECT_THROW(
|
||||
buildConnectionSettings("OldNet", "", SecurityType::WEP),
|
||||
std::invalid_argument
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Special Character Tests ───────────────────────────────────────────
|
||||
|
||||
TEST(ConnectionBuilderTest, PasswordWithSingleQuote) {
|
||||
auto settings = buildConnectionSettings("Net", "p@ss'w0rd", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), "p@ss'w0rd");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, PasswordWithDoubleQuote) {
|
||||
auto settings = buildConnectionSettings("Net", R"(p@ss"w0rd)", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), R"(p@ss"w0rd)");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, PasswordWithAt) {
|
||||
auto settings = buildConnectionSettings("Net", "user@domain.com", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), "user@domain.com");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, PasswordWithSpecialChars) {
|
||||
std::string special = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~";
|
||||
auto settings = buildConnectionSettings("Net", special, SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), special);
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, PasswordWithSpaces) {
|
||||
auto settings = buildConnectionSettings("Net", "password with spaces", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), "password with spaces");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, PasswordWithUnicode) {
|
||||
auto settings = buildConnectionSettings("Net", "pässwörd", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), "pässwörd");
|
||||
}
|
||||
|
||||
TEST(ConnectionBuilderTest, PasswordWithEmoji) {
|
||||
auto settings = buildConnectionSettings("Net", "🔐🔑password", SecurityType::WPA2);
|
||||
auto& sec = settings["802-11-wireless-security"];
|
||||
|
||||
EXPECT_EQ(std::get<std::string>(sec["psk"]), "🔐🔑password");
|
||||
}
|
||||
|
||||
// ─── Security Detection Tests ──────────────────────────────────────────
|
||||
|
||||
TEST(SecurityDetectionTest, OpenNetwork) {
|
||||
EXPECT_EQ(detectSecurityType(0, 0, 0), SecurityType::Open);
|
||||
}
|
||||
|
||||
TEST(SecurityDetectionTest, WEPWithPrivacy) {
|
||||
EXPECT_EQ(detectSecurityType(0x01, 0, 0), SecurityType::WEP);
|
||||
}
|
||||
|
||||
TEST(SecurityDetectionTest, WPAWithWpaFlags) {
|
||||
EXPECT_EQ(detectSecurityType(0, 0x01, 0), SecurityType::WPA);
|
||||
}
|
||||
|
||||
TEST(SecurityDetectionTest, WPA2WithRsnPsk) {
|
||||
EXPECT_EQ(detectSecurityType(0, 0, 0x100), SecurityType::WPA2);
|
||||
}
|
||||
|
||||
TEST(SecurityDetectionTest, WPA3WithSae) {
|
||||
EXPECT_EQ(detectSecurityType(0, 0, 0x400), SecurityType::WPA3);
|
||||
}
|
||||
|
||||
TEST(SecurityDetectionTest, WPA3TakesPrecedenceOverWPA2) {
|
||||
// SAE (0x400) should be detected before PSK (0x100)
|
||||
EXPECT_EQ(detectSecurityType(0, 0, 0x500), SecurityType::WPA3);
|
||||
}
|
||||
|
||||
TEST(SecurityDetectionTest, WPA2TakesPrecedenceOverWPA) {
|
||||
// RSN PSK should be detected before WPA flags
|
||||
EXPECT_EQ(detectSecurityType(0, 0x01, 0x100), SecurityType::WPA2);
|
||||
}
|
||||
|
||||
// ─── SSID Utility Tests ────────────────────────────────────────────────
|
||||
|
||||
TEST(SsidTest, SsidToBytesRoundTrip) {
|
||||
std::string ssid = "MyWiFi";
|
||||
auto bytes = ssidToBytes(ssid);
|
||||
auto result = bytesToSsid(bytes);
|
||||
|
||||
EXPECT_EQ(result, ssid);
|
||||
}
|
||||
|
||||
TEST(SsidTest, SsidToBytesUnicode) {
|
||||
std::string ssid = "Café";
|
||||
auto bytes = ssidToBytes(ssid);
|
||||
auto result = bytesToSsid(bytes);
|
||||
|
||||
EXPECT_EQ(result, ssid);
|
||||
}
|
||||
|
||||
TEST(SsidTest, IsValidSsidNormal) {
|
||||
EXPECT_TRUE(isValidSsid("MyWiFi"));
|
||||
EXPECT_TRUE(isValidSsid("A"));
|
||||
EXPECT_TRUE(isValidSsid(std::string(32, 'A')));
|
||||
}
|
||||
|
||||
TEST(SsidTest, IsValidSsidEmpty) {
|
||||
EXPECT_FALSE(isValidSsid(""));
|
||||
}
|
||||
|
||||
TEST(SsidTest, IsValidSsidTooLong) {
|
||||
EXPECT_FALSE(isValidSsid(std::string(33, 'A')));
|
||||
}
|
||||
|
||||
TEST(SsidTest, IsValidSsidWithNull) {
|
||||
EXPECT_FALSE(isValidSsid(std::string("My\0WiFi", 7)));
|
||||
}
|
||||
|
||||
// ─── Helper Function Tests ─────────────────────────────────────────────
|
||||
|
||||
TEST(HelperTest, RequiresPassword) {
|
||||
EXPECT_FALSE(requiresPassword(SecurityType::Open));
|
||||
EXPECT_TRUE(requiresPassword(SecurityType::WEP));
|
||||
EXPECT_TRUE(requiresPassword(SecurityType::WPA));
|
||||
EXPECT_TRUE(requiresPassword(SecurityType::WPA2));
|
||||
EXPECT_TRUE(requiresPassword(SecurityType::WPA3));
|
||||
}
|
||||
|
||||
TEST(HelperTest, SecurityTypeToString) {
|
||||
EXPECT_STREQ(securityTypeToString(SecurityType::Open), "Open");
|
||||
EXPECT_STREQ(securityTypeToString(SecurityType::WEP), "WEP");
|
||||
EXPECT_STREQ(securityTypeToString(SecurityType::WPA), "WPA");
|
||||
EXPECT_STREQ(securityTypeToString(SecurityType::WPA2), "WPA2");
|
||||
EXPECT_STREQ(securityTypeToString(SecurityType::WPA3), "WPA3");
|
||||
}
|
||||
|
||||
TEST(HelperTest, ConnectionStateToString) {
|
||||
EXPECT_STREQ(connectionStateToString(ConnectionState::Disconnected), "Disconnected");
|
||||
EXPECT_STREQ(connectionStateToString(ConnectionState::Activated), "Connected");
|
||||
EXPECT_STREQ(connectionStateToString(ConnectionState::Failed), "Failed");
|
||||
EXPECT_STREQ(connectionStateToString(ConnectionState::NeedAuth), "Authentication Required");
|
||||
}
|
||||
|
||||
// ─── UUID Generation Tests ─────────────────────────────────────────────
|
||||
|
||||
TEST(UuidTest, UUIDFormat) {
|
||||
auto settings = buildConnectionSettings("Net", "", SecurityType::Open);
|
||||
auto uuid = std::get<std::string>(settings["connection"]["uuid"]);
|
||||
|
||||
// UUID format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
EXPECT_EQ(uuid.size(), 36u);
|
||||
EXPECT_EQ(uuid[8], '-');
|
||||
EXPECT_EQ(uuid[13], '-');
|
||||
EXPECT_EQ(uuid[18], '-');
|
||||
EXPECT_EQ(uuid[23], '-');
|
||||
EXPECT_EQ(uuid[14], '4'); // Version 4
|
||||
}
|
||||
|
||||
TEST(UuidTest, UniqueUUIDs) {
|
||||
auto settings1 = buildConnectionSettings("Net1", "", SecurityType::Open);
|
||||
auto settings2 = buildConnectionSettings("Net2", "", SecurityType::Open);
|
||||
|
||||
auto uuid1 = std::get<std::string>(settings1["connection"]["uuid"]);
|
||||
auto uuid2 = std::get<std::string>(settings2["connection"]["uuid"]);
|
||||
|
||||
EXPECT_NE(uuid1, uuid2);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
#include "network/api_client.h"
|
||||
#include "network/json.h"
|
||||
#include "network/proclamation_sync.h"
|
||||
#include "db/database_manager.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace radiantwave::api;
|
||||
|
||||
namespace {
|
||||
|
||||
// Reads an env var, returning "" when unset.
|
||||
std::string env(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value ? std::string(value) : std::string();
|
||||
}
|
||||
|
||||
// Returns the optional live API key, or "" when none was provided.
|
||||
std::string liveApiKey() {
|
||||
return env("RW_LIVE_API_KEY");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ─── Offline: parse functions ─────────────────────────────────────────
|
||||
|
||||
TEST(ApiClientTest, ParseFrequenciesJson) {
|
||||
const std::string body =
|
||||
R"([{"id":1,"name":"Crown","value":936},
|
||||
{"id":5,"name":"Solar Plexus","value":528},
|
||||
{"id":7,"name":"Root","value":396}])";
|
||||
|
||||
std::vector<Frequency> out;
|
||||
EXPECT_TRUE(parseFrequenciesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 3u);
|
||||
|
||||
EXPECT_EQ(out[0].id, 1);
|
||||
EXPECT_EQ(out[0].name, "Crown");
|
||||
EXPECT_EQ(out[0].value, 936);
|
||||
|
||||
EXPECT_EQ(out[1].id, 5);
|
||||
EXPECT_EQ(out[1].name, "Solar Plexus");
|
||||
EXPECT_EQ(out[1].value, 528);
|
||||
|
||||
EXPECT_EQ(out[2].id, 7);
|
||||
EXPECT_EQ(out[2].name, "Root");
|
||||
EXPECT_EQ(out[2].value, 396);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseFrequenciesJsonEmpty) {
|
||||
std::vector<Frequency> out;
|
||||
EXPECT_TRUE(parseFrequenciesJson("[]", out));
|
||||
EXPECT_TRUE(out.empty());
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseFrequenciesJsonRejectsMalformed) {
|
||||
std::vector<Frequency> out;
|
||||
EXPECT_FALSE(parseFrequenciesJson("not json", out));
|
||||
EXPECT_FALSE(parseFrequenciesJson(R"({"id":1})", out)); // object, not array
|
||||
EXPECT_FALSE(parseFrequenciesJson(R"([{"id":1}", ])", out));
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseFrequenciesJsonSkipsMissingId) {
|
||||
std::vector<Frequency> out;
|
||||
EXPECT_TRUE(parseFrequenciesJson(
|
||||
R"([{"name":"NoId"},{"id":3,"name":"Throat","value":741}])", out));
|
||||
ASSERT_EQ(out.size(), 1u);
|
||||
EXPECT_EQ(out[0].id, 3);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseLicenseJsonLegacyStringLevel) {
|
||||
const std::string body =
|
||||
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com","subscription_level":"Basic"})";
|
||||
|
||||
LicenseInfo info;
|
||||
EXPECT_TRUE(parseLicenseJson(body, info));
|
||||
EXPECT_EQ(info.key, "ABCD-EFGH-IJKL-MNOP");
|
||||
EXPECT_EQ(info.email_address, "user@example.com");
|
||||
EXPECT_EQ(info.subscription_level.name, "Basic");
|
||||
EXPECT_EQ(info.subscription_level.id, 0);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseLicenseJsonObjectLevel) {
|
||||
const std::string body =
|
||||
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com",)"
|
||||
R"("subscription_level":{"id":2,"name":"Advanced","description":"General + Specific"}})";
|
||||
|
||||
LicenseInfo info;
|
||||
EXPECT_TRUE(parseLicenseJson(body, info));
|
||||
EXPECT_EQ(info.key, "ABCD-EFGH-IJKL-MNOP");
|
||||
EXPECT_EQ(info.email_address, "user@example.com");
|
||||
EXPECT_EQ(info.subscription_level.id, 2);
|
||||
EXPECT_EQ(info.subscription_level.name, "Advanced");
|
||||
EXPECT_EQ(info.subscription_level.description, "General + Specific");
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseLicenseJsonFlatLevel) {
|
||||
const std::string body =
|
||||
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com",)"
|
||||
R"("subscription_level":"Elite","subscription_level_id":3,)"
|
||||
R"("subscription_level_description":"General + Specific + Precise"})";
|
||||
|
||||
LicenseInfo info;
|
||||
EXPECT_TRUE(parseLicenseJson(body, info));
|
||||
EXPECT_EQ(info.subscription_level.id, 3);
|
||||
EXPECT_EQ(info.subscription_level.name, "Elite");
|
||||
EXPECT_EQ(info.subscription_level.description, "General + Specific + Precise");
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseLicenseJsonRejectsMalformed) {
|
||||
LicenseInfo info;
|
||||
EXPECT_FALSE(parseLicenseJson("garbage", info));
|
||||
EXPECT_FALSE(parseLicenseJson(R"({"key":""})", info));
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseCreateAccountJson) {
|
||||
const std::string body =
|
||||
R"({"success":true,"message":"Account and license key created","user_id":"abc-123"})";
|
||||
|
||||
AccountBody account;
|
||||
EXPECT_TRUE(parseCreateAccountJson(body, account));
|
||||
EXPECT_TRUE(account.success);
|
||||
EXPECT_EQ(account.message, "Account and license key created");
|
||||
EXPECT_EQ(account.user_id, "abc-123");
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) {
|
||||
AccountBody account;
|
||||
EXPECT_FALSE(parseCreateAccountJson("nope", account));
|
||||
EXPECT_FALSE(parseCreateAccountJson("[]", account));
|
||||
}
|
||||
|
||||
// ─── Offline: error message extraction ────────────────────────────────
|
||||
|
||||
TEST(ApiClientTest, ExtractErrorMessage) {
|
||||
EXPECT_EQ(radiantwave::json::extractErrorMessage(R"({"message":"license key not found"})"),
|
||||
"license key not found");
|
||||
EXPECT_EQ(radiantwave::json::extractErrorMessage(R"({"error":"API key required"})"),
|
||||
"API key required");
|
||||
EXPECT_EQ(radiantwave::json::extractErrorMessage(R"({"code":400})"), "");
|
||||
EXPECT_EQ(radiantwave::json::extractErrorMessage("not json"), "");
|
||||
}
|
||||
|
||||
// ─── Offline: real-world payload shapes ────────────────────────────────
|
||||
|
||||
TEST(ApiClientTest, ParseProclamationsHandlesSubscriptionLevelIdNull) {
|
||||
const std::string body =
|
||||
R"([{"id":"a","name":"Peace","content":"<p>I am peace.</p>","categoryId":5,
|
||||
"solfeggioFrequencyId":5,"subscriptionLevelId":null},
|
||||
{"id":"b","name":"Foot","content":"<p>Heal.</p>","categoryId":32,
|
||||
"solfeggioFrequencyId":7,"subscriptionLevelId":1}])";
|
||||
|
||||
std::vector<radiantwave::db::ProclamationRecord> out;
|
||||
EXPECT_TRUE(radiantwave::sync::parseProclamationsJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
EXPECT_EQ(out[0].title, "Peace");
|
||||
EXPECT_EQ(out[0].category_id, 5);
|
||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 5);
|
||||
EXPECT_EQ(out[1].category_id, 32);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
|
||||
const std::string body =
|
||||
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
|
||||
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
|
||||
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> out;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
|
||||
EXPECT_EQ(out[1].parent_id, 4);
|
||||
}
|
||||
|
||||
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
|
||||
|
||||
TEST(ApiClientLiveTest, FetchFrequenciesLive) {
|
||||
std::vector<Frequency> out;
|
||||
EXPECT_TRUE(fetchFrequencies(out, liveApiKey()));
|
||||
ASSERT_GE(out.size(), 7u);
|
||||
|
||||
bool foundSolarPlexus = false;
|
||||
for (const Frequency& f : out) {
|
||||
if (f.id == 5) {
|
||||
foundSolarPlexus = true;
|
||||
EXPECT_EQ(f.name, "Solar Plexus");
|
||||
EXPECT_EQ(f.value, 528);
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(foundSolarPlexus);
|
||||
}
|
||||
|
||||
// ─── Live: proclamation + category parsers against the real API ────────
|
||||
|
||||
TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
|
||||
HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS, liveApiKey());
|
||||
ASSERT_TRUE(procsResponse.transportOk);
|
||||
ASSERT_EQ(procsResponse.status, 200);
|
||||
|
||||
std::vector<radiantwave::db::ProclamationRecord> proclamations;
|
||||
EXPECT_TRUE(radiantwave::sync::parseProclamationsJson(procsResponse.body, proclamations));
|
||||
EXPECT_GE(proclamations.size(), 50u);
|
||||
if (!proclamations.empty()) {
|
||||
// HTML tags must be stripped from the content.
|
||||
EXPECT_EQ(proclamations[0].content.find('<'), std::string::npos);
|
||||
EXPECT_FALSE(proclamations[0].id.empty());
|
||||
}
|
||||
|
||||
HttpResponse catsResponse = httpGet(PATH_CATEGORIES, liveApiKey());
|
||||
ASSERT_TRUE(catsResponse.transportOk);
|
||||
ASSERT_EQ(catsResponse.status, 200);
|
||||
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
|
||||
EXPECT_GE(categories.size(), 20u);
|
||||
}
|
||||
|
||||
// ─── Live: POST /license error paths (no credentials required) ─────────
|
||||
|
||||
TEST(ApiClientLiveTest, ValidateLicenseMissingKeyLive) {
|
||||
LicenseResult result = validateLicense("", "test@example.com", liveApiKey());
|
||||
ASSERT_FALSE(result.ok());
|
||||
EXPECT_EQ(result.status, 400);
|
||||
EXPECT_EQ(result.error_message, "license_key is required");
|
||||
}
|
||||
|
||||
TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
|
||||
LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "", liveApiKey());
|
||||
ASSERT_FALSE(result.ok());
|
||||
EXPECT_EQ(result.status, 400);
|
||||
EXPECT_EQ(result.error_message, "email_address is required");
|
||||
}
|
||||
|
||||
TEST(ApiClientLiveTest, ValidateLicenseUnknownKeyLive) {
|
||||
LicenseResult result =
|
||||
validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com", liveApiKey());
|
||||
ASSERT_FALSE(result.ok());
|
||||
EXPECT_EQ(result.status, 400);
|
||||
EXPECT_EQ(result.error_message, "license key not found");
|
||||
}
|
||||
|
||||
// ─── Live: POST /license success path (requires env credentials) ───────
|
||||
|
||||
TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
|
||||
const std::string key = env("RW_LIVE_LICENSE_KEY");
|
||||
const std::string email = env("RW_LIVE_LICENSE_EMAIL");
|
||||
if (key.empty() || email.empty()) {
|
||||
GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set";
|
||||
}
|
||||
|
||||
LicenseResult result = validateLicense(key, email, liveApiKey());
|
||||
ASSERT_TRUE(result.ok()) << "status=" << result.status
|
||||
<< " error=" << result.error_message;
|
||||
EXPECT_EQ(result.info.key, key);
|
||||
EXPECT_EQ(result.info.email_address, email);
|
||||
EXPECT_FALSE(result.info.subscription_level.name.empty());
|
||||
}
|
||||
|
||||
// ─── Live: POST /automations/create/account error paths (harmless) ─────
|
||||
|
||||
TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) {
|
||||
AccountResult result = createAccount("", "Basic", "", liveApiKey());
|
||||
ASSERT_FALSE(result.ok());
|
||||
EXPECT_EQ(result.status, 400);
|
||||
EXPECT_EQ(result.error_message, "email is required");
|
||||
}
|
||||
|
||||
TEST(ApiClientLiveTest, CreateAccountMissingLevelLive) {
|
||||
AccountResult result = createAccount("test@example.com", "", "", liveApiKey());
|
||||
ASSERT_FALSE(result.ok());
|
||||
EXPECT_EQ(result.status, 400);
|
||||
EXPECT_EQ(result.error_message, "subscription_level is required");
|
||||
}
|
||||
|
||||
TEST(ApiClientLiveTest, CreateAccountInvalidLevelLive) {
|
||||
AccountResult result =
|
||||
createAccount("test@example.com", "NoSuchLevel123", "", liveApiKey());
|
||||
ASSERT_FALSE(result.ok());
|
||||
EXPECT_EQ(result.status, 400);
|
||||
EXPECT_NE(result.error_message.find("not found"), std::string::npos);
|
||||
}
|
||||
|
||||
// ─── Live: POST /automations/create/account success (creates a real
|
||||
// ─── account + emails the user; requires RW_LIVE_ACCOUNT_EMAIL) ────────
|
||||
|
||||
TEST(ApiClientLiveTest, CreateAccountSuccessLive) {
|
||||
const std::string email = env("RW_LIVE_ACCOUNT_EMAIL");
|
||||
if (email.empty()) {
|
||||
GTEST_SKIP() << "RW_LIVE_ACCOUNT_EMAIL not set";
|
||||
}
|
||||
|
||||
AccountResult result = createAccount(email, "Basic", "RadiantWave Test", liveApiKey());
|
||||
ASSERT_TRUE(result.ok()) << "status=" << result.status
|
||||
<< " error=" << result.error_message;
|
||||
EXPECT_TRUE(result.body.success);
|
||||
EXPECT_EQ(result.body.message, "Account and license key created");
|
||||
EXPECT_FALSE(result.body.user_id.empty());
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "test_harness.h"
|
||||
#include <filesystem>
|
||||
|
||||
TEST(fonts_exist)
|
||||
{
|
||||
ASSERT_TRUE(std::filesystem::exists("assets/fonts/Roboto-Regular.ttf"));
|
||||
}
|
||||
|
||||
TEST(rd_title_image_exists)
|
||||
{
|
||||
ASSERT_TRUE(std::filesystem::exists("assets/images/RDW-Title-White-medium.png"));
|
||||
}
|
||||
|
||||
TEST(rwt_circular_image_exists)
|
||||
{
|
||||
ASSERT_TRUE(std::filesystem::exists("assets/images/RWT-Circular-Color-ForOnDark-NoText.png"));
|
||||
}
|
||||
|
||||
RUN_ALL_TESTS()
|
||||
@@ -0,0 +1,38 @@
|
||||
#include "audio/audio_manager.h"
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
using namespace radiantwave::audio;
|
||||
|
||||
TEST(AudioTest, FriendlyDeviceNameMapping) {
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("alsa_output.pci-0000_00_1f.3.analog-stereo"), "Built-In Speakers");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
|
||||
}
|
||||
|
||||
TEST(AudioTest, AudioManagerVolumeClamping) {
|
||||
radiantwave::config::ConfigManager config;
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
ASSERT_TRUE(config.initialize(temp_path));
|
||||
|
||||
AudioManager am;
|
||||
// Test initialization without audio device (graceful fallback)
|
||||
am.initialize(config);
|
||||
|
||||
am.setVolume(150);
|
||||
EXPECT_EQ(am.getVolume(), 100);
|
||||
|
||||
am.setVolume(-20);
|
||||
EXPECT_EQ(am.getVolume(), 0);
|
||||
|
||||
am.setVolume(75);
|
||||
EXPECT_EQ(am.getVolume(), 75);
|
||||
|
||||
am.shutdown();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#include "audio/audio_downloader.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace radiantwave::audio {
|
||||
namespace {
|
||||
|
||||
std::string tempPath(const std::string& suffix) {
|
||||
return "/tmp/radiantwave_ad_test_" + std::to_string(static_cast<long>(getpid())) + suffix;
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderManifest, ParsesCurrentFormatWithDisplayName) {
|
||||
const std::string text =
|
||||
"# checksums generated by md5sum\n"
|
||||
"174Hz.wav '174 Hz Solfeggio' 174 5d41402abc4b2a76b9719d911017c592\n"
|
||||
"PinealGland.wav 'Pineal Gland Activator' 0 e99a18c428cb38d5f260853678922e03\n";
|
||||
std::vector<AudioManifestEntry> manifest;
|
||||
ASSERT_TRUE(parseManifest(text, manifest));
|
||||
ASSERT_EQ(manifest.size(), 2u);
|
||||
|
||||
EXPECT_EQ(manifest[0].filename, "174Hz.wav");
|
||||
EXPECT_EQ(manifest[0].display_name, "174 Hz Solfeggio");
|
||||
EXPECT_EQ(manifest[0].solfeggio_frequency_hz, 174);
|
||||
EXPECT_EQ(manifest[0].md5, "5d41402abc4b2a76b9719d911017c592");
|
||||
|
||||
EXPECT_EQ(manifest[1].filename, "PinealGland.wav");
|
||||
EXPECT_EQ(manifest[1].display_name, "Pineal Gland Activator");
|
||||
EXPECT_EQ(manifest[1].solfeggio_frequency_hz, 0);
|
||||
EXPECT_EQ(manifest[1].md5, "e99a18c428cb38d5f260853678922e03");
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderManifest, ParsesZeroHzAsNull) {
|
||||
const std::string text = "OriginalAudio.wav 'Original Audio' 0 5d41402abc4b2a76b9719d911017c592\n";
|
||||
std::vector<AudioManifestEntry> manifest;
|
||||
ASSERT_TRUE(parseManifest(text, manifest));
|
||||
ASSERT_EQ(manifest.size(), 1u);
|
||||
EXPECT_EQ(manifest[0].solfeggio_frequency_hz, 0);
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderManifest, FallsBackToFilenameForUnrecognizedHzToken) {
|
||||
// A transitional manifest may still carry a frequency id (or any
|
||||
// unrecognized number) in token 2; the filename Hz must win.
|
||||
const std::string text =
|
||||
"396Hz.wav '396 Hz' 2 5d41402abc4b2a76b9719d911017c592\n"
|
||||
"1111Hz.wav '1111 Hz' 10 e99a18c428cb38d5f260853678922e03\n";
|
||||
std::vector<AudioManifestEntry> manifest;
|
||||
ASSERT_TRUE(parseManifest(text, manifest));
|
||||
ASSERT_EQ(manifest.size(), 2u);
|
||||
EXPECT_EQ(manifest[0].solfeggio_frequency_hz, 396);
|
||||
EXPECT_EQ(manifest[1].solfeggio_frequency_hz, 1111);
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderManifest, ParsesLegacyMd5sumFormat) {
|
||||
const std::string text =
|
||||
"5d41402abc4b2a76b9719d911017c592 hello.txt\n"
|
||||
"e99a18c428cb38d5f260853678922e03 *396Hz.wav\n"
|
||||
"a9993e364706816aba3e25717850c26c ./nested.wav\n";
|
||||
std::vector<AudioManifestEntry> manifest;
|
||||
ASSERT_TRUE(parseManifest(text, manifest));
|
||||
ASSERT_EQ(manifest.size(), 3u);
|
||||
|
||||
EXPECT_EQ(manifest[0].filename, "hello.txt");
|
||||
EXPECT_EQ(manifest[0].display_name, "hello");
|
||||
EXPECT_EQ(manifest[0].solfeggio_frequency_hz, 0);
|
||||
EXPECT_EQ(manifest[0].md5, "5d41402abc4b2a76b9719d911017c592");
|
||||
|
||||
EXPECT_EQ(manifest[1].filename, "396Hz.wav");
|
||||
EXPECT_EQ(manifest[1].solfeggio_frequency_hz, 396);
|
||||
EXPECT_EQ(manifest[1].md5, "e99a18c428cb38d5f260853678922e03");
|
||||
|
||||
EXPECT_EQ(manifest[2].filename, "nested.wav");
|
||||
EXPECT_EQ(manifest[2].solfeggio_frequency_hz, 0);
|
||||
EXPECT_EQ(manifest[2].md5, "a9993e364706816aba3e25717850c26c");
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderManifest, SkipsMalformedLines) {
|
||||
const std::string text =
|
||||
"not-a-valid-line\n"
|
||||
"5d41402abc4b2a76b9719d911017c592 ok.txt\n"
|
||||
"1111Hz.wav 'Display Name With Spaces' 10 5d41402abc4b2a76b9719d911017c592\n";
|
||||
std::vector<AudioManifestEntry> manifest;
|
||||
ASSERT_TRUE(parseManifest(text, manifest));
|
||||
ASSERT_EQ(manifest.size(), 2u);
|
||||
EXPECT_EQ(manifest[0].filename, "ok.txt");
|
||||
EXPECT_EQ(manifest[0].solfeggio_frequency_hz, 0);
|
||||
EXPECT_EQ(manifest[1].filename, "1111Hz.wav");
|
||||
EXPECT_EQ(manifest[1].display_name, "Display Name With Spaces");
|
||||
EXPECT_EQ(manifest[1].solfeggio_frequency_hz, 1111);
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderManifest, EmptyOrCommentedTextFails) {
|
||||
std::vector<AudioManifestEntry> manifest;
|
||||
EXPECT_FALSE(parseManifest("", manifest));
|
||||
EXPECT_FALSE(parseManifest("# only a comment\n", manifest));
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderFrequency, MapsKnownHzToIds) {
|
||||
EXPECT_EQ(frequencyIdForHz(174), 1);
|
||||
EXPECT_EQ(frequencyIdForHz(285), 1);
|
||||
EXPECT_EQ(frequencyIdForHz(396), 2);
|
||||
EXPECT_EQ(frequencyIdForHz(417), 3);
|
||||
EXPECT_EQ(frequencyIdForHz(432), 4);
|
||||
EXPECT_EQ(frequencyIdForHz(528), 5);
|
||||
EXPECT_EQ(frequencyIdForHz(639), 6);
|
||||
EXPECT_EQ(frequencyIdForHz(741), 7);
|
||||
EXPECT_EQ(frequencyIdForHz(852), 8);
|
||||
EXPECT_EQ(frequencyIdForHz(963), 9);
|
||||
EXPECT_EQ(frequencyIdForHz(1111), 0);
|
||||
EXPECT_EQ(frequencyIdForHz(0), 0);
|
||||
EXPECT_EQ(frequencyIdForHz(-5), 0);
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderFrequency, ParsesHzFromFilenames) {
|
||||
EXPECT_EQ(hzFromFilename("396Hz.wav"), 396);
|
||||
EXPECT_EQ(hzFromFilename("14Hz_Binaural.wav"), 14);
|
||||
EXPECT_EQ(hzFromFilename("1111Hz.wav"), 1111);
|
||||
EXPECT_EQ(hzFromFilename("396hz.wav"), 396);
|
||||
EXPECT_EQ(hzFromFilename("AlphaWaves.wav"), 0);
|
||||
EXPECT_EQ(hzFromFilename("OriginalAudio.wav"), 0);
|
||||
EXPECT_EQ(hzFromFilename(""), 0);
|
||||
EXPECT_EQ(hzFromFilename("1x396Hz.wav"), 0);
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderMd5, ComputesKnownDigest) {
|
||||
std::string path = tempPath("_hello.txt");
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
out << "hello";
|
||||
}
|
||||
EXPECT_EQ(computeFileMd5(path), "5d41402abc4b2a76b9719d911017c592");
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderMd5, EmptyFileDigest) {
|
||||
std::string path = tempPath("_empty.txt");
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
}
|
||||
EXPECT_EQ(computeFileMd5(path), "d41d8cd98f00b204e9800998ecf8427e");
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST(AudioDownloaderMd5, MissingFileReturnsEmpty) {
|
||||
EXPECT_EQ(computeFileMd5(tempPath("_missing.bin")), "");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace radiantwave::audio
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "db/database_manager.h"
|
||||
#include "network/api_client.h"
|
||||
#include "network/proclamation_sync.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace radiantwave::api;
|
||||
|
||||
namespace {
|
||||
|
||||
// Reads an env var, returning "" when unset.
|
||||
std::string env(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value ? std::string(value) : std::string();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ─── Offline: GET /color-palettes parse ────────────────────────────────
|
||||
|
||||
TEST(ColorPalettesTest, ParseColorPalettesJson) {
|
||||
const std::string body =
|
||||
R"([
|
||||
{"solfeggioFrequencyId":1,
|
||||
"colors":["#8B6914","#B8860B","#DAA520","#E8B64C","#FFD700","#FFE9A8","#FFF4CC"]},
|
||||
{"solfeggioFrequencyId":5,
|
||||
"colors":["#32CD32","#3CB371","#90EE90","#98FB98","#ADFFA9","#C8FFD2","#F0FFF0"]}
|
||||
])";
|
||||
|
||||
std::vector<ColorPalette> out;
|
||||
EXPECT_TRUE(parseColorPalettesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
|
||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 1);
|
||||
ASSERT_EQ(out[0].colors.size(), 7u);
|
||||
EXPECT_EQ(out[0].colors[0].r, 0x8B);
|
||||
EXPECT_EQ(out[0].colors[0].g, 0x69);
|
||||
EXPECT_EQ(out[0].colors[0].b, 0x14);
|
||||
EXPECT_EQ(out[0].colors[6].b, 0xCC);
|
||||
|
||||
EXPECT_EQ(out[1].solfeggio_frequency_id, 5);
|
||||
ASSERT_EQ(out[1].colors.size(), 7u);
|
||||
EXPECT_EQ(out[1].colors[0].r, 0x32);
|
||||
EXPECT_EQ(out[1].colors[0].g, 0xCD);
|
||||
EXPECT_EQ(out[1].colors[0].b, 0x32);
|
||||
}
|
||||
|
||||
TEST(ColorPalettesTest, ParseColorPalettesJsonAcceptsHexWithoutHash) {
|
||||
const std::string body = R"([{"solfeggioFrequencyId":1,"colors":["32CD32"]}])";
|
||||
|
||||
std::vector<ColorPalette> out;
|
||||
EXPECT_TRUE(parseColorPalettesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 1u);
|
||||
ASSERT_EQ(out[0].colors.size(), 1u);
|
||||
EXPECT_EQ(out[0].colors[0].r, 0x32);
|
||||
EXPECT_EQ(out[0].colors[0].g, 0xCD);
|
||||
EXPECT_EQ(out[0].colors[0].b, 0x32);
|
||||
}
|
||||
|
||||
TEST(ColorPalettesTest, ParseColorPalettesJsonSkipsMissingFrequencyId) {
|
||||
const std::string body =
|
||||
R"([{"colors":["#32CD32"]},{"solfeggioFrequencyId":5,"colors":["#00FF7F"]}])";
|
||||
|
||||
std::vector<ColorPalette> out;
|
||||
EXPECT_TRUE(parseColorPalettesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 1u);
|
||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 5);
|
||||
}
|
||||
|
||||
TEST(ColorPalettesTest, ParseColorPalettesJsonEmptyAndMalformed) {
|
||||
std::vector<ColorPalette> out;
|
||||
EXPECT_TRUE(parseColorPalettesJson("[]", out));
|
||||
EXPECT_TRUE(out.empty());
|
||||
|
||||
out.clear();
|
||||
EXPECT_FALSE(parseColorPalettesJson("not json", out));
|
||||
EXPECT_FALSE(parseColorPalettesJson(R"({"solfeggioFrequencyId":1})", out));
|
||||
}
|
||||
|
||||
// ─── Offline: palettes embedded in /proclamations payload ──────────────
|
||||
|
||||
TEST(ColorPalettesTest, ParseProclamationPalettesJson) {
|
||||
const std::string body =
|
||||
R"PALETTE([
|
||||
{"id":"p1","name":"One","solfeggioFrequencyId":1,
|
||||
"colorPalette":["#8B6914","#B8860B"]},
|
||||
{"id":"p2","name":"Two","solfeggioFrequencyId":5,
|
||||
"colorPalette":["#32CD32","#3CB371","#90EE90"]},
|
||||
{"id":"p3","name":"Also freq 5 (ignored, first wins)",
|
||||
"solfeggioFrequencyId":5,
|
||||
"colorPalette":["#FF0000"]}
|
||||
])PALETTE";
|
||||
|
||||
std::vector<radiantwave::db::PaletteRecord> out;
|
||||
EXPECT_TRUE(radiantwave::sync::parseProclamationPalettesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
|
||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 1);
|
||||
ASSERT_EQ(out[0].colors.size(), 2u);
|
||||
EXPECT_EQ(out[0].colors[0].r, 0x8B);
|
||||
|
||||
EXPECT_EQ(out[1].solfeggio_frequency_id, 5);
|
||||
ASSERT_EQ(out[1].colors.size(), 3u);
|
||||
EXPECT_EQ(out[1].colors[0].r, 0x32); // not #FF0000: first proclamation wins
|
||||
}
|
||||
|
||||
TEST(ColorPalettesTest, ParseProclamationPalettesJsonSkipsMissingPalette) {
|
||||
const std::string body =
|
||||
R"([{"id":"p1","name":"No palette","solfeggioFrequencyId":1}])";
|
||||
|
||||
std::vector<radiantwave::db::PaletteRecord> out;
|
||||
EXPECT_TRUE(radiantwave::sync::parseProclamationPalettesJson(body, out));
|
||||
EXPECT_TRUE(out.empty());
|
||||
}
|
||||
|
||||
// ─── Offline: DB round-trip via replaceColorPalettes ───────────────────
|
||||
|
||||
TEST(ColorPalettesTest, ReplaceColorPalettesRoundTrip) {
|
||||
const std::string temp_path =
|
||||
(std::filesystem::temp_directory_path() / "test_rw_db_palettesync.db").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
radiantwave::db::DatabaseManager db;
|
||||
ASSERT_TRUE(db.initialize(temp_path));
|
||||
|
||||
radiantwave::db::PaletteRecord rec;
|
||||
rec.solfeggio_frequency_id = 1;
|
||||
rec.colors = {{0x8B, 0x69, 0x14, 255}, {0xFF, 0xD7, 0x00, 255}};
|
||||
ASSERT_TRUE(db.replaceColorPalettes({rec}));
|
||||
|
||||
auto palette = db.getPaletteForFrequencyId(1);
|
||||
ASSERT_EQ(palette.size(), 2u);
|
||||
EXPECT_EQ(palette[0].r, 0x8B);
|
||||
EXPECT_EQ(palette[0].g, 0x69);
|
||||
EXPECT_EQ(palette[0].b, 0x14);
|
||||
EXPECT_EQ(palette[1].r, 0xFF);
|
||||
EXPECT_EQ(palette[1].g, 0xD7);
|
||||
EXPECT_EQ(palette[1].b, 0x00);
|
||||
|
||||
db.close();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
TEST(ColorPalettesTest, ReplaceColorPalettesEmptyClearsAndFallsBackTo528) {
|
||||
const std::string temp_path =
|
||||
(std::filesystem::temp_directory_path() / "test_rw_db_paletteclear.db").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
radiantwave::db::DatabaseManager db;
|
||||
ASSERT_TRUE(db.initialize(temp_path));
|
||||
|
||||
ASSERT_TRUE(db.replaceColorPalettes({}));
|
||||
|
||||
// id 5 was cleared, so both the direct lookup and the 528 Hz fallback are empty.
|
||||
EXPECT_TRUE(db.getPaletteForFrequencyId(5).empty());
|
||||
|
||||
db.close();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
// ─── Live: GET /color-palettes (route not live yet; opt-in) ────────────
|
||||
|
||||
TEST(ColorPalettesLiveTest, FetchColorPalettesLive) {
|
||||
if (env("RW_LIVE_COLOR_PALETTES").empty()) {
|
||||
GTEST_SKIP() << "RW_LIVE_COLOR_PALETTES not set (route not live yet)";
|
||||
}
|
||||
|
||||
std::vector<ColorPalette> palettes;
|
||||
ASSERT_TRUE(fetchColorPalettes(palettes, env("RW_LIVE_API_KEY")));
|
||||
EXPECT_FALSE(palettes.empty());
|
||||
for (const auto& p : palettes) {
|
||||
EXPECT_GE(p.colors.size(), 1u);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#include "config/config_manager.h"
|
||||
#include "config/yaml_parser.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <thread>
|
||||
|
||||
using namespace radiantwave::config;
|
||||
|
||||
TEST(YamlParserTest, ParseBasicYaml) {
|
||||
std::string yaml = R"(
|
||||
license_key: "KEY-12345"
|
||||
email_address: "test@example.com"
|
||||
provisioning_status: "READY"
|
||||
|
||||
audio:
|
||||
volume: 95
|
||||
output_device: "hdmi"
|
||||
|
||||
proclamation:
|
||||
scroll_speed: 2.5
|
||||
font_size: 40
|
||||
)";
|
||||
|
||||
YamlNode root = parseYaml(yaml);
|
||||
EXPECT_TRUE(root.hasChild("license_key"));
|
||||
EXPECT_EQ(root.getChild("license_key").asString(), "KEY-12345");
|
||||
EXPECT_EQ(root.getChild("email_address").asString(), "test@example.com");
|
||||
|
||||
EXPECT_TRUE(root.hasChild("audio"));
|
||||
EXPECT_EQ(root.getChild("audio").getChild("volume").asInt(), 95);
|
||||
EXPECT_EQ(root.getChild("audio").getChild("output_device").asString(), "hdmi");
|
||||
|
||||
EXPECT_TRUE(root.hasChild("proclamation"));
|
||||
EXPECT_FLOAT_EQ(root.getChild("proclamation").getChild("scroll_speed").asFloat(), 2.5f);
|
||||
EXPECT_EQ(root.getChild("proclamation").getChild("font_size").asInt(), 40);
|
||||
}
|
||||
|
||||
TEST(YamlParserTest, ConfigRoundTrip) {
|
||||
AppConfig original;
|
||||
original.license_key = "RW-TEST-99";
|
||||
original.email_address = "admin@radiantwave.com";
|
||||
original.audio.volume = 60;
|
||||
original.audio.dynamic_audio = false;
|
||||
original.proclamation.scroll_speed = 1.8f;
|
||||
|
||||
std::string serialized = serializeConfigToYaml(original);
|
||||
AppConfig parsed = parseConfigFromYaml(serialized);
|
||||
|
||||
EXPECT_EQ(parsed.license_key, original.license_key);
|
||||
EXPECT_EQ(parsed.email_address, original.email_address);
|
||||
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);
|
||||
}
|
||||
|
||||
TEST(ConfigManagerTest, DefaultGeneration) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_config1.yaml").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
ConfigManager manager;
|
||||
EXPECT_TRUE(manager.initialize(temp_path));
|
||||
|
||||
AppConfig cfg = manager.get();
|
||||
EXPECT_EQ(cfg.audio.volume, 80);
|
||||
EXPECT_EQ(cfg.audio.dynamic_audio, true);
|
||||
EXPECT_EQ(cfg.proclamation.font_size, 32);
|
||||
|
||||
EXPECT_TRUE(std::filesystem::exists(temp_path));
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
TEST(ConfigManagerTest, MergePreservesExistingValues) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_config2.yaml").string();
|
||||
|
||||
// Write partial config
|
||||
{
|
||||
std::ofstream file(temp_path);
|
||||
file << "license_key: \"MY-LICENSE\"\n";
|
||||
file << "audio:\n";
|
||||
file << " volume: 42\n";
|
||||
}
|
||||
|
||||
ConfigManager manager;
|
||||
EXPECT_TRUE(manager.initialize(temp_path));
|
||||
|
||||
AppConfig cfg = manager.get();
|
||||
// Preserved custom values
|
||||
EXPECT_EQ(cfg.license_key, "MY-LICENSE");
|
||||
EXPECT_EQ(cfg.audio.volume, 42);
|
||||
|
||||
// Default values populated for missing fields
|
||||
EXPECT_EQ(cfg.proclamation.font_size, 32);
|
||||
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
TEST(ConfigManagerTest, HotReloadWatcher) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_config3.yaml").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
ConfigManager manager;
|
||||
EXPECT_TRUE(manager.initialize(temp_path));
|
||||
|
||||
std::atomic<bool> reloaded{false};
|
||||
int new_font_size = 0;
|
||||
|
||||
manager.onChanged([&](const AppConfig& new_cfg) {
|
||||
new_font_size = new_cfg.proclamation.font_size;
|
||||
reloaded = true;
|
||||
});
|
||||
|
||||
// Pause briefly then write modified config to disk
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(600));
|
||||
|
||||
{
|
||||
std::ofstream file(temp_path, std::ios::out | std::ios::trunc);
|
||||
file << "proclamation:\n";
|
||||
file << " font_size: 48\n";
|
||||
}
|
||||
|
||||
// Wait for file watcher thread to pick up change
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
if (reloaded) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
|
||||
EXPECT_TRUE(reloaded);
|
||||
EXPECT_EQ(new_font_size, 48);
|
||||
|
||||
manager.shutdown();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#include "date_format.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
|
||||
using namespace radiantwave::datetime;
|
||||
|
||||
class DateFormatTest : public ::testing::Test {
|
||||
protected:
|
||||
static void SetUpTestSuite() {
|
||||
setenv("TZ", "UTC", 1);
|
||||
tzset();
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(DateFormatTest, DefaultFormatConstant) {
|
||||
EXPECT_STREQ(DEFAULT_DATE_FORMAT, "%m/%d/%Y");
|
||||
}
|
||||
|
||||
TEST_F(DateFormatTest, FormatsKnownTimestamp) {
|
||||
std::tm tm = {};
|
||||
tm.tm_year = 2026 - 1900;
|
||||
tm.tm_mon = 8 - 1;
|
||||
tm.tm_mday = 4;
|
||||
tm.tm_hour = 14;
|
||||
tm.tm_min = 23;
|
||||
tm.tm_sec = 45;
|
||||
tm.tm_isdst = -1;
|
||||
time_t t = mktime(&tm);
|
||||
|
||||
ASSERT_NE(t, static_cast<time_t>(-1));
|
||||
EXPECT_EQ(formatDate(t, "%m/%d/%Y"), "08/04/2026");
|
||||
EXPECT_EQ(formatDate(t, "%d/%m/%Y"), "04/08/2026");
|
||||
EXPECT_EQ(formatDate(t, "%Y-%m-%d"), "2026-08-04");
|
||||
EXPECT_EQ(formatDate(t, "%m.%d.%Y"), "08.04.2026");
|
||||
EXPECT_EQ(formatDate(t, "%d.%m.%Y"), "04.08.2026");
|
||||
EXPECT_EQ(formatDate(t, "%B %d, %Y"), "August 04, 2026");
|
||||
EXPECT_EQ(formatDate(t, "%d %B %Y"), "04 August 2026");
|
||||
}
|
||||
|
||||
TEST_F(DateFormatTest, FormatsTime) {
|
||||
std::tm tm = {};
|
||||
tm.tm_year = 2026 - 1900;
|
||||
tm.tm_mon = 8 - 1;
|
||||
tm.tm_mday = 4;
|
||||
tm.tm_hour = 14;
|
||||
tm.tm_min = 23;
|
||||
tm.tm_sec = 45;
|
||||
tm.tm_isdst = -1;
|
||||
time_t t = mktime(&tm);
|
||||
|
||||
ASSERT_NE(t, static_cast<time_t>(-1));
|
||||
EXPECT_EQ(formatTime(t), "14:23:45");
|
||||
}
|
||||
|
||||
TEST_F(DateFormatTest, OptionsIncludeDefault) {
|
||||
auto options = getDateFormatOptions();
|
||||
ASSERT_FALSE(options.empty());
|
||||
EXPECT_EQ(options[0].pattern, std::string(DEFAULT_DATE_FORMAT));
|
||||
EXPECT_EQ(options[0].name, "Month/Day/Year");
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#include "db/database_manager.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <filesystem>
|
||||
|
||||
using namespace radiantwave::db;
|
||||
|
||||
TEST(DatabaseManagerTest, InitializeAndMigrate) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_db1.db").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
DatabaseManager db;
|
||||
EXPECT_TRUE(db.initialize(temp_path));
|
||||
|
||||
EXPECT_TRUE(std::filesystem::exists(temp_path));
|
||||
|
||||
db.close();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, KeyValueStoreOperations) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_db2.db").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
DatabaseManager db;
|
||||
ASSERT_TRUE(db.initialize(temp_path));
|
||||
|
||||
EXPECT_FALSE(db.hasKV("device_token"));
|
||||
EXPECT_EQ(db.getKV("device_token", "default_token"), "default_token");
|
||||
|
||||
EXPECT_TRUE(db.setKV("device_token", "ABC-123-XYZ"));
|
||||
EXPECT_TRUE(db.hasKV("device_token"));
|
||||
EXPECT_EQ(db.getKV("device_token"), "ABC-123-XYZ");
|
||||
|
||||
// Upsert test
|
||||
EXPECT_TRUE(db.setKV("device_token", "NEW-TOKEN-789"));
|
||||
EXPECT_EQ(db.getKV("device_token"), "NEW-TOKEN-789");
|
||||
|
||||
// Delete test
|
||||
EXPECT_TRUE(db.deleteKV("device_token"));
|
||||
EXPECT_FALSE(db.hasKV("device_token"));
|
||||
|
||||
db.close();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, ProclamationsAndDownloads) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_db3.db").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
DatabaseManager db;
|
||||
ASSERT_TRUE(db.initialize(temp_path));
|
||||
|
||||
ProclamationRecord proc;
|
||||
proc.id = "p1";
|
||||
proc.title = "Peace";
|
||||
proc.content = "I am peace.";
|
||||
proc.solfeggio_freq_hz = 528.0;
|
||||
|
||||
EXPECT_TRUE(db.saveProclamation(proc));
|
||||
|
||||
auto active = db.getActiveProclamations();
|
||||
ASSERT_EQ(active.size(), 1u);
|
||||
EXPECT_EQ(active[0].title, "Peace");
|
||||
EXPECT_DOUBLE_EQ(active[0].solfeggio_freq_hz, 528.0);
|
||||
|
||||
AssetDownloadRecord dl;
|
||||
dl.id = "a1";
|
||||
dl.filename = "audio1.wav";
|
||||
dl.url = "https://example.com/audio1.wav";
|
||||
dl.status = "pending";
|
||||
|
||||
EXPECT_TRUE(db.updateAssetDownload(dl));
|
||||
|
||||
auto pending = db.getPendingAssetDownloads();
|
||||
ASSERT_EQ(pending.size(), 1u);
|
||||
EXPECT_EQ(pending[0].filename, "audio1.wav");
|
||||
|
||||
EXPECT_TRUE(db.logEvent("INFO", "Test", "All systems operational"));
|
||||
|
||||
db.close();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Helper: build a fresh in-memory-less temp DatabaseManager.
|
||||
struct DbFixture {
|
||||
std::string path;
|
||||
DatabaseManager db;
|
||||
|
||||
DbFixture(const char* tag) {
|
||||
path = (std::filesystem::temp_directory_path() / tag).string();
|
||||
if (std::filesystem::exists(path)) {
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
db.initialize(path);
|
||||
}
|
||||
|
||||
~DbFixture() {
|
||||
db.close();
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
};
|
||||
|
||||
ProclamationRecord makeProc(const std::string& id, const std::string& title, int cat) {
|
||||
ProclamationRecord p;
|
||||
p.id = id;
|
||||
p.title = title;
|
||||
p.content = "content of " + title;
|
||||
p.category_id = cat;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(DatabaseManagerTest, SyncProclamationsPreservesSelectionAndDeletesRemoved) {
|
||||
const std::string defId = "default-1";
|
||||
DbFixture fx("test_rw_db_sync.db");
|
||||
|
||||
ProclamationRecord a = makeProc("a", "Alpha", 1);
|
||||
ProclamationRecord b = makeProc("b", "Beta", 1);
|
||||
|
||||
// First sync inserts both. The incoming `selected` flag is never trusted.
|
||||
ASSERT_TRUE(fx.db.syncProclamations({a, b}, defId));
|
||||
|
||||
// Simulate the user picking "a".
|
||||
ASSERT_TRUE(fx.db.setProclamationSelected("a", true));
|
||||
|
||||
// Re-sync: "a" stays selected, "b" is removed because the API no longer
|
||||
// returns it.
|
||||
ASSERT_TRUE(fx.db.syncProclamations({a}, defId));
|
||||
|
||||
auto all = fx.db.getAllProclamations();
|
||||
ASSERT_EQ(all.size(), 1u);
|
||||
EXPECT_EQ(all[0].id, "a");
|
||||
EXPECT_TRUE(all[0].selected);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, RequiredLevelIdRoundTripsThroughSync) {
|
||||
const std::string defId = "default-1";
|
||||
DbFixture fx("test_rw_db_level.db");
|
||||
|
||||
ProclamationRecord def = makeProc(defId, "Default", 1);
|
||||
def.required_level_id = 1;
|
||||
ProclamationRecord elite = makeProc("elite", "Elite Only", 2);
|
||||
elite.required_level_id = 3;
|
||||
|
||||
ASSERT_TRUE(fx.db.syncProclamations({def, elite}, defId));
|
||||
|
||||
auto all = fx.db.getAllProclamations();
|
||||
ASSERT_EQ(all.size(), 2u);
|
||||
for (const auto& p : all) {
|
||||
EXPECT_EQ(p.required_level_id, p.id == "elite" ? 3 : 1);
|
||||
}
|
||||
|
||||
// A re-sync with the same records keeps the level (and selection).
|
||||
ASSERT_TRUE(fx.db.syncProclamations({def, elite}, defId));
|
||||
auto again = fx.db.getAllProclamations();
|
||||
ASSERT_EQ(again.size(), 2u);
|
||||
for (const auto& p : again) {
|
||||
EXPECT_EQ(p.required_level_id, p.id == "elite" ? 3 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, SyncForcesDefaultProclamationSelected) {
|
||||
const std::string defId = "default-1";
|
||||
DbFixture fx("test_rw_db_def.db");
|
||||
|
||||
ProclamationRecord def = makeProc(defId, "Default", 3);
|
||||
ProclamationRecord other = makeProc("x", "Other", 3);
|
||||
def.selected = false;
|
||||
|
||||
ASSERT_TRUE(fx.db.syncProclamations({def, other}, defId));
|
||||
ASSERT_TRUE(fx.db.ensureDefaultProclamationSelected(defId));
|
||||
|
||||
auto all = fx.db.getAllProclamations();
|
||||
for (const auto& p : all) {
|
||||
if (p.id == defId) {
|
||||
EXPECT_TRUE(p.selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, SaveSelectedProclamationsClearsOthers) {
|
||||
const std::string defId = "default-1";
|
||||
DbFixture fx("test_rw_db_save.db");
|
||||
|
||||
ProclamationRecord def = makeProc(defId, "Default", 1);
|
||||
ProclamationRecord a = makeProc("a", "A", 1);
|
||||
ProclamationRecord b = makeProc("b", "B", 1);
|
||||
ASSERT_TRUE(fx.db.syncProclamations({def, a, b}, defId));
|
||||
ASSERT_TRUE(fx.db.setProclamationSelected("a", true));
|
||||
|
||||
// Deselect everything except a; the default must still end up selected.
|
||||
ASSERT_TRUE(fx.db.saveSelectedProclamations({"a"}, defId));
|
||||
|
||||
auto all = fx.db.getAllProclamations();
|
||||
for (const auto& p : all) {
|
||||
bool expected = (p.id == "a" || p.id == defId);
|
||||
EXPECT_EQ(p.selected, expected) << "proclamation " << p.id;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
|
||||
DbFixture fx("test_rw_db_cats.db");
|
||||
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
cats.push_back({3, "Root", 0, 1});
|
||||
cats.push_back({7, "Child", 3, 2});
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
||||
|
||||
auto all = fx.db.getAllCategories();
|
||||
ASSERT_EQ(all.size(), 2u);
|
||||
EXPECT_EQ(all[0].id, 3);
|
||||
EXPECT_EQ(all[0].parent_id, 0);
|
||||
EXPECT_EQ(all[1].id, 7);
|
||||
EXPECT_EQ(all[1].parent_id, 3);
|
||||
|
||||
// Replacing wipes previous categories.
|
||||
ASSERT_TRUE(fx.db.replaceCategories({}));
|
||||
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
|
||||
DbFixture fx("test_rw_db_audio.db");
|
||||
|
||||
std::vector<AudioFileRecord> first = {
|
||||
{0, "174 Hz Solfeggio", "174Hz.wav", 1, 1},
|
||||
{0, "Original Audio", "OriginalAudio.wav", 2, 0},
|
||||
{0, "Alpha Waves", "AlphaWaves.wav", 3, 13},
|
||||
};
|
||||
|
||||
ASSERT_TRUE(fx.db.syncAudioFiles(first));
|
||||
|
||||
auto rows = fx.db.getAudioFiles();
|
||||
ASSERT_EQ(rows.size(), 3u);
|
||||
EXPECT_EQ(rows[0].filename, "174Hz.wav");
|
||||
EXPECT_EQ(rows[0].display_name, "174 Hz Solfeggio");
|
||||
EXPECT_EQ(rows[0].display_order, 1);
|
||||
EXPECT_EQ(rows[0].solfeggio_frequency_id, 1);
|
||||
EXPECT_EQ(rows[1].filename, "OriginalAudio.wav");
|
||||
EXPECT_EQ(rows[1].solfeggio_frequency_id, 0);
|
||||
EXPECT_EQ(rows[2].filename, "AlphaWaves.wav");
|
||||
EXPECT_EQ(rows[2].solfeggio_frequency_id, 13);
|
||||
|
||||
// Manifest update: renamed display name, reordered, one file removed.
|
||||
std::vector<AudioFileRecord> second = {
|
||||
{0, "174 Hz Solfeggio (Updated)", "174Hz.wav", 2, 1},
|
||||
{0, "Alpha Waves", "AlphaWaves.wav", 1, 13},
|
||||
};
|
||||
|
||||
ASSERT_TRUE(fx.db.syncAudioFiles(second));
|
||||
|
||||
rows = fx.db.getAudioFiles();
|
||||
ASSERT_EQ(rows.size(), 2u);
|
||||
EXPECT_EQ(rows[0].filename, "AlphaWaves.wav");
|
||||
EXPECT_EQ(rows[0].display_order, 1);
|
||||
EXPECT_EQ(rows[1].filename, "174Hz.wav");
|
||||
EXPECT_EQ(rows[1].display_name, "174 Hz Solfeggio (Updated)");
|
||||
EXPECT_EQ(rows[1].display_order, 2);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) {
|
||||
DbFixture fx("test_rw_db_audio_empty.db");
|
||||
|
||||
ASSERT_TRUE(fx.db.syncAudioFiles({{0, "Original Audio", "OriginalAudio.wav", 1, 0}}));
|
||||
ASSERT_EQ(fx.db.getAudioFiles().size(), 1u);
|
||||
|
||||
ASSERT_TRUE(fx.db.syncAudioFiles({}));
|
||||
EXPECT_EQ(fx.db.getAudioFiles().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, PalettesSeededAndRead) {
|
||||
DbFixture fx("test_rw_db_palette.db");
|
||||
|
||||
auto palette = fx.db.getPaletteForFrequencyId(5); // 528 Hz
|
||||
ASSERT_EQ(palette.size(), 7u);
|
||||
EXPECT_EQ(palette[0].r, 0x32);
|
||||
EXPECT_EQ(palette[0].g, 0xCD);
|
||||
EXPECT_EQ(palette[0].b, 0x32);
|
||||
EXPECT_EQ(palette[0].a, 255u);
|
||||
EXPECT_EQ(palette[6].r, 0xF0);
|
||||
EXPECT_EQ(palette[6].g, 0xFF);
|
||||
EXPECT_EQ(palette[6].b, 0xF0);
|
||||
|
||||
auto red = fx.db.getPaletteForFrequencyId(2); // 396 Hz
|
||||
ASSERT_EQ(red.size(), 7u);
|
||||
EXPECT_EQ(red[0].r, 0x8B);
|
||||
EXPECT_EQ(red[0].g, 0x00);
|
||||
EXPECT_EQ(red[0].b, 0x00);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, UnknownFrequencyFallsBackTo528) {
|
||||
DbFixture fx("test_rw_db_palette_fb.db");
|
||||
|
||||
auto palette = fx.db.getPaletteForFrequencyId(9999);
|
||||
ASSERT_EQ(palette.size(), 7u);
|
||||
EXPECT_EQ(palette[0].r, 0x32);
|
||||
EXPECT_EQ(palette[0].g, 0xCD);
|
||||
EXPECT_EQ(palette[0].b, 0x32);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, PaletteSeedIsIdempotent) {
|
||||
DbFixture fx("test_rw_db_palette_seed.db");
|
||||
|
||||
// Re-initialize on the same file; seeding must not duplicate rows.
|
||||
fx.db.close();
|
||||
ASSERT_TRUE(fx.db.initialize(fx.path));
|
||||
|
||||
auto palette = fx.db.getPaletteForFrequencyId(9); // 963 Hz
|
||||
ASSERT_EQ(palette.size(), 7u);
|
||||
EXPECT_EQ(palette[0].r, 0x94);
|
||||
EXPECT_EQ(palette[0].g, 0x00);
|
||||
EXPECT_EQ(palette[0].b, 0xD3);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "fibonacci_line_pattern.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
float widthOf(const std::string& word)
|
||||
{
|
||||
return static_cast<float>(word.size()) * 10.0f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(FibonacciLinePatternTest, WordCountFollowsFibonacciSequence)
|
||||
{
|
||||
radiantwave::FibonacciLinePattern pattern(13, 100000.0f, 10.0f, widthOf);
|
||||
// Enough words that the first sixteen lines never reach the end of the list,
|
||||
// so line counts follow the pure Fibonacci oscillation.
|
||||
std::vector<std::string> words;
|
||||
for (int i = 0; i < 100; ++i)
|
||||
{
|
||||
words.push_back("w" + std::to_string(i));
|
||||
}
|
||||
std::vector<int> expected = {1, 1, 2, 3, 5, 8, 13, 8, 5, 3, 2, 1, 1, 2, 3, 5};
|
||||
|
||||
for (int count : expected)
|
||||
{
|
||||
std::vector<std::string> line;
|
||||
int placed = pattern.nextLine(words, line);
|
||||
EXPECT_EQ(placed, count);
|
||||
EXPECT_EQ(line.size(), static_cast<size_t>(count));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FibonacciLinePatternTest, SequencePeaksAtConfiguredWordCount)
|
||||
{
|
||||
// A 15-word peak (what a 1920px screen might ask for) becomes the apex of
|
||||
// the oscillation instead of a hardcoded 13.
|
||||
radiantwave::FibonacciLinePattern pattern(15, 100000.0f, 10.0f, widthOf);
|
||||
std::vector<std::string> words;
|
||||
for (int i = 0; i < 100; ++i)
|
||||
{
|
||||
words.push_back("w" + std::to_string(i));
|
||||
}
|
||||
std::vector<int> expected = {1, 1, 2, 3, 5, 8, 13, 15, 13, 8, 5, 3, 2, 1, 1, 2};
|
||||
|
||||
for (int count : expected)
|
||||
{
|
||||
std::vector<std::string> line;
|
||||
EXPECT_EQ(pattern.nextLine(words, line), count);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FibonacciLinePatternTest, WidthCapTruncatesLines)
|
||||
{
|
||||
// Each word is 100px, space is 10px; the cap is 300px.
|
||||
radiantwave::FibonacciLinePattern pattern(
|
||||
13, 300.0f, 10.0f, [](const std::string &) { return 100.0f; });
|
||||
std::vector<std::string> words = {"aaaa", "bbbb", "cccc", "dddd", "eeee"};
|
||||
|
||||
std::vector<std::string> line;
|
||||
EXPECT_EQ(pattern.nextLine(words, line), 1); // target 1
|
||||
line.clear();
|
||||
EXPECT_EQ(pattern.nextLine(words, line), 1); // target 1
|
||||
line.clear();
|
||||
EXPECT_EQ(pattern.nextLine(words, line), 2); // target 2: 100 + 10 + 100 = 210 <= 300
|
||||
line.clear();
|
||||
// target 3: only "eeee" remains before the end of the list, so the line
|
||||
// stops with one word rather than wrapping mid-line.
|
||||
EXPECT_EQ(pattern.nextLine(words, line), 1);
|
||||
EXPECT_EQ(line[0], "eeee");
|
||||
}
|
||||
|
||||
TEST(FibonacciLinePatternTest, FirstWordAlwaysPlaced)
|
||||
{
|
||||
radiantwave::FibonacciLinePattern pattern(
|
||||
13, 5.0f, 10.0f, [](const std::string &) { return 100.0f; });
|
||||
std::vector<std::string> words = {"aaa", "bbb"};
|
||||
|
||||
std::vector<std::string> line;
|
||||
EXPECT_EQ(pattern.nextLine(words, line), 1);
|
||||
EXPECT_EQ(line[0], "aaa");
|
||||
}
|
||||
|
||||
TEST(FibonacciLinePatternTest, WrapsOnlyAtLineBoundaries)
|
||||
{
|
||||
radiantwave::FibonacciLinePattern pattern(13, 100000.0f, 10.0f, widthOf);
|
||||
std::vector<std::string> words = {"a", "b", "c"};
|
||||
|
||||
std::vector<std::vector<std::string>> lines;
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
std::vector<std::string> line;
|
||||
pattern.nextLine(words, line);
|
||||
lines.push_back(line);
|
||||
}
|
||||
|
||||
// Words are consumed in order and never wrap mid-line: every line is a
|
||||
// contiguous slice of "a b c a b c ...".
|
||||
ASSERT_EQ(lines[0].size(), 1u);
|
||||
EXPECT_EQ(lines[0][0], "a");
|
||||
ASSERT_EQ(lines[1].size(), 1u);
|
||||
EXPECT_EQ(lines[1][0], "b");
|
||||
ASSERT_EQ(lines[2].size(), 1u); // only "c" remains; the list ends, no wrap
|
||||
EXPECT_EQ(lines[2][0], "c");
|
||||
ASSERT_EQ(lines[3].size(), 3u); // wraps at the line boundary instead
|
||||
EXPECT_EQ(lines[3][0], "a");
|
||||
EXPECT_EQ(lines[3][1], "b");
|
||||
EXPECT_EQ(lines[3][2], "c");
|
||||
ASSERT_EQ(lines[4].size(), 3u);
|
||||
EXPECT_EQ(lines[4][0], "a");
|
||||
ASSERT_EQ(lines[5].size(), 3u);
|
||||
EXPECT_EQ(lines[5][0], "a");
|
||||
}
|
||||
|
||||
TEST(FibonacciLinePatternTest, EmptyWordListYieldsZero)
|
||||
{
|
||||
radiantwave::FibonacciLinePattern pattern(13, 100.0f, 10.0f, widthOf);
|
||||
std::vector<std::string> words;
|
||||
std::vector<std::string> line;
|
||||
EXPECT_EQ(pattern.nextLine(words, line), 0);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
#include "scramble/fibonacci_scrambler.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using radiantwave::scramble::ScrambleConfig;
|
||||
using radiantwave::scramble::canvasSize;
|
||||
using radiantwave::scramble::scramble;
|
||||
using radiantwave::scramble::unscramble;
|
||||
|
||||
// W x H RGBA8888 image whose every pixel has a distinct 32-bit value, so a
|
||||
// permutation can be verified by counting values. Pixel (x, y) gets the value
|
||||
// y*width + x + 1; the +1 keeps every source value distinct from the
|
||||
// transparent-black padding (0).
|
||||
std::vector<uint8_t> uniqueImage(int width, int height)
|
||||
{
|
||||
std::vector<uint8_t> img(static_cast<size_t>(width) * height * 4);
|
||||
for (int y = 0; y < height; ++y)
|
||||
{
|
||||
for (int x = 0; x < width; ++x)
|
||||
{
|
||||
const uint32_t v = static_cast<uint32_t>(y) * width + x + 1;
|
||||
uint8_t *p = img.data() + (static_cast<size_t>(y) * width + x) * 4;
|
||||
p[0] = static_cast<uint8_t>(v & 0xFF);
|
||||
p[1] = static_cast<uint8_t>((v >> 8) & 0xFF);
|
||||
p[2] = static_cast<uint8_t>((v >> 16) & 0xFF);
|
||||
p[3] = static_cast<uint8_t>((v >> 24) & 0xFF);
|
||||
}
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
bool roundTrips(int width, int height, int rounds, uint64_t seed)
|
||||
{
|
||||
const auto img = uniqueImage(width, height);
|
||||
const int n = canvasSize(width, height);
|
||||
std::vector<uint8_t> scrambled(static_cast<size_t>(n) * n * 4);
|
||||
std::vector<uint8_t> recovered(static_cast<size_t>(width) * height * 4);
|
||||
|
||||
ScrambleConfig cfg;
|
||||
cfg.rounds = rounds;
|
||||
cfg.seed = seed;
|
||||
if (!scramble(img.data(), scrambled.data(), width, height, cfg))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!unscramble(scrambled.data(), recovered.data(), width, height, cfg))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return recovered == img;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(FibonacciScramblerTest, CanvasSizeIsMaxOfDimensions)
|
||||
{
|
||||
EXPECT_EQ(canvasSize(1, 1), 1);
|
||||
EXPECT_EQ(canvasSize(8, 8), 8);
|
||||
EXPECT_EQ(canvasSize(8, 4), 8);
|
||||
EXPECT_EQ(canvasSize(4, 8), 8);
|
||||
EXPECT_EQ(canvasSize(1, 8), 8);
|
||||
EXPECT_EQ(canvasSize(8, 1), 8);
|
||||
EXPECT_EQ(canvasSize(0, 8), 0);
|
||||
EXPECT_EQ(canvasSize(8, 0), 0);
|
||||
EXPECT_EQ(canvasSize(0, 0), 0);
|
||||
EXPECT_EQ(canvasSize(-3, 8), 0);
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, RoundTripIsExactForSquareImages)
|
||||
{
|
||||
const int sizes[] = {1, 2, 3, 8, 32};
|
||||
for (int n : sizes)
|
||||
{
|
||||
for (int t = 1; t <= 6; ++t)
|
||||
{
|
||||
EXPECT_TRUE(roundTrips(n, n, t, radiantwave::scramble::kDefaultSeed))
|
||||
<< "N=" << n << " rounds=" << t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, RoundTripIsExactForWideAndTallImages)
|
||||
{
|
||||
const int dims[][2] = {{8, 4}, {4, 8}, {1, 8}, {8, 1}, {3, 17}, {31, 3}};
|
||||
for (const auto &d : dims)
|
||||
{
|
||||
for (int t : {1, 4})
|
||||
{
|
||||
EXPECT_TRUE(roundTrips(d[0], d[1], t, radiantwave::scramble::kDefaultSeed))
|
||||
<< "W=" << d[0] << " H=" << d[1] << " rounds=" << t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, RoundTripIsExactForGlyphShapedTiles)
|
||||
{
|
||||
// Punctuation glyphs are narrow or short, so N is max(W, H): these exercise
|
||||
// the non-square padding path with glyph-like proportions (e.g. an
|
||||
// apostrophe tile is 6 wide x 38 tall at 32px).
|
||||
const int dims[][2] = {{1, 5}, {2, 7}, {6, 1}, {1, 1},
|
||||
{17, 37}, {9, 37}, {37, 9}};
|
||||
for (const auto &d : dims)
|
||||
{
|
||||
for (int t : {1, 4})
|
||||
{
|
||||
EXPECT_TRUE(roundTrips(d[0], d[1], t, radiantwave::scramble::kDefaultSeed))
|
||||
<< "W=" << d[0] << " H=" << d[1] << " rounds=" << t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, ScrambleIsABijectionOnTheCanvas)
|
||||
{
|
||||
const int dims[][2] = {{5, 7}, {8, 8}, {3, 1}, {1, 4}};
|
||||
for (const auto &d : dims)
|
||||
{
|
||||
const int width = d[0];
|
||||
const int height = d[1];
|
||||
const int n = canvasSize(width, height);
|
||||
const auto img = uniqueImage(width, height);
|
||||
std::vector<uint8_t> scrambled(static_cast<size_t>(n) * n * 4);
|
||||
|
||||
ScrambleConfig cfg;
|
||||
cfg.rounds = 3;
|
||||
ASSERT_TRUE(scramble(img.data(), scrambled.data(), width, height, cfg));
|
||||
|
||||
std::unordered_map<uint32_t, int> counts;
|
||||
for (const uint8_t *p = scrambled.data(); p != scrambled.data() + scrambled.size(); p += 4)
|
||||
{
|
||||
const uint32_t v = static_cast<uint32_t>(p[0]) |
|
||||
(static_cast<uint32_t>(p[1]) << 8) |
|
||||
(static_cast<uint32_t>(p[2]) << 16) |
|
||||
(static_cast<uint32_t>(p[3]) << 24);
|
||||
counts[v]++;
|
||||
}
|
||||
|
||||
// Every source pixel value appears exactly once (the map restricted to
|
||||
// the W x H region is injective)...
|
||||
const size_t pixels = static_cast<size_t>(width) * height;
|
||||
for (size_t i = 0; i < pixels; ++i)
|
||||
{
|
||||
const uint32_t v = static_cast<uint32_t>(i) + 1;
|
||||
EXPECT_EQ(counts[v], 1)
|
||||
<< "W=" << width << " H=" << height << " value=" << v;
|
||||
}
|
||||
// ...and the remaining canvas positions are transparent-black padding.
|
||||
EXPECT_EQ(counts[0], static_cast<int>(static_cast<size_t>(n) * n - pixels))
|
||||
<< "W=" << width << " H=" << height;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, ScrambleChangesTheImage)
|
||||
{
|
||||
const auto img = uniqueImage(8, 8);
|
||||
const int n = 8;
|
||||
std::vector<uint8_t> scrambled(static_cast<size_t>(n) * n * 4);
|
||||
|
||||
ScrambleConfig cfg;
|
||||
cfg.rounds = 1;
|
||||
ASSERT_TRUE(scramble(img.data(), scrambled.data(), 8, 8, cfg));
|
||||
|
||||
bool changed = false;
|
||||
for (size_t i = 0; i < img.size(); ++i)
|
||||
{
|
||||
if (scrambled[i] != img[i])
|
||||
{
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(changed);
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, OutputIsDeterministic)
|
||||
{
|
||||
const auto img = uniqueImage(6, 9);
|
||||
const int n = 9;
|
||||
std::vector<uint8_t> a(static_cast<size_t>(n) * n * 4);
|
||||
std::vector<uint8_t> b(static_cast<size_t>(n) * n * 4);
|
||||
|
||||
ScrambleConfig cfg;
|
||||
cfg.rounds = 3;
|
||||
ASSERT_TRUE(scramble(img.data(), a.data(), 6, 9, cfg));
|
||||
ASSERT_TRUE(scramble(img.data(), b.data(), 6, 9, cfg));
|
||||
EXPECT_EQ(a, b);
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, DifferentSeedChangesOutput)
|
||||
{
|
||||
const auto img = uniqueImage(8, 8);
|
||||
const int n = 8;
|
||||
std::vector<uint8_t> a(static_cast<size_t>(n) * n * 4);
|
||||
std::vector<uint8_t> b(static_cast<size_t>(n) * n * 4);
|
||||
|
||||
ScrambleConfig cfgA;
|
||||
cfgA.rounds = 2;
|
||||
cfgA.seed = radiantwave::scramble::kDefaultSeed;
|
||||
ScrambleConfig cfgB = cfgA;
|
||||
cfgB.seed = cfgA.seed + 1;
|
||||
|
||||
ASSERT_TRUE(scramble(img.data(), a.data(), 8, 8, cfgA));
|
||||
ASSERT_TRUE(scramble(img.data(), b.data(), 8, 8, cfgB));
|
||||
EXPECT_NE(a, b);
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, DifferentRoundsChangeOutput)
|
||||
{
|
||||
const auto img = uniqueImage(8, 8);
|
||||
const int n = 8;
|
||||
std::vector<uint8_t> a(static_cast<size_t>(n) * n * 4);
|
||||
std::vector<uint8_t> b(static_cast<size_t>(n) * n * 4);
|
||||
|
||||
ScrambleConfig cfgA;
|
||||
cfgA.rounds = 1;
|
||||
ScrambleConfig cfgB;
|
||||
cfgB.rounds = 2;
|
||||
|
||||
ASSERT_TRUE(scramble(img.data(), a.data(), 8, 8, cfgA));
|
||||
ASSERT_TRUE(scramble(img.data(), b.data(), 8, 8, cfgB));
|
||||
EXPECT_NE(a, b);
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, AlphaChannelIsPreservedByRoundTrip)
|
||||
{
|
||||
// Pixel values exercise all 32 bits, alpha included.
|
||||
EXPECT_TRUE(roundTrips(11, 7, 5, 12345));
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, PaddingIsTransparentAndRoundTrips)
|
||||
{
|
||||
// Opaque white source: after scrambling, every canvas pixel must be either
|
||||
// white (a real source pixel) or transparent black (padding), and the
|
||||
// unscramble must restore pure white.
|
||||
const int width = 5;
|
||||
const int height = 7;
|
||||
const int n = canvasSize(width, height);
|
||||
std::vector<uint8_t> img(static_cast<size_t>(width) * height * 4, 255);
|
||||
std::vector<uint8_t> scrambled(static_cast<size_t>(n) * n * 4);
|
||||
std::vector<uint8_t> recovered(static_cast<size_t>(width) * height * 4);
|
||||
|
||||
ScrambleConfig cfg;
|
||||
cfg.rounds = 4;
|
||||
ASSERT_TRUE(scramble(img.data(), scrambled.data(), width, height, cfg));
|
||||
for (size_t i = 0; i < scrambled.size(); i += 4)
|
||||
{
|
||||
const bool isWhite = scrambled[i] == 255 && scrambled[i + 1] == 255 &&
|
||||
scrambled[i + 2] == 255 && scrambled[i + 3] == 255;
|
||||
const bool isPadding = scrambled[i] == 0 && scrambled[i + 1] == 0 &&
|
||||
scrambled[i + 2] == 0 && scrambled[i + 3] == 0;
|
||||
EXPECT_TRUE(isWhite || isPadding)
|
||||
<< "canvas pixel " << (i / 4) << " is neither white nor padding";
|
||||
}
|
||||
ASSERT_TRUE(unscramble(scrambled.data(), recovered.data(), width, height, cfg));
|
||||
EXPECT_EQ(recovered, img);
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, RoundsBelowOneClampToOne)
|
||||
{
|
||||
const auto img = uniqueImage(7, 5);
|
||||
const int n = 7;
|
||||
std::vector<uint8_t> clamped(static_cast<size_t>(n) * n * 4);
|
||||
std::vector<uint8_t> single(static_cast<size_t>(n) * n * 4);
|
||||
|
||||
ScrambleConfig cfgClamped;
|
||||
cfgClamped.rounds = 0;
|
||||
ScrambleConfig cfgSingle;
|
||||
cfgSingle.rounds = 1;
|
||||
|
||||
ASSERT_TRUE(scramble(img.data(), clamped.data(), 7, 5, cfgClamped));
|
||||
ASSERT_TRUE(scramble(img.data(), single.data(), 7, 5, cfgSingle));
|
||||
EXPECT_EQ(clamped, single);
|
||||
EXPECT_TRUE(roundTrips(7, 5, 0, radiantwave::scramble::kDefaultSeed));
|
||||
}
|
||||
|
||||
TEST(FibonacciScramblerTest, RejectsInvalidArguments)
|
||||
{
|
||||
std::vector<uint8_t> dummy(16 * 16 * 4, 0);
|
||||
ScrambleConfig cfg;
|
||||
|
||||
EXPECT_FALSE(scramble(nullptr, dummy.data(), 4, 4, cfg));
|
||||
EXPECT_FALSE(scramble(dummy.data(), nullptr, 4, 4, cfg));
|
||||
EXPECT_FALSE(scramble(dummy.data(), dummy.data(), 0, 4, cfg));
|
||||
EXPECT_FALSE(scramble(dummy.data(), dummy.data(), 4, -1, cfg));
|
||||
EXPECT_FALSE(unscramble(nullptr, dummy.data(), 4, 4, cfg));
|
||||
EXPECT_FALSE(unscramble(dummy.data(), nullptr, 4, 4, cfg));
|
||||
EXPECT_FALSE(unscramble(dummy.data(), dummy.data(), 0, 4, cfg));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdlib>
|
||||
|
||||
struct TestCase {
|
||||
std::string name;
|
||||
void (*fn)();
|
||||
};
|
||||
|
||||
inline std::vector<TestCase>& tests()
|
||||
{
|
||||
static std::vector<TestCase> t;
|
||||
return t;
|
||||
}
|
||||
|
||||
inline int register_test(const char* name, void (*fn)())
|
||||
{
|
||||
tests().push_back({name, fn});
|
||||
return 0;
|
||||
}
|
||||
|
||||
#define TEST(name) \
|
||||
void test_##name(); \
|
||||
static int reg_##name = register_test(#name, test_##name); \
|
||||
void test_##name()
|
||||
|
||||
#define ASSERT_TRUE(expr) \
|
||||
do { \
|
||||
if (!(expr)) { \
|
||||
std::cerr << " FAIL: " << #expr << " (" << __FILE__ << ":" \
|
||||
<< __LINE__ << ")" << std::endl; \
|
||||
std::exit(1); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define ASSERT_FALSE(expr) ASSERT_TRUE(!(expr))
|
||||
|
||||
#define ASSERT_EQ(a, b) \
|
||||
do { \
|
||||
if ((a) != (b)) { \
|
||||
std::cerr << " FAIL: " << #a << " == " << #b << " (" << __FILE__ \
|
||||
<< ":" << __LINE__ << ")" << std::endl; \
|
||||
std::exit(1); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define RUN_ALL_TESTS() \
|
||||
int main() { \
|
||||
int passed = 0; \
|
||||
int failed = 0; \
|
||||
for (auto& tc : tests()) { \
|
||||
std::cout << tc.name << "... "; \
|
||||
try { \
|
||||
tc.fn(); \
|
||||
std::cout << "ok" << std::endl; \
|
||||
passed++; \
|
||||
} catch (...) { \
|
||||
std::cout << "FAILED" << std::endl; \
|
||||
failed++; \
|
||||
} \
|
||||
} \
|
||||
std::cout << passed << " passed, " << failed << " failed" << std::endl;\
|
||||
return failed > 0 ? 1 : 0; \
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#include "test_harness.h"
|
||||
#include "../src/keybind_manager.h"
|
||||
|
||||
// Build a SDL_KEYDOWN event for the given key + modifier set.
|
||||
static SDL_Event keyDownEvent(SDL_Keycode key, SDL_Keymod mod)
|
||||
{
|
||||
SDL_Event ev{};
|
||||
ev.type = SDL_EVENT_KEY_DOWN;
|
||||
ev.key.key = key;
|
||||
ev.key.mod = mod;
|
||||
return ev;
|
||||
}
|
||||
|
||||
TEST(bare_bind_fires_without_modifiers)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_RETURN, "enter", [&calls]() { calls++; });
|
||||
|
||||
ASSERT_TRUE(kb.handleEvent(keyDownEvent(SDLK_RETURN, SDL_KMOD_NONE)));
|
||||
ASSERT_EQ(calls, 1);
|
||||
}
|
||||
|
||||
TEST(bare_bind_ignores_modifiers)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_RETURN, "enter", [&calls]() { calls++; });
|
||||
|
||||
// A plain bind fires even while a modifier is held.
|
||||
ASSERT_TRUE(kb.handleEvent(keyDownEvent(SDLK_RETURN, SDL_KMOD_CTRL)));
|
||||
ASSERT_EQ(calls, 1);
|
||||
}
|
||||
|
||||
TEST(combo_requires_modifier)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_D, SDL_KMOD_CTRL, "clear", [&calls]() { calls++; });
|
||||
|
||||
// Without Ctrl held the combo must not fire.
|
||||
ASSERT_FALSE(kb.handleEvent(keyDownEvent(SDLK_D, SDL_KMOD_NONE)));
|
||||
ASSERT_EQ(calls, 0);
|
||||
|
||||
// With Ctrl held it fires.
|
||||
ASSERT_TRUE(kb.handleEvent(keyDownEvent(SDLK_D, SDL_KMOD_CTRL)));
|
||||
ASSERT_EQ(calls, 1);
|
||||
}
|
||||
|
||||
// Regression: a physical press only sets ONE side of a paired modifier
|
||||
// (e.g. SDL_KMOD_LCTRL), never the combined SDL_KMOD_CTRL mask. The raw
|
||||
// bitwise check (pressed & required) == required therefore never matched and
|
||||
// Ctrl+D silently did nothing on real hardware.
|
||||
TEST(combo_fires_with_single_ctrl_key)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_D, SDL_KMOD_CTRL, "clear", [&calls]() { calls++; });
|
||||
|
||||
ASSERT_TRUE(kb.handleEvent(keyDownEvent(SDLK_D, SDL_KMOD_LCTRL)));
|
||||
ASSERT_EQ(calls, 1);
|
||||
|
||||
ASSERT_TRUE(kb.handleEvent(keyDownEvent(SDLK_D, SDL_KMOD_RCTRL)));
|
||||
ASSERT_EQ(calls, 2);
|
||||
}
|
||||
|
||||
TEST(combo_tolerates_extra_modifiers)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_D, SDL_KMOD_CTRL, "clear", [&calls]() { calls++; });
|
||||
|
||||
// CapsLock being stuck on should not block the combo.
|
||||
const SDL_Keymod extra = static_cast<SDL_Keymod>(SDL_KMOD_CTRL | SDL_KMOD_CAPS);
|
||||
ASSERT_TRUE(kb.handleEvent(keyDownEvent(SDLK_D, extra)));
|
||||
ASSERT_EQ(calls, 1);
|
||||
}
|
||||
|
||||
TEST(combo_partial_modifiers_do_not_fire)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_D, static_cast<SDL_Keymod>(SDL_KMOD_CTRL | SDL_KMOD_SHIFT), "clear", [&calls]() { calls++; });
|
||||
|
||||
// Only Ctrl held, Shift missing: must not fire.
|
||||
ASSERT_FALSE(kb.handleEvent(keyDownEvent(SDLK_D, SDL_KMOD_CTRL)));
|
||||
ASSERT_EQ(calls, 0);
|
||||
|
||||
// Both held: fires.
|
||||
ASSERT_TRUE(kb.handleEvent(keyDownEvent(SDLK_D, static_cast<SDL_Keymod>(SDL_KMOD_CTRL | SDL_KMOD_SHIFT))));
|
||||
ASSERT_EQ(calls, 1);
|
||||
}
|
||||
|
||||
TEST(unbind_removes_binding)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_D, SDL_KMOD_CTRL, "clear", [&calls]() { calls++; });
|
||||
kb.unbind(SDLK_D);
|
||||
|
||||
ASSERT_FALSE(kb.isBound(SDLK_D));
|
||||
ASSERT_FALSE(kb.handleEvent(keyDownEvent(SDLK_D, SDL_KMOD_CTRL)));
|
||||
ASSERT_EQ(calls, 0);
|
||||
}
|
||||
|
||||
TEST(unbind_all_removes_bindings)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_D, SDL_KMOD_CTRL, "clear", [&calls]() { calls++; });
|
||||
kb.bind(SDLK_UP, "up", [&calls]() { calls++; });
|
||||
kb.unbindAll();
|
||||
|
||||
ASSERT_FALSE(kb.isBound(SDLK_D));
|
||||
ASSERT_FALSE(kb.isBound(SDLK_UP));
|
||||
ASSERT_FALSE(kb.handleEvent(keyDownEvent(SDLK_D, SDL_KMOD_CTRL)));
|
||||
ASSERT_EQ(calls, 0);
|
||||
}
|
||||
|
||||
TEST(is_bound_reflects_bindings)
|
||||
{
|
||||
KeybindManager kb;
|
||||
kb.bind(SDLK_D, SDL_KMOD_CTRL, "clear", []() {});
|
||||
ASSERT_TRUE(kb.isBound(SDLK_D));
|
||||
ASSERT_FALSE(kb.isBound(SDLK_UP));
|
||||
}
|
||||
|
||||
TEST(non_keydown_event_is_ignored)
|
||||
{
|
||||
KeybindManager kb;
|
||||
int calls = 0;
|
||||
kb.bind(SDLK_D, SDL_KMOD_CTRL, "clear", [&calls]() { calls++; });
|
||||
|
||||
SDL_Event ev{};
|
||||
ev.type = SDL_EVENT_KEY_UP;
|
||||
ev.key.key = SDLK_D;
|
||||
ev.key.mod = SDL_KMOD_CTRL;
|
||||
ASSERT_FALSE(kb.handleEvent(ev));
|
||||
ASSERT_EQ(calls, 0);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#include "network/license_service.h"
|
||||
#include "db/db_keys.h"
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
using namespace radiantwave::license;
|
||||
using namespace radiantwave::db;
|
||||
|
||||
namespace {
|
||||
|
||||
// A temporary database that is created fresh per test and cleaned up on exit.
|
||||
struct TempDb {
|
||||
TempDb() {
|
||||
path = (std::filesystem::temp_directory_path() /
|
||||
("test_rw_license_" + std::to_string(counter++) + ".db"))
|
||||
.string();
|
||||
std::filesystem::remove(path);
|
||||
db.initialize(path);
|
||||
}
|
||||
|
||||
~TempDb() {
|
||||
db.close();
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
std::string path;
|
||||
DatabaseManager db;
|
||||
static int counter;
|
||||
};
|
||||
|
||||
int TempDb::counter = 0;
|
||||
|
||||
std::string env(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value ? std::string(value) : std::string();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ─── Offline: not-configured paths (no network) ────────────────────────
|
||||
|
||||
TEST(LicenseServiceTest, NotConfiguredWhenNoEmail) {
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
|
||||
|
||||
std::string levelName = "stale";
|
||||
std::string errorMessage = "stale";
|
||||
EXPECT_EQ(validateStoredLicense(tmp.db, "", &levelName, &errorMessage),
|
||||
Result::NotConfigured);
|
||||
EXPECT_EQ(levelName, "");
|
||||
EXPECT_EQ(errorMessage, "");
|
||||
EXPECT_FALSE(tmp.db.hasKV(KV_SUBSCRIPTION_LEVEL_NAME));
|
||||
}
|
||||
|
||||
TEST(LicenseServiceTest, NotConfiguredWhenNoLicense) {
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
|
||||
|
||||
std::string levelName = "stale";
|
||||
EXPECT_EQ(validateStoredLicense(tmp.db, "", &levelName, nullptr),
|
||||
Result::NotConfigured);
|
||||
EXPECT_EQ(levelName, "");
|
||||
}
|
||||
|
||||
TEST(LicenseServiceTest, NotConfiguredWhenBothMissing) {
|
||||
TempDb tmp;
|
||||
EXPECT_EQ(validateStoredLicense(tmp.db), Result::NotConfigured);
|
||||
}
|
||||
|
||||
// ─── Level helpers (no network) ─────────────────────────────────────────
|
||||
|
||||
TEST(LicenseServiceTest, StoredLevelIdReadsPersistedValue) {
|
||||
TempDb tmp;
|
||||
EXPECT_EQ(getStoredSubscriptionLevelId(tmp.db), 0);
|
||||
|
||||
tmp.db.setKV(KV_SUBSCRIPTION_LEVEL_ID, "2");
|
||||
EXPECT_EQ(getStoredSubscriptionLevelId(tmp.db), 2);
|
||||
|
||||
tmp.db.setKV(KV_SUBSCRIPTION_LEVEL_ID, "not-a-number");
|
||||
EXPECT_EQ(getStoredSubscriptionLevelId(tmp.db), 0);
|
||||
}
|
||||
|
||||
TEST(LicenseServiceTest, LevelNames) {
|
||||
EXPECT_EQ(subscriptionLevelName(1), "Basic");
|
||||
EXPECT_EQ(subscriptionLevelName(2), "Advanced");
|
||||
EXPECT_EQ(subscriptionLevelName(3), "Elite");
|
||||
EXPECT_EQ(subscriptionLevelName(4), "Subscription Level 4");
|
||||
}
|
||||
|
||||
TEST(LicenseServiceTest, LevelIdForName) {
|
||||
EXPECT_EQ(subscriptionLevelIdForName("Basic"), 1);
|
||||
EXPECT_EQ(subscriptionLevelIdForName("advanced"), 2);
|
||||
EXPECT_EQ(subscriptionLevelIdForName("ELITE"), 3);
|
||||
EXPECT_EQ(subscriptionLevelIdForName(" Elite "), 0); // unknown/whitespace
|
||||
EXPECT_EQ(subscriptionLevelIdForName("Enterprise"), 0);
|
||||
EXPECT_EQ(subscriptionLevelIdForName(""), 0);
|
||||
}
|
||||
|
||||
// ─── Offline grace period ───────────────────────────────────────────────
|
||||
|
||||
long long epochSeconds(long long offsetSeconds = 0) {
|
||||
const auto now = std::chrono::system_clock::now();
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch())
|
||||
.count() +
|
||||
offsetSeconds;
|
||||
}
|
||||
|
||||
TEST(LicenseServiceTest, MarkVerifiedStampsNowAndGrantsGrace) {
|
||||
TempDb tmp;
|
||||
EXPECT_FALSE(tmp.db.hasKV(KV_LICENSE_LAST_VERIFIED));
|
||||
EXPECT_FALSE(isLicenseWithinGracePeriod(tmp.db));
|
||||
|
||||
markLicenseVerified(tmp.db);
|
||||
EXPECT_TRUE(tmp.db.hasKV(KV_LICENSE_LAST_VERIFIED));
|
||||
EXPECT_TRUE(isLicenseWithinGracePeriod(tmp.db));
|
||||
}
|
||||
|
||||
TEST(LicenseServiceTest, GracePeriodExpiresAfterSevenDays) {
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds(-6 * 24 * 3600)));
|
||||
EXPECT_TRUE(isLicenseWithinGracePeriod(tmp.db));
|
||||
|
||||
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds(-8 * 24 * 3600)));
|
||||
EXPECT_FALSE(isLicenseWithinGracePeriod(tmp.db));
|
||||
}
|
||||
|
||||
TEST(LicenseServiceTest, GracePeriodIgnoresCorruptStamp) {
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, "not-a-timestamp");
|
||||
EXPECT_FALSE(isLicenseWithinGracePeriod(tmp.db));
|
||||
|
||||
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, "-42");
|
||||
EXPECT_FALSE(isLicenseWithinGracePeriod(tmp.db));
|
||||
}
|
||||
|
||||
// ─── Live: full validation + persistence (requires env credentials) ─────
|
||||
|
||||
TEST(LicenseServiceLiveTest, ValidatesAndStoresSubscriptionLevel) {
|
||||
const std::string key = env("RW_LIVE_LICENSE_KEY");
|
||||
const std::string email = env("RW_LIVE_LICENSE_EMAIL");
|
||||
if (key.empty() || email.empty()) {
|
||||
GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set";
|
||||
}
|
||||
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_KEY, key);
|
||||
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
|
||||
|
||||
std::string levelName;
|
||||
std::string errorMessage;
|
||||
const Result result = validateStoredLicense(tmp.db, "", &levelName, &errorMessage);
|
||||
EXPECT_EQ(result, Result::Ok) << "error=" << errorMessage;
|
||||
|
||||
// The subscription level name (and id/description when available) must be
|
||||
// persisted to the local database.
|
||||
EXPECT_EQ(levelName, tmp.db.getKV(KV_SUBSCRIPTION_LEVEL_NAME));
|
||||
EXPECT_FALSE(tmp.db.getKV(KV_SUBSCRIPTION_LEVEL_NAME).empty());
|
||||
ASSERT_TRUE(tmp.db.hasKV(KV_SUBSCRIPTION_LEVEL_ID));
|
||||
EXPECT_GT(std::atoi(tmp.db.getKV(KV_SUBSCRIPTION_LEVEL_ID).c_str()), 0);
|
||||
|
||||
// A successful validation must record the "last verified" stamp that drives
|
||||
// the offline grace period.
|
||||
EXPECT_TRUE(tmp.db.hasKV(KV_LICENSE_LAST_VERIFIED));
|
||||
EXPECT_TRUE(isLicenseWithinGracePeriod(tmp.db));
|
||||
}
|
||||
|
||||
TEST(LicenseServiceLiveTest, InvalidPairReportsInvalid) {
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_KEY, "AAAA-AAAA-AAAA-AAAA");
|
||||
tmp.db.setKV(KV_EMAIL_ADDRESS, "nobody@example.com");
|
||||
|
||||
std::string errorMessage;
|
||||
EXPECT_EQ(validateStoredLicense(tmp.db, "", nullptr, &errorMessage),
|
||||
Result::Invalid);
|
||||
EXPECT_FALSE(errorMessage.empty());
|
||||
EXPECT_FALSE(tmp.db.hasKV(KV_SUBSCRIPTION_LEVEL_NAME));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "network/license_status.h"
|
||||
#include "db/db_keys.h"
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
using namespace radiantwave::license;
|
||||
using namespace radiantwave::db;
|
||||
|
||||
namespace {
|
||||
|
||||
// A temporary database created fresh per test and cleaned up on exit.
|
||||
struct TempDb {
|
||||
TempDb() {
|
||||
path = (std::filesystem::temp_directory_path() /
|
||||
("test_rw_license_status_" + std::to_string(counter++) + ".db"))
|
||||
.string();
|
||||
std::filesystem::remove(path);
|
||||
db.initialize(path);
|
||||
}
|
||||
|
||||
~TempDb() {
|
||||
db.close();
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
std::string path;
|
||||
DatabaseManager db;
|
||||
static int counter;
|
||||
};
|
||||
|
||||
int TempDb::counter = 0;
|
||||
|
||||
std::string env(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value ? std::string(value) : std::string();
|
||||
}
|
||||
|
||||
// Waits until `status()` no longer reports Checking (or `timeout` elapses).
|
||||
// Returns the final status.
|
||||
Status waitForResult(LicenseStatusTracker& tracker, int timeoutMs = 2000) {
|
||||
const auto deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(timeoutMs);
|
||||
while (tracker.status() == Status::Checking) {
|
||||
if (std::chrono::steady_clock::now() >= deadline) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
return tracker.status();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ─── Pure mapping (offline) ─────────────────────────────────────────────
|
||||
|
||||
TEST(LicenseStatusTest, StatusForResultMapping) {
|
||||
EXPECT_EQ(statusForResult(Result::Ok), Status::Valid);
|
||||
EXPECT_EQ(statusForResult(Result::NotConfigured), Status::Invalid);
|
||||
EXPECT_EQ(statusForResult(Result::Invalid), Status::Invalid);
|
||||
EXPECT_EQ(statusForResult(Result::NetworkError), Status::Invalid);
|
||||
}
|
||||
|
||||
long long epochSeconds(long long offsetSeconds = 0) {
|
||||
const auto now = std::chrono::system_clock::now();
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch())
|
||||
.count() +
|
||||
offsetSeconds;
|
||||
}
|
||||
|
||||
TEST(LicenseStatusTest, StatusForResultWithGraceNoPriorValidation) {
|
||||
TempDb tmp;
|
||||
// No "last verified" stamp: a network error still invalidates because the
|
||||
// device has no record of ever confirming the license.
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::Ok, tmp.db), Status::Valid);
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::NotConfigured, tmp.db), Status::Invalid);
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::Invalid, tmp.db), Status::Invalid);
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Invalid);
|
||||
}
|
||||
|
||||
TEST(LicenseStatusTest, StatusForResultWithGraceWithinWindow) {
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds()));
|
||||
|
||||
// A network error keeps a recently-confirmed license valid...
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Valid);
|
||||
// ...but the grace period never rescues an explicit rejection.
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::Invalid, tmp.db), Status::Invalid);
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::NotConfigured, tmp.db), Status::Invalid);
|
||||
}
|
||||
|
||||
TEST(LicenseStatusTest, StatusForResultWithGraceExpired) {
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds(-8 * 24 * 3600)));
|
||||
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Invalid);
|
||||
}
|
||||
|
||||
// ─── Worker lifecycle (no network) ──────────────────────────────────────
|
||||
|
||||
TEST(LicenseStatusTest, UnknownBeforeStart) {
|
||||
LicenseStatusTracker tracker;
|
||||
EXPECT_EQ(tracker.status(), Status::Unknown);
|
||||
EXPECT_FALSE(tracker.isValid());
|
||||
|
||||
// refresh() before start() must not spawn a worker.
|
||||
tracker.refresh();
|
||||
EXPECT_EQ(tracker.status(), Status::Unknown);
|
||||
}
|
||||
|
||||
TEST(LicenseStatusTest, InvalidWhenNoCredentials) {
|
||||
TempDb tmp;
|
||||
LicenseStatusTracker tracker;
|
||||
tracker.start(tmp.db);
|
||||
tracker.refresh();
|
||||
|
||||
// NotConfigured resolves without touching the network.
|
||||
EXPECT_EQ(waitForResult(tracker), Status::Invalid);
|
||||
EXPECT_FALSE(tracker.isValid());
|
||||
tracker.shutdown();
|
||||
}
|
||||
|
||||
TEST(LicenseStatusTest, InvalidWhenDatabaseClosed) {
|
||||
TempDb tmp;
|
||||
LicenseStatusTracker tracker;
|
||||
tracker.start(tmp.db);
|
||||
tracker.refresh();
|
||||
EXPECT_EQ(waitForResult(tracker), Status::Invalid);
|
||||
tracker.shutdown();
|
||||
}
|
||||
|
||||
// ─── Live: confirmed-valid license (requires env credentials) ───────────
|
||||
|
||||
TEST(LicenseStatusLiveTest, ValidWhenLicensePairAccepted) {
|
||||
const std::string key = env("RW_LIVE_LICENSE_KEY");
|
||||
const std::string email = env("RW_LIVE_LICENSE_EMAIL");
|
||||
if (key.empty() || email.empty()) {
|
||||
GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set";
|
||||
}
|
||||
|
||||
TempDb tmp;
|
||||
tmp.db.setKV(KV_LICENSE_KEY, key);
|
||||
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
|
||||
|
||||
LicenseStatusTracker tracker;
|
||||
tracker.start(tmp.db);
|
||||
tracker.refresh();
|
||||
|
||||
EXPECT_EQ(waitForResult(tracker, 20000), Status::Valid);
|
||||
EXPECT_TRUE(tracker.isValid());
|
||||
tracker.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#include "logger/logger.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
using namespace radiantwave::logger;
|
||||
|
||||
TEST(LoggerTest, InitializeAndWriteLog) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_logger1.txt").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
Logger logger;
|
||||
EXPECT_TRUE(logger.initialize(temp_path));
|
||||
|
||||
logger.info("System", "System starting up");
|
||||
logger.warning("WiFi", "Network connection lost (interface wlan0 carrier dropped)");
|
||||
logger.error("Database", "Database query failed (SQLITE_CORRUPT table proclamations)");
|
||||
|
||||
auto entries = logger.getEntries();
|
||||
ASSERT_EQ(entries.size(), 3u);
|
||||
|
||||
EXPECT_EQ(entries[0].component, "System");
|
||||
EXPECT_EQ(entries[0].level, LogLevel::Info);
|
||||
|
||||
EXPECT_EQ(entries[1].component, "WiFi");
|
||||
EXPECT_EQ(entries[1].level, LogLevel::Warning);
|
||||
|
||||
EXPECT_EQ(entries[2].component, "Database");
|
||||
EXPECT_EQ(entries[2].level, LogLevel::Error);
|
||||
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
TEST(LoggerTest, FilterLogs) {
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_logger2.txt").string();
|
||||
if (std::filesystem::exists(temp_path)) {
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
Logger logger;
|
||||
ASSERT_TRUE(logger.initialize(temp_path));
|
||||
|
||||
logger.debug("Core", "Debug tracing");
|
||||
logger.info("Core", "Info message");
|
||||
logger.warning("Core", "Warning message");
|
||||
logger.error("Core", "Error message");
|
||||
|
||||
auto min_warn = logger.getEntriesFiltered(LogLevel::Warning, false);
|
||||
ASSERT_EQ(min_warn.size(), 2u); // Warning and Error
|
||||
|
||||
auto exact_error = logger.getEntriesFiltered(LogLevel::Error, true);
|
||||
ASSERT_EQ(exact_error.size(), 1u);
|
||||
EXPECT_EQ(exact_error[0].message, "Error message");
|
||||
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#include "../src/page.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct LifecycleEvent {
|
||||
std::string type;
|
||||
int pageId;
|
||||
|
||||
bool operator==(const LifecycleEvent& other) const
|
||||
{
|
||||
return type == other.type && pageId == other.pageId;
|
||||
}
|
||||
bool operator!=(const LifecycleEvent& other) const
|
||||
{
|
||||
return !(*this == other);
|
||||
}
|
||||
};
|
||||
|
||||
inline std::vector<LifecycleEvent>& lifecycle_log()
|
||||
{
|
||||
static std::vector<LifecycleEvent> log;
|
||||
return log;
|
||||
}
|
||||
|
||||
inline void clear_log()
|
||||
{
|
||||
lifecycle_log().clear();
|
||||
}
|
||||
|
||||
class TestPage : public Page {
|
||||
public:
|
||||
explicit TestPage(int id) : id_(id) {}
|
||||
|
||||
int id() const { return id_; }
|
||||
|
||||
void onPush(AppState& state) override
|
||||
{
|
||||
lifecycle_log().push_back({"onPush", id_});
|
||||
}
|
||||
void onResume(AppState& state) override
|
||||
{
|
||||
lifecycle_log().push_back({"onResume", id_});
|
||||
}
|
||||
void onPause(AppState& state) override
|
||||
{
|
||||
lifecycle_log().push_back({"onPause", id_});
|
||||
}
|
||||
void onPop(AppState& state) override
|
||||
{
|
||||
lifecycle_log().push_back({"onPop", id_});
|
||||
}
|
||||
void handleEvent(const SDL_Event& event) override {}
|
||||
void update(float dt) override {}
|
||||
void render(SDL_Renderer* renderer) override {}
|
||||
|
||||
private:
|
||||
int id_;
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
#include "test_harness.h"
|
||||
#include "test_page.h"
|
||||
#include "../src/page_manager.h"
|
||||
|
||||
// Minimal AppState for test compilation
|
||||
struct AppState {
|
||||
SDL_Window* window = nullptr;
|
||||
SDL_Renderer* renderer = nullptr;
|
||||
PageManager pages;
|
||||
};
|
||||
|
||||
TEST(push_single_page)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.push(state, std::make_unique<TestPage>(1));
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(1));
|
||||
ASSERT_EQ(static_cast<TestPage*>(state.pages.current())->id(), 1);
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(1));
|
||||
ASSERT_EQ(lifecycle_log()[0], (LifecycleEvent{"onPush", 1}));
|
||||
}
|
||||
|
||||
TEST(push_two_pages)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.push(state, std::make_unique<TestPage>(1));
|
||||
clear_log();
|
||||
|
||||
state.pages.push(state, std::make_unique<TestPage>(2));
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(2));
|
||||
ASSERT_EQ(static_cast<TestPage*>(state.pages.current())->id(), 2);
|
||||
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(2));
|
||||
ASSERT_EQ(lifecycle_log()[0], (LifecycleEvent{"onPause", 1}));
|
||||
ASSERT_EQ(lifecycle_log()[1], (LifecycleEvent{"onPush", 2}));
|
||||
}
|
||||
|
||||
TEST(pop_single_page)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.push(state, std::make_unique<TestPage>(1));
|
||||
clear_log();
|
||||
|
||||
state.pages.pop(state);
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(0));
|
||||
ASSERT_TRUE(state.pages.current() == nullptr);
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(1));
|
||||
ASSERT_EQ(lifecycle_log()[0], (LifecycleEvent{"onPop", 1}));
|
||||
}
|
||||
|
||||
TEST(pop_resumes_previous)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.push(state, std::make_unique<TestPage>(1));
|
||||
state.pages.push(state, std::make_unique<TestPage>(2));
|
||||
clear_log();
|
||||
|
||||
state.pages.pop(state);
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(1));
|
||||
ASSERT_EQ(static_cast<TestPage*>(state.pages.current())->id(), 1);
|
||||
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(2));
|
||||
ASSERT_EQ(lifecycle_log()[0], (LifecycleEvent{"onPop", 2}));
|
||||
ASSERT_EQ(lifecycle_log()[1], (LifecycleEvent{"onResume", 1}));
|
||||
}
|
||||
|
||||
TEST(replace_on_empty)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.replace(state, std::make_unique<TestPage>(1));
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(1));
|
||||
ASSERT_EQ(static_cast<TestPage*>(state.pages.current())->id(), 1);
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(1));
|
||||
ASSERT_EQ(lifecycle_log()[0], (LifecycleEvent{"onPush", 1}));
|
||||
}
|
||||
|
||||
TEST(replace_on_existing)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.push(state, std::make_unique<TestPage>(1));
|
||||
clear_log();
|
||||
|
||||
state.pages.replace(state, std::make_unique<TestPage>(2));
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(1));
|
||||
ASSERT_EQ(static_cast<TestPage*>(state.pages.current())->id(), 2);
|
||||
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(2));
|
||||
ASSERT_EQ(lifecycle_log()[0], (LifecycleEvent{"onPop", 1}));
|
||||
ASSERT_EQ(lifecycle_log()[1], (LifecycleEvent{"onPush", 2}));
|
||||
}
|
||||
|
||||
TEST(pop_empty_stack)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.pop(state);
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(0));
|
||||
ASSERT_TRUE(state.pages.current() == nullptr);
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(0));
|
||||
}
|
||||
|
||||
TEST(depth_tracks_correctly)
|
||||
{
|
||||
AppState state;
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(0));
|
||||
|
||||
state.pages.push(state, std::make_unique<TestPage>(1));
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(1));
|
||||
|
||||
state.pages.push(state, std::make_unique<TestPage>(2));
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(2));
|
||||
|
||||
state.pages.pop(state);
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(1));
|
||||
|
||||
state.pages.pop(state);
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(0));
|
||||
}
|
||||
|
||||
TEST(push_pop_push_sequence)
|
||||
{
|
||||
clear_log();
|
||||
AppState state;
|
||||
state.pages.push(state, std::make_unique<TestPage>(1));
|
||||
state.pages.push(state, std::make_unique<TestPage>(2));
|
||||
state.pages.pop(state);
|
||||
state.pages.push(state, std::make_unique<TestPage>(3));
|
||||
|
||||
ASSERT_EQ(state.pages.depth(), std::size_t(2));
|
||||
ASSERT_EQ(static_cast<TestPage*>(state.pages.current())->id(), 3);
|
||||
|
||||
// 1: push(1), 2: push(2) [pause(1), push(2)],
|
||||
// 3: pop() [pop(2), resume(1)], 4: push(3) [pause(1), push(3)]
|
||||
ASSERT_EQ(lifecycle_log().size(), std::size_t(7));
|
||||
ASSERT_EQ(lifecycle_log()[0], (LifecycleEvent{"onPush", 1}));
|
||||
ASSERT_EQ(lifecycle_log()[1], (LifecycleEvent{"onPause", 1}));
|
||||
ASSERT_EQ(lifecycle_log()[2], (LifecycleEvent{"onPush", 2}));
|
||||
ASSERT_EQ(lifecycle_log()[3], (LifecycleEvent{"onPop", 2}));
|
||||
ASSERT_EQ(lifecycle_log()[4], (LifecycleEvent{"onResume", 1}));
|
||||
ASSERT_EQ(lifecycle_log()[5], (LifecycleEvent{"onPause", 1}));
|
||||
ASSERT_EQ(lifecycle_log()[6], (LifecycleEvent{"onPush", 3}));
|
||||
}
|
||||
|
||||
RUN_ALL_TESTS()
|
||||
@@ -0,0 +1,95 @@
|
||||
#include "network/proclamation_sync.h"
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
using namespace radiantwave::db;
|
||||
using namespace radiantwave::sync;
|
||||
|
||||
TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||
const std::string body =
|
||||
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2,"subscriptionLevelId":3},
|
||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
|
||||
{"notAnObject":true}])";
|
||||
|
||||
std::vector<ProclamationRecord> out;
|
||||
EXPECT_TRUE(parseProclamationsJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
|
||||
EXPECT_EQ(out[0].id, "p1");
|
||||
EXPECT_EQ(out[0].title, "Peace");
|
||||
EXPECT_EQ(out[0].content, "I am peace.");
|
||||
EXPECT_EQ(out[0].category_id, 3);
|
||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
|
||||
EXPECT_EQ(out[0].required_level_id, 3);
|
||||
|
||||
EXPECT_EQ(out[1].id, "p2");
|
||||
EXPECT_EQ(out[1].title, "Joy & Calm");
|
||||
EXPECT_EQ(out[1].content, "text");
|
||||
EXPECT_EQ(out[1].category_id, 0);
|
||||
// Missing subscriptionLevelId defaults to the least privileged level.
|
||||
EXPECT_EQ(out[1].required_level_id, 1);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseLevelIdDefaultsToBasic) {
|
||||
std::vector<ProclamationRecord> out;
|
||||
EXPECT_TRUE(parseProclamationsJson(
|
||||
R"([{"id":"a","name":"A","subscriptionLevelId":0},
|
||||
{"id":"b","name":"B","subscriptionLevelId":2}])", out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
EXPECT_EQ(out[0].required_level_id, 1);
|
||||
EXPECT_EQ(out[1].required_level_id, 2);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||
const std::string body =
|
||||
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
|
||||
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
|
||||
|
||||
std::vector<ProclamationCategoryRecord> out;
|
||||
EXPECT_TRUE(parseCategoriesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
|
||||
EXPECT_EQ(out[0].id, 3);
|
||||
EXPECT_EQ(out[0].name, "Spiritual");
|
||||
EXPECT_EQ(out[0].parent_id, 0);
|
||||
EXPECT_EQ(out[0].sort_order, 1);
|
||||
|
||||
EXPECT_EQ(out[1].id, 7);
|
||||
EXPECT_EQ(out[1].name, "Inner");
|
||||
EXPECT_EQ(out[1].parent_id, 3);
|
||||
EXPECT_EQ(out[1].sort_order, 2);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, RejectMalformedJson) {
|
||||
std::vector<ProclamationRecord> procs;
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
|
||||
EXPECT_FALSE(parseProclamationsJson("not json", procs));
|
||||
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
|
||||
EXPECT_FALSE(parseProclamationsJson(R"([{"id":"p1"}", ])", procs));
|
||||
|
||||
EXPECT_FALSE(parseCategoriesJson("garbage", cats));
|
||||
// Non-numeric ids are tolerated (row skipped), not fatal.
|
||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
|
||||
EXPECT_EQ(cats.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
||||
std::vector<ProclamationRecord> procs;
|
||||
EXPECT_TRUE(parseProclamationsJson(R"([{"name":"NoId"},{"id":"ok","name":"Fine","categoryId":4}])", procs));
|
||||
ASSERT_EQ(procs.size(), 1u);
|
||||
EXPECT_EQ(procs[0].id, "ok");
|
||||
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","parentId":0}])", cats));
|
||||
ASSERT_EQ(cats.size(), 1u);
|
||||
EXPECT_EQ(cats[0].id, 9);
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, StripHtmlEntities) {
|
||||
std::vector<ProclamationRecord> out;
|
||||
const std::string body =
|
||||
R"([{"id":"p1","name":"N","content":"A & B <C> 'quoted' end"}] )";
|
||||
EXPECT_TRUE(parseProclamationsJson(body, out));
|
||||
ASSERT_EQ(out.size(), 1u);
|
||||
EXPECT_EQ(out[0].content, "A & B <C> 'quoted' end");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#include "test_harness.h"
|
||||
#include "../src/provisioning.h"
|
||||
|
||||
using radiantwave::provisioning::firstMissingStep;
|
||||
using radiantwave::provisioning::indicatesFirstRun;
|
||||
using radiantwave::provisioning::Step;
|
||||
|
||||
TEST(first_missing_step_all_satisfied)
|
||||
{
|
||||
ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
|
||||
}
|
||||
|
||||
TEST(first_missing_step_no_internet)
|
||||
{
|
||||
// Internet is checked before email/license regardless of the others.
|
||||
ASSERT_EQ(firstMissingStep(false, false, false), Step::Internet);
|
||||
ASSERT_EQ(firstMissingStep(false, true, true), Step::Internet);
|
||||
}
|
||||
|
||||
TEST(first_missing_step_no_email)
|
||||
{
|
||||
ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
|
||||
ASSERT_EQ(firstMissingStep(true, false, true), Step::Email);
|
||||
}
|
||||
|
||||
TEST(first_missing_step_no_license)
|
||||
{
|
||||
ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
|
||||
}
|
||||
|
||||
TEST(first_run_all_missing)
|
||||
{
|
||||
ASSERT_TRUE(indicatesFirstRun(false, false, false));
|
||||
}
|
||||
|
||||
TEST(first_run_has_internet)
|
||||
{
|
||||
ASSERT_FALSE(indicatesFirstRun(true, false, false));
|
||||
}
|
||||
|
||||
TEST(first_run_has_email)
|
||||
{
|
||||
ASSERT_FALSE(indicatesFirstRun(false, true, false));
|
||||
}
|
||||
|
||||
TEST(first_run_has_license)
|
||||
{
|
||||
ASSERT_FALSE(indicatesFirstRun(false, false, true));
|
||||
}
|
||||
|
||||
RUN_ALL_TESTS()
|
||||
@@ -0,0 +1,165 @@
|
||||
#include "scroller_streams.h"
|
||||
|
||||
#include "db/database_manager.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using radiantwave::db::AudioFileRecord;
|
||||
using radiantwave::db::ProclamationRecord;
|
||||
using radiantwave::scroller::ProclamationStream;
|
||||
using radiantwave::scroller::StreamColor;
|
||||
using radiantwave::scroller::buildStreams;
|
||||
using radiantwave::scroller::kDefaultRepeatCount;
|
||||
using radiantwave::scroller::kSelectedRepeatCount;
|
||||
|
||||
const std::string kDefaultId = "default";
|
||||
|
||||
std::vector<StreamColor> palette()
|
||||
{
|
||||
StreamColor c;
|
||||
c.r = 10;
|
||||
c.g = 20;
|
||||
c.b = 30;
|
||||
return {c};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
p1.id = "one";
|
||||
p1.title = "One";
|
||||
p1.content = "hello world";
|
||||
p1.selected = true;
|
||||
p1.solfeggio_frequency_id = 2;
|
||||
|
||||
ProclamationRecord p2;
|
||||
p2.id = "two";
|
||||
p2.title = "Two";
|
||||
p2.content = "foo bar baz";
|
||||
p2.selected = true;
|
||||
p2.solfeggio_frequency_id = 5;
|
||||
|
||||
ProclamationRecord def;
|
||||
def.id = kDefaultId;
|
||||
def.title = "Default";
|
||||
def.content = "default text";
|
||||
def.selected = true;
|
||||
def.solfeggio_frequency_id = 5;
|
||||
|
||||
std::unordered_map<int, std::vector<StreamColor>> palettes;
|
||||
palettes[2] = palette();
|
||||
|
||||
std::unordered_map<int, AudioFileRecord> audio;
|
||||
AudioFileRecord af;
|
||||
af.id = 1;
|
||||
af.filename = "396Hz.wav";
|
||||
af.solfeggio_frequency_id = 2;
|
||||
audio[2] = af;
|
||||
|
||||
auto streams = buildStreams({p1, p2, def}, palettes, palette(), audio, kDefaultId);
|
||||
|
||||
// The first user-selected proclamation plays first, followed by the
|
||||
// default, then the next selected, then the default again.
|
||||
ASSERT_EQ(streams.size(), 4u);
|
||||
|
||||
EXPECT_EQ(streams[0].name, "One");
|
||||
ASSERT_EQ(streams[0].words.size(), 2u);
|
||||
EXPECT_EQ(streams[0].words[0], "hello");
|
||||
EXPECT_EQ(streams[0].words[1], "world");
|
||||
EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount);
|
||||
EXPECT_EQ(streams[0].solfeggio_frequency_id, 2);
|
||||
EXPECT_EQ(streams[0].audioFilename, "396Hz.wav");
|
||||
EXPECT_EQ(streams[0].palette.size(), 1u);
|
||||
|
||||
EXPECT_EQ(streams[1].name, "Default");
|
||||
EXPECT_EQ(streams[1].repeat, kDefaultRepeatCount);
|
||||
EXPECT_EQ(streams[1].solfeggio_frequency_id, 5);
|
||||
EXPECT_EQ(streams[1].audioFilename, "OriginalAudio.wav"); // no audio for freq 5
|
||||
EXPECT_EQ(streams[1].palette.size(), 1u); // falls back to 528 palette
|
||||
|
||||
EXPECT_EQ(streams[2].name, "Two");
|
||||
EXPECT_EQ(streams[2].repeat, kSelectedRepeatCount);
|
||||
EXPECT_EQ(streams[2].palette.size(), 1u); // falls back to 528 palette
|
||||
|
||||
EXPECT_EQ(streams[3].name, "Default");
|
||||
EXPECT_EQ(streams[3].repeat, kDefaultRepeatCount);
|
||||
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
p1.id = "one";
|
||||
p1.title = "One";
|
||||
p1.content = "x";
|
||||
p1.selected = false;
|
||||
|
||||
ProclamationRecord def;
|
||||
def.id = kDefaultId;
|
||||
def.title = "Default";
|
||||
def.content = "y";
|
||||
def.selected = true;
|
||||
|
||||
auto streams = buildStreams({p1, def}, {}, palette(), {}, kDefaultId);
|
||||
|
||||
ASSERT_EQ(streams.size(), 1u);
|
||||
EXPECT_EQ(streams[0].name, "Default");
|
||||
EXPECT_EQ(streams[0].repeat, kDefaultRepeatCount);
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, MissingDefaultStillStreamsSelected)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
p1.id = "one";
|
||||
p1.title = "One";
|
||||
p1.content = "x";
|
||||
p1.selected = true;
|
||||
|
||||
auto streams = buildStreams({p1}, {}, palette(), {}, kDefaultId);
|
||||
ASSERT_EQ(streams.size(), 1u);
|
||||
EXPECT_EQ(streams[0].name, "One");
|
||||
EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount);
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, SplitsGluedSentencePunctuation)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
p1.id = "one";
|
||||
p1.title = "One";
|
||||
p1.content = "state?Create NOW?KING field’s fine (Acts 17:27)I example.com";
|
||||
p1.selected = true;
|
||||
|
||||
auto streams = buildStreams({p1}, {}, palette(), {}, kDefaultId);
|
||||
|
||||
ASSERT_EQ(streams.size(), 1u);
|
||||
const auto& words = streams[0].words;
|
||||
|
||||
// "?" glued to an uppercase word splits after the punctuation, keeping the
|
||||
// sentence separator attached to the preceding word.
|
||||
EXPECT_EQ(words[0], "state?");
|
||||
EXPECT_EQ(words[1], "Create");
|
||||
EXPECT_EQ(words[2], "NOW?");
|
||||
EXPECT_EQ(words[3], "KING");
|
||||
|
||||
// Curly apostrophes are not sentence punctuation and stay inside the word.
|
||||
EXPECT_EQ(words[4], "field’s");
|
||||
EXPECT_EQ(words[5], "fine");
|
||||
|
||||
// Closing paren followed by an uppercase letter splits so the verse
|
||||
// reference and the next word are separate tokens.
|
||||
EXPECT_EQ(words[6], "(Acts");
|
||||
EXPECT_EQ(words[7], "17:27)");
|
||||
EXPECT_EQ(words[8], "I");
|
||||
|
||||
// A lowercase continuation after a dot is not a new sentence, so this
|
||||
// stays intact.
|
||||
EXPECT_EQ(words[9], "example.com");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "timezone_helper.h"
|
||||
#include <gtest/gtest.h>
|
||||
#include <ctime>
|
||||
|
||||
using namespace radiantwave::timezone;
|
||||
|
||||
TEST(TimeZoneTest, GetStandardTimeZonesNonEmpty) {
|
||||
const auto& countries = getStandardTimeZones();
|
||||
EXPECT_FALSE(countries.empty());
|
||||
|
||||
bool found_us = false;
|
||||
bool found_utc = false;
|
||||
|
||||
for (const auto& c : countries) {
|
||||
if (c.country_name == "United States") found_us = true;
|
||||
if (c.country_name == "Coordinated Universal Time") found_utc = true;
|
||||
}
|
||||
|
||||
EXPECT_TRUE(found_us);
|
||||
EXPECT_TRUE(found_utc);
|
||||
}
|
||||
|
||||
TEST(TimeZoneTest, UtcTimestampFormat) {
|
||||
std::string iso = getUtcTimestampIso();
|
||||
EXPECT_FALSE(iso.empty());
|
||||
EXPECT_NE(iso.find("UTC"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(TimeZoneTest, FormatUtcToTimezone) {
|
||||
// 2026-07-24 12:00:00 UTC
|
||||
// Fixed epoch timestamp: 1784808000
|
||||
time_t fixed_utc = 1784808000;
|
||||
|
||||
std::string formatted_ny = formatUtcToTimezone(fixed_utc, "America/New_York", "%Y-%m-%d %H:%M:%S");
|
||||
std::string formatted_utc = formatUtcToTimezone(fixed_utc, "UTC", "%Y-%m-%d %H:%M:%S");
|
||||
|
||||
EXPECT_FALSE(formatted_ny.empty());
|
||||
EXPECT_EQ(formatted_utc, "2026-07-23 12:00:00");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "validation.h"
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
using namespace radiantwave::validation;
|
||||
|
||||
TEST(ValidationTest, ValidEmails) {
|
||||
EXPECT_TRUE(isValidEmail("user@example.com"));
|
||||
EXPECT_TRUE(isValidEmail("first.last@example.co.uk"));
|
||||
EXPECT_TRUE(isValidEmail("user+tag@example.com"));
|
||||
EXPECT_TRUE(isValidEmail("UPPER.CASE@EXAMPLE.ORG"));
|
||||
EXPECT_TRUE(isValidEmail("a@b.co"));
|
||||
}
|
||||
|
||||
TEST(ValidationTest, InvalidEmails) {
|
||||
EXPECT_FALSE(isValidEmail(""));
|
||||
EXPECT_FALSE(isValidEmail("ab"));
|
||||
EXPECT_FALSE(isValidEmail("user@"));
|
||||
EXPECT_FALSE(isValidEmail("@example.com"));
|
||||
EXPECT_FALSE(isValidEmail("user@@example.com"));
|
||||
EXPECT_FALSE(isValidEmail("user@example")); // no dot in domain
|
||||
EXPECT_FALSE(isValidEmail("user name@example.com")); // whitespace
|
||||
EXPECT_FALSE(isValidEmail("user@.example.com")); // empty label
|
||||
EXPECT_FALSE(isValidEmail("user@example..com")); // empty label
|
||||
EXPECT_FALSE(isValidEmail(".user@example.com")); // leading dot in local
|
||||
EXPECT_FALSE(isValidEmail("user.@example.com")); // trailing dot in local
|
||||
EXPECT_FALSE(isValidEmail(std::string(255, 'a') + "@example.com"));
|
||||
}
|
||||
|
||||
TEST(ValidationTest, SanitizeLicenseKey) {
|
||||
EXPECT_EQ(sanitizeLicenseKey(""), "");
|
||||
EXPECT_EQ(sanitizeLicenseKey("abcd-efgh-1234-5678"), "ABCDEFGH12345678");
|
||||
EXPECT_EQ(sanitizeLicenseKey("ab cd ef gh ij kl"), "ABCDEFGHIJKL"); // whitespace stripped
|
||||
EXPECT_EQ(sanitizeLicenseKey("AbC1!!dEf"), "ABC1DEF"); // symbols stripped
|
||||
EXPECT_EQ(sanitizeLicenseKey("12345678901234567890"), "1234567890123456"); // capped at 16
|
||||
}
|
||||
|
||||
TEST(ValidationTest, FormatLicenseKey) {
|
||||
EXPECT_EQ(formatLicenseKey(""), "");
|
||||
EXPECT_EQ(formatLicenseKey("ABCD"), "ABCD");
|
||||
EXPECT_EQ(formatLicenseKey("ABCDEFGH"), "ABCD-EFGH");
|
||||
EXPECT_EQ(formatLicenseKey("ABCDEFGH12345678"), "ABCD-EFGH-1234-5678");
|
||||
// Grouping applies to whatever is passed; length capping is the
|
||||
// sanitizer's responsibility.
|
||||
EXPECT_EQ(formatLicenseKey("ABCDEFGH123456789"), "ABCD-EFGH-1234-5678-9");
|
||||
}
|
||||
Reference in New Issue
Block a user