151 lines
9.1 KiB
C#
151 lines
9.1 KiB
C#
using Lumi.Companion.Abstractions;
|
|
using Lumi.Companion.Core;
|
|
using Lumi.Companion.Overlay;
|
|
|
|
static void Assert(bool condition, string message)
|
|
{
|
|
if (!condition) throw new InvalidOperationException(message);
|
|
}
|
|
|
|
var operation = new SingleFlightOperation();
|
|
var calls = 0;
|
|
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
|
|
Assert(calls == 1 && !operation.IsRunning, "A successful update-style operation did not reset.");
|
|
|
|
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
|
|
Assert(calls == 2 && !operation.IsRunning, "A no-update-style repeat did not execute.");
|
|
|
|
try
|
|
{
|
|
await operation.RunAsync(_ => throw new InvalidOperationException("expected"));
|
|
}
|
|
catch (InvalidOperationException error) when (error.Message == "expected") { }
|
|
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
|
|
Assert(calls == 3 && !operation.IsRunning, "A failed operation left stale single-flight state.");
|
|
|
|
using (var cancelled = new CancellationTokenSource())
|
|
{
|
|
cancelled.Cancel();
|
|
try { await operation.RunAsync(token => Task.Delay(1, token), cancelled.Token); }
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
|
|
Assert(calls == 4 && !operation.IsRunning, "A cancelled operation left stale single-flight state.");
|
|
|
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var concurrentCalls = 0;
|
|
var first = operation.RunAsync(async _ => { concurrentCalls += 1; await release.Task; });
|
|
var second = operation.RunAsync(async _ => { concurrentCalls += 1; await Task.Yield(); });
|
|
Assert(operation.IsRunning && concurrentCalls == 1, "Rapid update checks started more than one operation.");
|
|
release.SetResult();
|
|
await Task.WhenAll(first, second);
|
|
Assert(concurrentCalls == 1 && !operation.IsRunning, "Rapid update checks did not share and reset the active operation.");
|
|
|
|
var sanitized = CompanionLogSanitizer.Sanitize(
|
|
"LumiDevice device-id.private-device-secret Authorization=private-value https://lumi.test/?token=private-token Cookie: session=private-cookie"
|
|
);
|
|
Assert(!sanitized.Contains("private-value"), "Companion logs retained an authorization value.");
|
|
Assert(!sanitized.Contains("private-cookie"), "Companion logs retained a cookie.");
|
|
Assert(!sanitized.Contains("private-token"), "Companion logs retained a query token.");
|
|
Assert(!sanitized.Contains("private-device-secret"), "Companion logs retained a paired-device credential.");
|
|
Assert(CompanionLogSanitizer.NormalizeEvent("Update Failed!") == "update_failed", "Companion event normalization drifted.");
|
|
Assert(CompanionLogSanitizer.LevelForEvent("update_failed") == "error", "Companion failed events must use the error level.");
|
|
|
|
var anchored = new OverlayContainerSettings
|
|
{
|
|
Anchor = OverlayAnchor.Top,
|
|
PaddingTop = 20,
|
|
PaddingLeft = 40,
|
|
PaddingRight = 60,
|
|
PaddingBottom = 999,
|
|
Width = 5000,
|
|
Height = 300
|
|
};
|
|
var rect = AnchorLayout.Calculate(anchored, 1920, 1080);
|
|
Assert(anchored.PaddingBottom == 0, "Top-side anchors must zero disabled bottom padding.");
|
|
Assert(rect.Width == 1820 && rect.X == 40 && rect.Y == 20, "Physical-pixel layout did not clamp and center symmetrically inside valid padding.");
|
|
|
|
var now = DateTimeOffset.UnixEpoch;
|
|
var queueSettings = new OverlayContainerSettings { QueueLimit = 2, EntryDurationMs = 100, LifetimeMs = 200, ExitDurationMs = 100 };
|
|
var queue = new OverlayLifecycleQueue(() => queueSettings, () => now);
|
|
queue.Add("one", "chat", "one");
|
|
queue.Add("two", "chat", "two");
|
|
queue.Add("three", "chat", "three");
|
|
Assert(queue.Items[0].State == OverlayItemState.Exiting && queue.Items.Count == 3,
|
|
"Overflow must animate the oldest item out while keeping it rendered.");
|
|
now += TimeSpan.FromMilliseconds(100);
|
|
queue.Advance();
|
|
Assert(queue.Items.Count == 2 && queue.Items.All(item => item.State == OverlayItemState.Visible),
|
|
"Entry and exit lifecycle transitions did not advance deterministically.");
|
|
now += TimeSpan.FromMilliseconds(201);
|
|
queue.Advance();
|
|
Assert(queue.Items.All(item => item.State == OverlayItemState.Exiting), "Lifetime must begin only after entry completes.");
|
|
for (var index = 0; index < 20; index++) queue.Add($"burst-{index}", "chat", index);
|
|
Assert(queue.Items.Count <= queueSettings.QueueLimit * 2, "Burst handling must keep the rendered lifecycle queue strictly bounded.");
|
|
|
|
var settingsPath = Path.Combine(Path.GetTempPath(), $"lumi-overlay-test-{Guid.NewGuid():N}", "settings.json");
|
|
var overlayStore = new OverlaySettingsStore(settingsPath);
|
|
await overlayStore.LoadAsync();
|
|
overlayStore.Current.Sources.Add(new OverlaySourceSetting
|
|
{
|
|
Id = "twitch:renamed-channel",
|
|
Platform = "twitch",
|
|
Label = "Previously configured channel",
|
|
Selected = true
|
|
});
|
|
overlayStore.Current.Unified.Anchor = OverlayAnchor.BottomRight;
|
|
overlayStore.Current.Unified.PaddingTop = 42;
|
|
await overlayStore.SaveAsync(overlayStore.Current);
|
|
var restoredStore = new OverlaySettingsStore(settingsPath);
|
|
await restoredStore.LoadAsync();
|
|
Assert(restoredStore.Current.Sources.Single().Selected && restoredStore.Current.Sources.Single().Label == "Previously configured channel",
|
|
"Unavailable source selections were not retained by atomic local settings persistence.");
|
|
Assert(restoredStore.Current.Unified.PaddingTop == 0,
|
|
"Settings normalization must clear padding that is invalid for the selected anchor.");
|
|
var unsavedDraft = restoredStore.Current.CreateCopy();
|
|
unsavedDraft.ChatPercentage = 60;
|
|
unsavedDraft.Sources[0].Selected = false;
|
|
Assert(restoredStore.Current.ChatPercentage != unsavedDraft.ChatPercentage && restoredStore.Current.Sources[0].Selected,
|
|
"Editable overlay drafts must not mutate or get replaced by the last saved settings.");
|
|
Assert(!CompanionPluginNavigation.UsesNestedNavigation(
|
|
[new CompanionPluginPage("SongOverlay", "Overview & settings")]) &&
|
|
CompanionPluginNavigation.UsesNestedNavigation(
|
|
[new CompanionPluginPage("Transcription", "Capture"), new CompanionPluginPage("Test", "Test")]),
|
|
"Single-page plugins must be direct navigation entries while multi-page plugins remain grouped.");
|
|
Assert(new CaptureExclusionStatus(CaptureExclusionState.Unsupported, "unsupported").State == CaptureExclusionState.Unsupported,
|
|
"Capture-exclusion unsupported state must remain distinct from failure.");
|
|
var visibilitySettings = new LumiOverlaySettings
|
|
{
|
|
Visibility = OverlayVisibilityMode.WhenLive,
|
|
Sources = [new() { Id = "twitch:lumi", Platform = "twitch", Label = "Lumi", Selected = true }]
|
|
};
|
|
var liveSources = new[] { new OverlaySource("twitch:lumi", "twitch", "Lumi", true, true, "live") };
|
|
Assert(OverlayVisibilityPolicy.Automatic(visibilitySettings, liveSources), "A selected live Twitch source must activate When live.");
|
|
Assert(!OverlayVisibilityPolicy.Automatic(visibilitySettings,
|
|
[new OverlaySource("discord:guild:channel", "discord", "Discord", true, true, "connected")]),
|
|
"Discord must never activate When live by itself.");
|
|
Assert(OverlayVisibilityPolicy.Effective(visibilitySettings, [], true, null), "Configuration preview must override live-state visibility.");
|
|
visibilitySettings.Enabled = false;
|
|
Assert(OverlayVisibilityPolicy.Effective(visibilitySettings, [], true, null), "Configuration preview must remain available while the runtime is disabled.");
|
|
visibilitySettings.Enabled = true;
|
|
Assert(!OverlayVisibilityPolicy.Effective(visibilitySettings, liveSources, false, false), "A manual hidden override must win until automatic visibility is restored.");
|
|
var automaticPresentation = OverlayVisibilityPresenter.Describe(visibilitySettings, true, false, null);
|
|
Assert(automaticPresentation.Visible && automaticPresentation.Action == "Hide temporarily" &&
|
|
automaticPresentation.State.Contains("automatic", StringComparison.Ordinal),
|
|
"Visibility presentation must separate the current automatic state from the button action.");
|
|
var overridePresentation = OverlayVisibilityPresenter.Describe(visibilitySettings, true, false, false);
|
|
Assert(!overridePresentation.Visible && overridePresentation.Action == "Restore automatic visibility",
|
|
"Visibility presentation must identify a temporary override and explain how to leave it.");
|
|
var previewPresentation = OverlayVisibilityPresenter.Describe(visibilitySettings, false, true, null);
|
|
Assert(previewPresentation.Visible && !previewPresentation.CanToggle && previewPresentation.Action.Contains("End preview", StringComparison.Ordinal),
|
|
"Preview-controlled visibility must be explicit and must not offer an ineffective override action.");
|
|
var monitorFallback = OverlayMonitorPolicy.Resolve("missing", ["primary", "second"], "primary");
|
|
Assert(monitorFallback.MonitorId == "primary" && monitorFallback.UsedFallback && monitorFallback.Warning is not null,
|
|
"Monitor loss must fall back without erasing the saved monitor or hiding the warning.");
|
|
Assert(CompanionLiveReconnectPolicy.Delay(1) == TimeSpan.FromSeconds(2) &&
|
|
CompanionLiveReconnectPolicy.Delay(99) == TimeSpan.FromSeconds(30),
|
|
"Live feed reconnect backoff must be bounded and deterministic.");
|
|
Directory.Delete(Path.GetDirectoryName(settingsPath)!, true);
|
|
|
|
Console.WriteLine("Companion core, native overlay layout/lifecycle, settings persistence, and capture-status regression checks passed.");
|