The frame-rate boundary is correct as a product rule, but it only bites in a Release build, where DevBuild.IsDevelopment is false and DeveloperMode cannot unlock anything — exactly the build whose performance numbers are worth reading (see docs/ai/environment.md: Debug pixel paths are several times slower). 60 fps could not be tested at all. OutputProfile.EntitlementEnforced is false: every shipped tier is selectable by any build, and HasProOutput — the one property the tier setter, the dropdown's strikethrough and the locked-item style all read — answers yes regardless of ownership. static readonly rather than const, so the consumed `||` does not fold into unreachable operand code (same reason as DevBuild.IsDevelopment). The licence indicator row is suppressed while the switch is off: it exists to explain what the boundary costs, and claiming "Steam ID Limit: 60fps" to an unowned build would be a false statement. Docs corrected in place — the enforced rule is unchanged, only its state is recorded. Tests: one integration case covers both switch states (unowned build reaches 1080p60 with the switch off, 1080p30 with it on), so flipping at launch leaves no red test behind. The two suites that anchor on the pinned licence row early-return while it is absent; EntitlementTests covers that state meanwhile. 667 pass, build 0 warnings.
207 lines
8.4 KiB
C#
207 lines
8.4 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Data.Sqlite;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services.Entitlement;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// The left panel's alert stack (creator design, 2026-11-05). Row 1 is the Steam licence indicator and
|
|
/// nothing displaces it: pinned (no close) while higher tiers are unavailable, closable — permanently —
|
|
/// once ownership is verified, and back as pinned if ownership is later lost.
|
|
/// <para>Event rows stack beneath it, newest first, each dismissible, and clicking one opens the Logs
|
|
/// tab with that event's lines painted (the range is what the row carries).</para>
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class AttentionAlertStackTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public AttentionAlertStackTests(RealAppHost app) => _app = app;
|
|
|
|
[Fact]
|
|
public void LicenceIndicator_IsPinnedFirst_AndShowsStatusNotAPunishment()
|
|
{
|
|
WithVm(vm =>
|
|
{
|
|
// A Debug build fakes ownership; this test is about the NOT-OWNED state.
|
|
vm.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
|
|
|
|
var row = Assert.Single(vm.AttentionAlerts);
|
|
Assert.Equal("steam-licence", row.Id);
|
|
Assert.True(row.IsPinned);
|
|
Assert.True(row.ShowsPin);
|
|
Assert.False(row.CanDismiss);
|
|
Assert.Equal(AttentionAlertAction.About, row.Action);
|
|
// Two lines at most: the heading above the message was redundant.
|
|
Assert.DoesNotContain("\n\n", row.Text);
|
|
|
|
// Creator wording: the limit this build runs at, and where to find out why.
|
|
Assert.Contains("Steam ID Limit: 30fps", row.Text);
|
|
Assert.Contains("Click for More Info", row.Text);
|
|
Assert.DoesNotContain("unavailable", row.Text, StringComparison.OrdinalIgnoreCase);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void OwnedLicence_MakesTheIndicatorClosable_AndTheCloseIsPermanent()
|
|
{
|
|
WithVm((vm, db) =>
|
|
{
|
|
vm.EntitlementOverride = new FakeEntitlement(true);
|
|
|
|
var row = Assert.Single(vm.AttentionAlerts);
|
|
Assert.False(row.IsPinned);
|
|
Assert.False(row.ShowsPin);
|
|
Assert.True(row.CanDismiss);
|
|
Assert.Contains("Steam ID Limit: 60fps", row.Text);
|
|
|
|
vm.DismissAlertCommand.Execute(row);
|
|
Assert.Empty(vm.AttentionAlerts);
|
|
|
|
// Permanent: a fresh view-model on the same profile must not bring it back…
|
|
var reloaded = new MainViewModel();
|
|
Assert.DoesNotContain(reloaded.AttentionAlerts, alert => alert.Id == "steam-licence");
|
|
|
|
// …but losing ownership restores it as a pinned indicator: a change of state, not a preference.
|
|
reloaded.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
|
|
var restored = reloaded.AttentionAlerts.First(alert => alert.Id == "steam-licence");
|
|
Assert.True(restored.IsPinned);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void PinnedIndicator_CannotBeDismissed()
|
|
{
|
|
WithVm(vm =>
|
|
{
|
|
vm.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
|
|
var row = vm.AttentionAlerts.Single();
|
|
vm.DismissAlertCommand.Execute(row);
|
|
Assert.Single(vm.AttentionAlerts); // still there: it is state, not an alert to clear
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void EventRows_StackBeneathTheIndicator_NewestFirst_AndDismissIndividually()
|
|
{
|
|
WithVm(vm =>
|
|
{
|
|
vm.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
|
|
vm.PushAttentionAlertForTest("drop-1", "Stream dropped — reconnecting (attempt 1 of 4)", 10, 12);
|
|
vm.PushAttentionAlertForTest("drop-2", "Stream dropped — reconnecting (attempt 2 of 4)", 13, 15);
|
|
|
|
Assert.Equal(new[] { "steam-licence", "drop-2", "drop-1" }, vm.AttentionAlerts.Select(a => a.Id));
|
|
// Newest event sits directly under the indicator.
|
|
Assert.Contains("attempt 2", vm.AttentionAlerts[1].Text);
|
|
Assert.Contains("attempt 1", vm.AttentionAlerts[2].Text);
|
|
|
|
// The same event advancing updates its row instead of adding another.
|
|
vm.PushAttentionAlertForTest("drop-2", "Stream recovered after 1.4 s", 13, 16);
|
|
Assert.Equal(3, vm.AttentionAlerts.Count);
|
|
Assert.Contains("recovered", vm.AttentionAlerts[1].Text);
|
|
|
|
vm.DismissAlertCommand.Execute(vm.AttentionAlerts[1]);
|
|
Assert.Equal(2, vm.AttentionAlerts.Count);
|
|
Assert.Equal("steam-licence", vm.AttentionAlerts[0].Id);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void ClickingAnEventRow_OpensTheLogsTab_AndHighlightsItsRange()
|
|
{
|
|
WithVm(vm =>
|
|
{
|
|
vm.PushAttentionAlertForTest("drop-1", "Stream dropped", 41, 47);
|
|
|
|
vm.ActivateAlertCommand.Execute(vm.AttentionAlerts[1]);
|
|
|
|
Assert.True(vm.IsLogsTabSelected);
|
|
Assert.Equal(41, vm.HighlightedLogFrom);
|
|
Assert.Equal(47, vm.HighlightedLogTo);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecoveryReportAndStop_FromWorkerThread_MarshalAlertChangesToDispatcher()
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-alerts-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
MainViewModel? vm = null;
|
|
try
|
|
{
|
|
await _app.RunAsync(async () =>
|
|
{
|
|
vm = new MainViewModel();
|
|
var dispatcher = System.Windows.Application.Current!.Dispatcher;
|
|
var changedOnUi = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
vm.AttentionAlerts.CollectionChanged += (_, _) =>
|
|
changedOnUi.TrySetResult(dispatcher.CheckAccess());
|
|
|
|
await Task.Run(() => vm.ReportRecoveryStatus(
|
|
"Stream dropped — reconnecting (attempt 1 of 4)", 10));
|
|
Assert.True(await changedOnUi.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
|
|
|
var stopped = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
vm.PropertyChanged += (_, args) =>
|
|
{
|
|
if (args.PropertyName == nameof(vm.StreamStatus) && vm.IsOffline)
|
|
stopped.TrySetResult(dispatcher.CheckAccess());
|
|
};
|
|
vm.StreamStatus = ytLive.Models.StreamStatus.Streaming;
|
|
|
|
await Task.Run(() => vm.EndStreamCommand.Execute(null));
|
|
Assert.True(await stopped.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
|
Assert.DoesNotContain(vm.AttentionAlerts, alert => alert.Id == "stream-recovery");
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (vm != null)
|
|
await _app.RunAsync(() =>
|
|
{
|
|
vm.Shutdown();
|
|
return Task.CompletedTask;
|
|
});
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
private void WithVm(Action<MainViewModel> body) => WithVm((vm, _) => body(vm));
|
|
|
|
private void WithVm(Action<MainViewModel, string> body)
|
|
{
|
|
// Every case here hangs off the pinned licence row, and that row exists only while the
|
|
// frame-rate boundary is enforced — pre-release nothing is limited, so it is deliberately
|
|
// absent. The suite goes dark until launch; the switch-off state is covered by
|
|
// EntitlementTests.
|
|
if (!OutputProfile.EntitlementEnforced) return;
|
|
|
|
_app.Run(() =>
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-alerts-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
var window = new MainWindow();
|
|
var vm = (MainViewModel)window.DataContext;
|
|
try
|
|
{
|
|
body(vm, tempDb);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
});
|
|
}
|
|
}
|