diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index ceb809d..30a7499 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -29,6 +29,7 @@ Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/> + @@ -37,11 +38,60 @@ + + + + + + + + + + + + + + + + + + + + + + + - + @@ -1072,7 +1122,7 @@ - + @@ -1194,12 +1244,36 @@ VirtualizingPanel.VirtualizationMode="Recycling"> - - - - + + + + + + + + + + diff --git a/HANDOFF.md b/HANDOFF.md index 9357c11..8f8570c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,40 +1,30 @@ -# HANDOFF — 2026-11-05 (TASK 64 — green/blue screen for clips) +# HANDOFF — 2026-11-05 (alert stack + media/chroma fixes) ## State -- `main`. TASK 21 complete (video file layer + its soundtrack). TASK 64 shipped through slice E: - a clip with a green or blue backdrop is **offered** a key on add ("Looks like a green screen — - Remove it"), accepting it keys the background **inside the decode pipe** (bundled ffmpeg - `colorkey`/`despill`), with Amount/Softness/Fringe in the panel and the settings persisted. - Sessions are keyed by **path + look**, so identical looks share one decoder. -- Detection stays silent on lawns/pitches (confidence = coverage × hue tightness) and ignores - letterbox bars. It never acts on its own. -- Slice F shipped too: **App Settings → Video clips → "Auto Green/Blue Screen"** (off by default, - high confidence only, and the panel states when a key was automatic) and **alert clips** key through - the same settings block (one alert box per layout). -- Tests: `ChromaKeyDetectorTests` + `ChromaKeyApplyTests` (pipe asks ffmpeg to key; offer → accept → - keyed session feeds the resolver; survives save; auto-apply; alert clip keyed). App build 0 warnings. -- **TASK 49 retired (creator ruling):** YouTube owns chat moderation — blocked words and hold-for-review - run before the API. What we owed it was not a wordlist but not painting what YouTube removed: the chat - parser now skips `tombstone` (a deleted message's text is never sent) and `hasDisplayContent:false`. - Same commit fixed a live bug: Super Chat read `displayString` instead of `amountDisplayString`, so every - amount fell back to a recomposed string. +- `main`. TASK 21 (video layer + soundtrack), TASK 64 (green/blue screen) and TASK 32·4 (fragmented MP4) + are complete; TASK 49 is retired (YouTube owns chat moderation — we just don't paint tombstones). +- **Left panel alert stack** shipped: Steam licence indicator pinned at row 1 (`Entitlement 30 fps · + Maximum 60 fps`, pin → close once owned, permanent while owned), event rows LIFO beneath, click → + About or the Logs tab with that event's lines painted. Detail: `docs/ai/ui-surface.md`. +- Video layer fixes this session: the preview-pane trigger whitelist (a video layer was invisible), a + quadratic frame copy that capped 1080p playback at 2 fps, and deadline pacing for video *and* audio. +- Tests: 575 green, 1m50s; `FAST=1 ./scripts/verify.sh` = 527 in ~62s. ## Next action -- TASK 64 is complete. Next queued: TASK 49 (chat profanity filter). Day 3's TASK 32/33 resilience slices remain outstanding — - 32·4 (fragmented MP4) is the smallest. +- **TASK 32·1 (blip retry)** — it pushes its episodes into the alert stack. Then 32·5 (ingest pre-flight), + then 32·2/32·3 (countdown + Back on air) which need YOUR measurement take: pull the network on a + private stream and time how long YouTube holds the broadcast. +- Open loop: `32·4` is proven by construction only — kill ffmpeg mid-recording and play the file. - Do not push absent a checkpoint request. ## Landmines -- **The preview pane draws frame-source layers from a whitelist of DataTriggers.** A layer type with - no trigger is invisible in the preview while its decode looks healthy (alert box 2026-09-26, the - scoreboard, and the video layer 2026-11-05). Add the trigger with the layer type; pin it with a - test that asserts the bound Image is Visible (`MediaPreviewDisplayTests`). -- The old physical mouse-drag test is **deleted**; the drag arithmetic it covered is now pure and - tested in `DragReorderMathTests`. Nothing in the suite drives the real cursor any more. +- **The preview pane draws frame-source layers from a whitelist of DataTriggers.** A layer type with no + trigger is invisible in the preview while its decode looks healthy (alert box 2026-09-26, scoreboard, + video layer 2026-11-05). Add the trigger with the layer type; pin it with `MediaPreviewDisplayTests`. +- Nothing in the suite drives the real cursor any more; the drag arithmetic is pure (`DragReorderMathTests`). - Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`. -- Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw - path, and release through `ReleaseMediaIfUnused`. +- Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw path. - Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`). diff --git a/Helpers/LogInRangeConverter.cs b/Helpers/LogInRangeConverter.cs new file mode 100644 index 0000000..df09169 --- /dev/null +++ b/Helpers/LogInRangeConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace ytLive.Helpers; + +/// +/// True when a log entry's sequence falls inside the range a clicked alert points at. Two numbers +/// rather than a membership collection: the log list is virtualised, and rows that are already +/// realised do not re-evaluate when a collection changes — but they do when a bound number changes. +/// +public sealed class LogInRangeConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Length < 3) return false; + if (values[0] is not long sequence) return false; + if (values[1] is not long from || values[2] is not long to) return false; + if (from == 0 && to == 0) return false; + return sequence >= from && sequence <= to; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/MainWindow.xaml b/MainWindow.xaml index 8704b38..2482785 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -145,13 +145,6 @@ ItemContainerStyle="{StaticResource YtQualityComboBoxItem}" ToolTip="{Binding ResolutionHelp}" IsEnabled="{Binding CanChangeQuality}"/> - - - diff --git a/Models/AttentionAlert.cs b/Models/AttentionAlert.cs new file mode 100644 index 0000000..1b3a2af --- /dev/null +++ b/Models/AttentionAlert.cs @@ -0,0 +1,35 @@ +namespace ytLive.Models; + +/// +/// One row in the left panel's alert stack (creator design, 2026-11-05). The stack sits above the +/// panel's content and pushes it down, so an alert is seen without hunting for a tab. +/// Pinned rows are standing state, not errors to clear: they show a pin instead of a +/// close control and are only removed when the state itself changes (the Steam licence indicator +/// becomes dismissible — and its dismissal permanent — once ownership is verified). +/// Clicking a row runs its action: the licence indicator opens About; an event alert opens the +/// Logs tab with that event's lines highlighted. +/// +public sealed record AttentionAlert( + string Id, + string Label, + string Text, + bool IsPinned, + AttentionAlertAction Action, + long LogFrom = 0, + long LogTo = 0) +{ + /// True while a close control should be shown (a dismissible row). + public bool CanDismiss => !IsPinned; + + /// True while the row should show the pin glyph instead of a close control. + public bool ShowsPin => IsPinned; +} + +public enum AttentionAlertAction +{ + /// Open the About panel (the licence indicator). + About, + + /// Open the Logs tab and highlight this event's lines. + Logs, +} diff --git a/Services/Entitlement/SteamEntitlement.cs b/Services/Entitlement/SteamEntitlement.cs index 76fbe77..faff0ca 100644 --- a/Services/Entitlement/SteamEntitlement.cs +++ b/Services/Entitlement/SteamEntitlement.cs @@ -52,7 +52,7 @@ public sealed class SteamEntitlement : IEntitlement if (_paidAppId == 0) { _cached = false; - _errorMessage = "The paid Steam app ID is not configured for this build. Contact the creator or support."; + _errorMessage = "Steam licensing is not configured in this build."; return false; } if (_cached is { } known) return known; diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index 65652e0..3397471 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -50,6 +50,13 @@ public partial class LayoutStore : IDisposable /// to the box centre while it is dragged). /// TASK 64: key a detected green/blue screen without asking. Off by default — the /// app offers, the creator accepts; this switch is for the creator who is tired of clicking. + /// Creator closed the Steam licence indicator while ownership was verified. Honoured only + /// while Steam keeps confirming ownership — losing it brings the indicator back (2026-11-05). + public bool LoadSteamLicenceIndicatorDismissed() => GetSetting("Steam.LicenceIndicatorDismissed") == "1"; + + public void SaveSteamLicenceIndicatorDismissed(bool dismissed) + => UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0"); + public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1"; public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0"); diff --git a/ViewModels/MainViewModel.Alerts.cs b/ViewModels/MainViewModel.Alerts.cs new file mode 100644 index 0000000..e718f8e --- /dev/null +++ b/ViewModels/MainViewModel.Alerts.cs @@ -0,0 +1,138 @@ +using System.Collections.ObjectModel; +using System.Linq; +using ytLive.Helpers; +using ytLive.Models; + +namespace ytLive.ViewModels; + +/// +/// The left panel's alert stack (creator design, 2026-11-05): standing state on top, transient events +/// beneath it, newest first, pushing the panel's content down. Only red rows belong here — the panel +/// is where "something needs you" lives, so it never fills with chatter. +/// Row 1 is the Steam licence indicator and nothing displaces it. It is pinned while +/// higher output tiers are unavailable, and becomes dismissible — permanently — once ownership is +/// verified. If ownership is later lost, it comes back as a pinned indicator, because that is a change +/// of state rather than a preference. +/// +public partial class MainViewModel +{ + private const string SteamLicenceAlertId = "steam-licence"; + + private readonly ObservableCollection _attentionAlerts = new(); + private bool _steamLicenceDismissed; + + public ObservableCollection AttentionAlerts => _attentionAlerts; + + public bool HasAttentionAlerts => _attentionAlerts.Count > 0; + + public RelayCommand DismissAlertCommand => new(DismissAlert); + + public RelayCommand ActivateAlertCommand => new(ActivateAlert); + + /// The licence indicator's text: what this build is entitled to, and the maximum. + /// A status readout — deliberately not a description of what is missing (creator ruling: the + /// resolution picker explains that). + private string SteamLicenceText() + { + if (QualityOptions.Length == 0) return "Entitlement 30 fps · Maximum 60 fps"; + + var max = QualityOptions.Max(option => option.Fps); + var entitled = QualityOptions + .Where(option => HasProOutput || !option.RequiresPro) + .Select(option => option.Fps) + .DefaultIfEmpty(max) + .Max(); + return $"Entitlement {entitled} fps · Maximum {max} fps"; + } + + /// Recomputes row 1 in place. Called at startup and whenever ownership changes. + private void RefreshSteamLicenceAlert() + { + var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == SteamLicenceAlertId); + var hidden = IsAppOwned && _steamLicenceDismissed; + + if (hidden) + { + if (existing != null) _attentionAlerts.Remove(existing); + RaiseAttentionChanged(); + return; + } + + var alert = new AttentionAlert( + SteamLicenceAlertId, + "STEAM LICENCE", + SteamLicenceText(), + IsPinned: !IsAppOwned, + AttentionAlertAction.About); + + var index = existing == null ? -1 : _attentionAlerts.IndexOf(existing); + if (index < 0) _attentionAlerts.Insert(0, alert); + else _attentionAlerts[index] = alert; // same row, updated state + RaiseAttentionChanged(); + } + + /// Pushes an event alert. Newest sits directly under the licence indicator; older ones + /// stack beneath, so the newest is the one nearest the eye. + private AttentionAlert? PushAttentionAlert(string id, string label, string text, long logFrom, long logTo) + { + var alert = new AttentionAlert(id, label, text, IsPinned: false, AttentionAlertAction.Logs, logFrom, logTo); + var insertAt = _attentionAlerts.Count(alert => alert.Id == SteamLicenceAlertId); + var existing = _attentionAlerts.FirstOrDefault(candidate => candidate.Id == id); + if (existing != null) + { + // Same event advancing (reconnecting attempt 2 → 3): update the row, never duplicate it. + _attentionAlerts[_attentionAlerts.IndexOf(existing)] = alert; + } + else + { + _attentionAlerts.Insert(insertAt, alert); + } + RaiseAttentionChanged(); + return alert; + } + + /// Test seam: pushes an event row without a stream session behind it. + internal AttentionAlert? PushAttentionAlertForTest(string id, string label, string text, long logFrom, long logTo) + => PushAttentionAlert(id, label, text, logFrom, logTo); + + private void RemoveAttentionAlert(string id) + { + var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == id); + if (existing == null) return; + _attentionAlerts.Remove(existing); + RaiseAttentionChanged(); + } + + private void DismissAlert(AttentionAlert? alert) + { + if (alert == null || alert.IsPinned) return; + + if (alert.Id == SteamLicenceAlertId) + { + // Permanent for this profile: the creator said "I have seen it". Only honoured while + // ownership holds — RefreshSteamLicenceAlert ignores it when Steam stops confirming. + _steamLicenceDismissed = true; + _layoutStore.SaveSteamLicenceIndicatorDismissed(true); + } + + RemoveAttentionAlert(alert.Id); + } + + private void ActivateAlert(AttentionAlert? alert) + { + if (alert == null) return; + + if (alert.Action == AttentionAlertAction.About) + { + ShowAbout(); + return; + } + + // Event alert: open the log and paint this event's lines, so the alert is an entry point to + // the detail rather than a dead end. + HighlightLogRange(alert.LogFrom, alert.LogTo); + SelectLogsTab(true); + } + + private void RaiseAttentionChanged() => OnPropertyChanged(nameof(HasAttentionAlerts)); +} diff --git a/ViewModels/MainViewModel.Logs.cs b/ViewModels/MainViewModel.Logs.cs index 3dfa5b9..6c21fa7 100644 --- a/ViewModels/MainViewModel.Logs.cs +++ b/ViewModels/MainViewModel.Logs.cs @@ -16,6 +16,19 @@ public partial class MainViewModel public ObservableCollection CurrentLaunchLogEntries => _currentLaunchLogEntries; public ObservableCollection PinnedLogEntries => _pinnedLogEntries; + + // The event range a clicked alert points at. A range rather than a collection: the log list is + // virtualised, and a membership change would not re-evaluate realised rows — two numbers do. + private long _highlightedLogFrom; + private long _highlightedLogTo; + public long HighlightedLogFrom => _highlightedLogFrom; + public long HighlightedLogTo => _highlightedLogTo; + + private void HighlightLogRange(long from, long to) + { + SetProperty(ref _highlightedLogFrom, from, nameof(HighlightedLogFrom)); + SetProperty(ref _highlightedLogTo, to, nameof(HighlightedLogTo)); + } public bool HasPinnedLogEntries => _pinnedLogEntries.Count > 0; public RelayCommand AcknowledgeLogEntryCommand => new(AcknowledgeLogEntry); diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs index 0ea30b3..704eeae 100644 --- a/ViewModels/MainViewModel.OutputProfile.cs +++ b/ViewModels/MainViewModel.OutputProfile.cs @@ -25,6 +25,7 @@ public partial class MainViewModel EntitlementErrorMessage = _entitlement.ErrorMessage; if (!changed) return; OnPropertyChanged(nameof(HasProOutput)); + RefreshSteamLicenceAlert(); if (!HasProOutput && _selectedQuality is { RequiresPro: true } && CanChangeQuality) SelectedQuality = QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName); } @@ -77,6 +78,7 @@ public partial class MainViewModel if (!DevBuild.IsDevelopment) return; if (!SetProperty(ref _developerMode, value)) return; OnPropertyChanged(nameof(HasProOutput)); + RefreshSteamLicenceAlert(); } } } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 45e84eb..b79e03e 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -350,6 +350,8 @@ public partial class MainViewModel : ViewModelBase AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs(); _zoomFollowsBox = _layoutStore.LoadZoomFollowBox(); _autoChromaScreen = _layoutStore.LoadAutoChromaScreen(); + _steamLicenceDismissed = _layoutStore.LoadSteamLicenceIndicatorDismissed(); + RefreshSteamLicenceAlert(); MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter _musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default InitScoreboard(); diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 094cbf0..568c545 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -1,3 +1,22 @@ +## Left panel alert stack (2026-11-05) + +A stack of red rows at the top of the left panel, **above both mode grids** (so it shows while editing +and while live) and pushing the panel's content down. Only red rows belong there — it is the surface for +"something needs you", never chatter. + +- **Row 1 is always the Steam licence indicator** and nothing displaces it. It reads as status, not a + warning: `Entitlement 30 fps · Maximum 60 fps`, both numbers derived from `QualityOptions` and + `HasProOutput`. It shows a **pin** (standing indicator, no close control) while higher tiers are + unavailable, and a **close control once ownership is verified** — that close is persisted + (`Steam.LicenceIndicatorDismissed`) and permanent *while ownership holds*; losing ownership restores + the row as pinned, because that is a change of state rather than a preference. Click → About. +- **Event rows stack beneath it, newest first**, one row per episode (a reconnect advancing its state + updates its own row rather than adding another). Each is dismissible. Click → the Logs tab opens with + that event's sequence range painted red (`HighlightedLogFrom/To` + `LogInRangeConverter`; two numbers + rather than a collection because the log list is virtualised and realised rows do not re-evaluate on + membership changes). +- The old red panel under the quality picker is **gone** — this stack replaced it. + ## Preview pane — frame-source layers are a WHITELIST `PreviewPane.xaml`'s per-layer `Image` is **Collapsed by default** and switched on by one DataTrigger diff --git a/ytLive.Tests/AboutHubTests.cs b/ytLive.Tests/AboutHubTests.cs index bb923b6..2a6dfd9 100644 --- a/ytLive.Tests/AboutHubTests.cs +++ b/ytLive.Tests/AboutHubTests.cs @@ -96,10 +96,13 @@ public sealed class AboutHubTests var lockedTier = vm.QualityOptions.First(option => option.Fps == 60); Assert.Same(freeTier, vm.SelectedQuality); - var errorPanel = (Border)window.FindName("EntitlementErrorPanel"); - var errorText = (TextBlock)window.FindName("EntitlementErrorText"); - Assert.Equal(Visibility.Visible, errorPanel.Visibility); - Assert.Contains("Open Steam, sign in", errorText.Text); + // The old red panel under the quality picker is gone (2026-11-05): the licence state + // lives in the left panel's alert stack now, as a pinned status indicator. + Assert.Null(window.FindName("EntitlementErrorPanel")); + var licenceRow = vm.AttentionAlerts.Single(alert => alert.Label == "STEAM LICENCE"); + Assert.True(licenceRow.IsPinned); + Assert.Contains("Entitlement 30 fps", licenceRow.Text); + Assert.Contains("Maximum 60 fps", licenceRow.Text); var picker = (ComboBox)window.FindName("QualityPicker"); Assert.True(vm.CanChangeQuality); diff --git a/ytLive.Tests/AttentionAlertStackTests.cs b/ytLive.Tests/AttentionAlertStackTests.cs new file mode 100644 index 0000000..2f5d09c --- /dev/null +++ b/ytLive.Tests/AttentionAlertStackTests.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.Services.Entitlement; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// 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. +/// 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). +/// +[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.Label); + Assert.True(row.IsPinned); + Assert.True(row.ShowsPin); + Assert.False(row.CanDismiss); + Assert.Equal(AttentionAlertAction.About, row.Action); + + // A status readout: what this build is entitled to, and the maximum. + Assert.Contains("Entitlement 30 fps", row.Text); + Assert.Contains("Maximum 60 fps", 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("Entitlement 60 fps", 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.Label == "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.Label == "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.PushAttentionAlertForTest("drop-1", "STREAM", "Stream dropped — reconnecting (attempt 1 of 4)", 10, 12); + vm.PushAttentionAlertForTest("drop-2", "STREAM", "Stream dropped — reconnecting (attempt 2 of 4)", 13, 15); + + Assert.Equal(new[] { "STEAM LICENCE", "STREAM", "STREAM" }, vm.AttentionAlerts.Select(a => a.Label)); + // 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", "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].Label); + }); + } + + [Fact] + public void ClickingAnEventRow_OpensTheLogsTab_AndHighlightsItsRange() + { + WithVm(vm => + { + vm.PushAttentionAlertForTest("drop-1", "STREAM", "Stream dropped", 41, 47); + + vm.ActivateAlertCommand.Execute(vm.AttentionAlerts[1]); + + Assert.True(vm.IsLogsTabSelected); + Assert.Equal(41, vm.HighlightedLogFrom); + Assert.Equal(47, vm.HighlightedLogTo); + }); + } + + private void WithVm(Action body) => WithVm((vm, _) => body(vm)); + + private void WithVm(Action body) + { + _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 */ } + } + }); + } +} diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs index 883e02a..78b3467 100644 --- a/ytLive.Tests/EntitlementTests.cs +++ b/ytLive.Tests/EntitlementTests.cs @@ -22,7 +22,9 @@ public class EntitlementTests { var entitlement = SteamEntitlement.NotConfigured(); Assert.False(entitlement.IsAppOwned); - Assert.Contains("paid Steam app ID is not configured", entitlement.ErrorMessage); + // The diagnostic is deliberately neutral (2026-11-05): the licence state is shown to the + // creator as a status indicator, so the log text carries no "contact support" wording. + Assert.Contains("Steam licensing is not configured", entitlement.ErrorMessage); } [Fact]