Warning screen, smarter license re-validation, version reporting

Warning screen
- New reusable WarningPage (word-wrapped message, ENTER acknowledges and
  continues to the screen you were about to enter).
- First use: pressing Play while the selected audio is still downloading.
  Dynamic Audio requires every file; a fixed track requires its own file.

License re-validation
- No longer re-verifies on every Home render. Checks now run only when the
  email/license key is entered or changed, on the manual option, at startup
  when already due, and hourly while valid.
- Invalid + unchanged credentials are never re-checked (persisted pair).
- Valid re-checks at most once per hour, anchored on a DB timestamp so an
  overdue check runs immediately after a power cycle.
- New system_kv keys track the checked pair, outcome, and last-check time.
- Added a System Settings row "Manually Validate License Key OK|Invalid"
  (OK green, Invalid red, label white) that runs a check immediately.

Portal / local integration
- License validation request now sends the app version as `appVersion`.
- The entered license key is mirrored to ~/.local/radiantwave/license.txt.

Misc
- "Please connect to WiFi" only shows when no WiFi is joined AND the internet
  is unreachable (fixes the NetworkManager-missing and Ethernet cases).
- Added unit tests for the audio-download warning, license scheduling policy,
  license request shape, and the new settings-menu entry.
This commit is contained in:
NeilClack
2026-10-14 10:58:40 -05:00
parent 9b57e48969
commit c3b3d6774a
21 changed files with 999 additions and 52 deletions
+35
View File
@@ -96,6 +96,41 @@ TEST(ApiClientTest, ParseLicenseJsonRejectsMalformed) {
EXPECT_FALSE(parseLicenseJson(R"({"key":""})", info));
}
TEST(ApiClientTest, BuildLicenseRequestJsonIncludesVersionAndSystemName) {
const std::string body = buildLicenseRequestJson(
"ABCD-EFGH-IJKL-MNOP", "user@example.com", "Living Room", "1.2.3");
// Round-trips through the parser used for the request body shape.
radiantwave::json::JsonValue root;
ASSERT_TRUE(radiantwave::json::parseDocument(body, root));
EXPECT_EQ(root.stringField("license_key"), "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(root.stringField("email_address"), "user@example.com");
EXPECT_EQ(root.stringField("appVersion"), "1.2.3");
EXPECT_EQ(root.stringField("systemName"), "Living Room");
}
TEST(ApiClientTest, BuildLicenseRequestJsonOmitsEmptyOptionalFields) {
const std::string body =
buildLicenseRequestJson("ABCD-EFGH-IJKL-MNOP", "user@example.com", "", "");
radiantwave::json::JsonValue root;
ASSERT_TRUE(radiantwave::json::parseDocument(body, root));
EXPECT_EQ(root.stringField("license_key"), "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(root.stringField("appVersion"), "");
EXPECT_EQ(root.stringField("systemName"), "");
EXPECT_EQ(root.find("appVersion"), nullptr);
EXPECT_EQ(root.find("systemName"), nullptr);
}
TEST(ApiClientTest, BuildLicenseRequestJsonEscapesValues) {
const std::string body =
buildLicenseRequestJson("k\"ey", "a@b.com", "N\"ame", "1.0");
radiantwave::json::JsonValue root;
ASSERT_TRUE(radiantwave::json::parseDocument(body, root));
EXPECT_EQ(root.stringField("license_key"), "k\"ey");
EXPECT_EQ(root.stringField("systemName"), "N\"ame");
}
TEST(ApiClientTest, ParseCreateAccountJson) {
const std::string body =
R"({"success":true,"message":"Account and license key created","user_id":"abc-123"})";
+65
View File
@@ -150,5 +150,70 @@ TEST(AudioDownloaderMd5, MissingFileReturnsEmpty) {
EXPECT_EQ(computeFileMd5(tempPath("_missing.bin")), "");
}
TEST(AudioDownloadWarning, DynamicAudioWarnsWhenAnyFileMissing) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = false;
snap.files = {
{"174Hz.wav", "174 Hz", true},
{"396Hz.wav", "396 Hz", false},
};
std::string message;
ASSERT_TRUE(buildAudioDownloadWarning(snap, true, "396Hz.wav", message));
EXPECT_NE(message.find("'Dynamic Audio'"), std::string::npos);
EXPECT_NE(message.find("these files"), std::string::npos);
}
TEST(AudioDownloadWarning, DynamicAudioSilentWhenAllAvailable) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = true;
snap.files = {{"174Hz.wav", "174 Hz", true}};
std::string message;
EXPECT_FALSE(buildAudioDownloadWarning(snap, true, "174Hz.wav", message));
}
TEST(AudioDownloadWarning, FixedTrackWarnsWhenItsFileMissing) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = false;
snap.files = {
{"174Hz.wav", "174 Hz", true},
{"OriginalAudio.wav", "Original Audio", false},
};
std::string message;
ASSERT_TRUE(buildAudioDownloadWarning(snap, false, "OriginalAudio.wav", message));
EXPECT_NE(message.find("'Original Audio'"), std::string::npos);
EXPECT_NE(message.find("this file"), std::string::npos);
}
TEST(AudioDownloadWarning, FixedTrackSilentWhenItsFilePresentEvenIfOthersMissing) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = false;
snap.files = {
{"174Hz.wav", "174 Hz", true},
{"396Hz.wav", "396 Hz", false},
};
std::string message;
EXPECT_FALSE(buildAudioDownloadWarning(snap, false, "174Hz.wav", message));
}
TEST(AudioDownloadWarning, UnknownSelectionNeverWarns) {
// No manifest fetched yet: the downloader has no evidence either way.
AudioDownloadSnapshot snap;
std::string message;
EXPECT_FALSE(buildAudioDownloadWarning(snap, false, "OriginalAudio.wav", message));
}
TEST(AudioDownloadWarning, FallsBackToFilenameForDisplayName) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.files = {{"Mystery.wav", "", false}};
std::string message;
ASSERT_TRUE(buildAudioDownloadWarning(snap, false, "Mystery.wav", message));
EXPECT_NE(message.find("'Mystery.wav'"), std::string::npos);
}
} // namespace
} // namespace radiantwave::audio
+84
View File
@@ -97,6 +97,46 @@ TEST(LicenseStatusTest, StatusForResultWithGraceExpired) {
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Invalid);
}
// ─── Scheduling policy (pure) ───────────────────────────────────────────
TEST(LicenseStatusPolicyTest, ForcedAlwaysRuns) {
EXPECT_TRUE(shouldValidate(true, false, false, Status::Invalid, 1000, 999));
EXPECT_TRUE(shouldValidate(true, false, false, Status::Unknown, 1000, 0));
}
TEST(LicenseStatusPolicyTest, CredentialChangeRuns) {
EXPECT_TRUE(shouldValidate(false, true, false, Status::Invalid, 1000, 999));
}
TEST(LicenseStatusPolicyTest, OverdueRecheckRuns) {
EXPECT_TRUE(shouldValidate(false, false, true, Status::Invalid, 1000, 999));
}
TEST(LicenseStatusPolicyTest, InvalidUnchangedDoesNotRun) {
// An invalid license with unchanged credentials is never re-checked, no
// matter how old the last attempt is.
EXPECT_FALSE(shouldValidate(false, false, false, Status::Invalid, 1000, 999));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Invalid,
1000, 1000 - 10 * RECHECK_INTERVAL_SECONDS));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Unknown, 1000, 0));
}
TEST(LicenseStatusPolicyTest, ValidRechecksAfterInterval) {
const long long now = 1000000;
EXPECT_FALSE(shouldValidate(false, false, false, Status::Valid, now, now - 10));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Valid, now,
now - (RECHECK_INTERVAL_SECONDS - 1)));
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, now,
now - RECHECK_INTERVAL_SECONDS));
// Off for several hours: still immediately due, not another full hour away.
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, now,
now - 5 * RECHECK_INTERVAL_SECONDS));
}
TEST(LicenseStatusPolicyTest, ValidWithMissingStampRechecks) {
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, 1000, 0));
}
// ─── Worker lifecycle (no network) ──────────────────────────────────────
TEST(LicenseStatusTest, UnknownBeforeStart) {
@@ -130,6 +170,50 @@ TEST(LicenseStatusTest, InvalidWhenDatabaseClosed) {
tracker.shutdown();
}
TEST(LicenseStatusTest, LoadsPersistedValidWithinGraceWithoutChecking) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "k");
tmp.db.setKV(KV_EMAIL_ADDRESS, "e");
tmp.db.setKV(KV_LICENSE_CHECKED_KEY, "k");
tmp.db.setKV(KV_LICENSE_CHECKED_EMAIL, "e");
tmp.db.setKV(KV_LICENSE_CHECK_STATUS, "valid");
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds()));
tmp.db.setKV(KV_LICENSE_LAST_CHECK, std::to_string(epochSeconds()));
LicenseStatusTracker tracker;
tracker.start(tmp.db);
// Seeded valid + recently checked + unchanged credentials: the worker must
// stay idle (no network), and the status is valid immediately.
EXPECT_EQ(tracker.status(), Status::Valid);
tracker.shutdown();
}
TEST(LicenseStatusTest, RecordResultUpdatesStatusAndPersistsCheckedPair) {
TempDb tmp;
const std::string key = "AAAA-BBBB-CCCC-DDDD";
const std::string email = "user@example.com";
tmp.db.setKV(KV_LICENSE_KEY, key);
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
// Seed the checked pair as already-invalid so the worker does not start a
// network check; this exercises recordResult() in isolation.
tmp.db.setKV(KV_LICENSE_CHECKED_KEY, key);
tmp.db.setKV(KV_LICENSE_CHECKED_EMAIL, email);
tmp.db.setKV(KV_LICENSE_CHECK_STATUS, "invalid");
LicenseStatusTracker tracker;
tracker.start(tmp.db);
EXPECT_EQ(tracker.status(), Status::Invalid);
tracker.recordResult(Result::Ok);
EXPECT_EQ(tracker.status(), Status::Valid);
EXPECT_TRUE(tracker.isValid());
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECKED_KEY), key);
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECKED_EMAIL), email);
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECK_STATUS), "valid");
EXPECT_FALSE(tmp.db.getKV(KV_LICENSE_LAST_CHECK).empty());
tracker.shutdown();
}
// ─── Live: confirmed-valid license (requires env credentials) ───────────
TEST(LicenseStatusLiveTest, ValidWhenLicensePairAccepted) {
+14 -11
View File
@@ -7,21 +7,23 @@ namespace settings = radiantwave::settings;
TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) {
const auto entries = settings::systemSettingEntries();
ASSERT_EQ(entries.size(), 7u);
ASSERT_EQ(entries.size(), 8u);
EXPECT_EQ(entries[0].label, "Email Address");
EXPECT_EQ(entries[0].id, settings::SystemSettingId::EmailAddress);
EXPECT_EQ(entries[1].label, "License Key");
EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey);
EXPECT_EQ(entries[2].label, "System Name");
EXPECT_EQ(entries[2].id, settings::SystemSettingId::SystemName);
EXPECT_EQ(entries[3].label, "Schedule");
EXPECT_EQ(entries[3].id, settings::SystemSettingId::Schedule);
EXPECT_EQ(entries[4].label, "WiFi Setup");
EXPECT_EQ(entries[4].id, settings::SystemSettingId::WiFiSetup);
EXPECT_EQ(entries[5].label, "Time Zone Setup");
EXPECT_EQ(entries[5].id, settings::SystemSettingId::Timezone);
EXPECT_EQ(entries[6].label, "Date Format");
EXPECT_EQ(entries[6].id, settings::SystemSettingId::DateFormat);
EXPECT_EQ(entries[2].label, "Manually Validate License Key");
EXPECT_EQ(entries[2].id, settings::SystemSettingId::ManualValidateLicense);
EXPECT_EQ(entries[3].label, "System Name");
EXPECT_EQ(entries[3].id, settings::SystemSettingId::SystemName);
EXPECT_EQ(entries[4].label, "Schedule");
EXPECT_EQ(entries[4].id, settings::SystemSettingId::Schedule);
EXPECT_EQ(entries[5].label, "WiFi Setup");
EXPECT_EQ(entries[5].id, settings::SystemSettingId::WiFiSetup);
EXPECT_EQ(entries[6].label, "Time Zone Setup");
EXPECT_EQ(entries[6].id, settings::SystemSettingId::Timezone);
EXPECT_EQ(entries[7].label, "Date Format");
EXPECT_EQ(entries[7].id, settings::SystemSettingId::DateFormat);
}
TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) {
@@ -42,6 +44,7 @@ TEST(SystemSettingsMenuTest, EveryEntryMapsToAKnownPage) {
switch (entry.id) {
case settings::SystemSettingId::EmailAddress:
case settings::SystemSettingId::LicenseKey:
case settings::SystemSettingId::ManualValidateLicense:
case settings::SystemSettingId::SystemName:
case settings::SystemSettingId::Schedule:
case settings::SystemSettingId::WiFiSetup: