From 9fa06c67c246ef24aab2da72d148b2b4552694d4 Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 2 Nov 2026 11:06:43 -0800 Subject: [PATCH] feat(ui): add current-launch logs tab Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging. Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473 --- Controls/LeftPanel.xaml | 142 +++++++++++++++++----------- Controls/index.md | 4 +- HANDOFF.md | 29 +++--- Helpers/AppLog.cs | 20 +++- Helpers/index.md | 2 +- ViewModels/MainViewModel.Logs.cs | 95 +++++++++++++++++++ ViewModels/MainViewModel.cs | 2 + ViewModels/index.md | 1 + docs/ai/ui-surface.md | 2 +- ytLive.Tests/AppLogRotationTests.cs | 1 + ytLive.Tests/LogsPanelTests.cs | 84 ++++++++++++++++ 11 files changed, 305 insertions(+), 77 deletions(-) create mode 100644 ViewModels/MainViewModel.Logs.cs create mode 100644 ytLive.Tests/LogsPanelTests.cs diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 76fec90..e1b35a0 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -913,69 +913,97 @@ - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - - - + + - - - - - - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Controls/index.md b/Controls/index.md index 70c12d3..610e378 100644 --- a/Controls/index.md +++ b/Controls/index.md @@ -19,7 +19,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions. The Add Layer menu exposes one standalone recent-alert Ticker per scene. The properties panel edits its independent `Ticker` settings; Stream Alerts retains -its separate current-event ticker controls. +its separate current-event ticker controls. Outside edit mode, the left panel +offers Live Chat first and Logs second; Logs shows this process's newest AppLog +entries first and excludes earlier file contents. ## Window ↔ control contract diff --git a/HANDOFF.md b/HANDOFF.md index 412e06d..b678930 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,27 +1,24 @@ -# HANDOFF — 2026-11-02 (scene-local alert ticker) +# HANDOFF — 2026-11-02 (current-launch Logs tab) ## State -- `main`; ticker work is local and not pushed. -- Standalone `SourceType.Ticker` is addable once per scene, has per-source settings, and shows the - in-memory current + queued alert feed. It does not persist event history. -- Stream Alerts keeps one box per layout; its ticker shares `Ticker` settings but shows only the - active event. Existing per-Source columns persist the settings without a schema migration. -- Focused build succeeded; the new integration test and all `AlertTickerPreviewDisplayTests` pass. +- `main`; scene-local ticker commit `20f0594` is local, not pushed. +- Logs tab work is in progress: non-edit panel has Live Chat first/default and Logs second. Logs + reads only the process-local AppLog buffer (newest 500, newest first); file logging and rotation + remain unchanged, and prior-launch file contents are not loaded. +- The Logs-panel integration test passes; clean app build is 0 warnings and scope check passes. ## Next action -Next queued unit: Day 1 system-message strip; also check the queued Debug-only test-access rule. -Keep one integration test per change. Do not push absent a milestone/checkpoint request. +Run the declared-scope verification and commit the Logs-tab unit. Then resolve the outstanding +UI/content test failures one issue at a time, with a separate local commit for each resolved task. +Do not push absent a milestone/checkpoint request. ## Landmines - Build/test with Windows `dotnet.exe`; the full suite can hang in WASAPI teardown. -- Focused ticker tests pass; clean app build is 0 warnings. Full suite reports 13–14 UI/content - failures (508/521 or 507/521 pass), matching the prior baseline note; none is a ticker test. Their - relationship to this change is unconfirmed, and `verify.sh` currently only allows two other tests. -- The integration assertion is in `ytLive.Tests/AlertTickerPreviewDisplayTests.cs` and must use - `LayoutPathOverride`/clear it in `finally` if it constructs `MainWindow`. -- Scene tickers are dynamic per-element sources; only the AlertBox ticker uses the pump's global - ticker overlay callback. +- Full suite after Logs changes: 13 failed / 509 passed of 522; the same UI/content failures remain + (none in Logs or ticker tests). They are the next queued work, not to be masked or added here. +- Any integration test constructing `MainWindow` must set `LayoutPathOverride` to a temporary DB + and clear it in `finally`; AppLog path overrides are also process-wide and are serialized here. - Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`). diff --git a/Helpers/AppLog.cs b/Helpers/AppLog.cs index 53e8a81..c60bd31 100644 --- a/Helpers/AppLog.cs +++ b/Helpers/AppLog.cs @@ -12,6 +12,7 @@ public static class AppLog { /// The cap for the live log file. Past this, it rolls to startup.1.log. private const long DefaultMaxBytes = 5 * 1024 * 1024; + private const int CurrentLaunchEntryLimit = 500; /// How many rolled copies to keep (startup.1.log, startup.2.log, …). private const int RetainedFiles = 1; @@ -20,6 +21,8 @@ public static class AppLog InstanceProfile.DataRoot, "startup.log"); private static readonly object Gate = new(); + private static readonly List CurrentLaunchEntries = new(); + private static long _nextSequence; /// Test seam: point the log somewhere else. Null = the real profile's log. internal static string? PathOverride { get; set; } @@ -29,16 +32,29 @@ public static class AppLog private static string LogPath => PathOverride ?? DefaultLogPath; + internal static IReadOnlyList SnapshotCurrentLaunch() + { + lock (Gate) + return CurrentLaunchEntries.ToArray(); + } + public static void Write(string message) { try { lock (Gate) { + var entry = new AppLogEntry( + ++_nextSequence, + $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}"); + CurrentLaunchEntries.Add(entry); + if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit) + CurrentLaunchEntries.RemoveAt(0); + var path = LogPath; Directory.CreateDirectory(Path.GetDirectoryName(path)!); LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles); - File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}"); + File.AppendAllText(path, $"{entry.Text}{Environment.NewLine}"); } } catch @@ -52,3 +68,5 @@ public static class AppLog Write($"{message}: {exception}"); } } + +public sealed record AppLogEntry(long Sequence, string Text); diff --git a/Helpers/index.md b/Helpers/index.md index 9fa3204..5fef211 100644 --- a/Helpers/index.md +++ b/Helpers/index.md @@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv |------|---------| | `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty()` — base for all ViewModels | | `RelayCommand.cs` | `ICommand` implementation for all button actions | -| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) | +| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab; it never loads prior-launch file contents | | `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) | | `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard | | `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) | diff --git a/ViewModels/MainViewModel.Logs.cs b/ViewModels/MainViewModel.Logs.cs new file mode 100644 index 0000000..d91c1d7 --- /dev/null +++ b/ViewModels/MainViewModel.Logs.cs @@ -0,0 +1,95 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Threading; +using ytLive.Helpers; + +namespace ytLive.ViewModels; + +public partial class MainViewModel +{ + private const int VisibleLogEntryLimit = 500; + private readonly ObservableCollection _currentLaunchLogEntries = new(); + private DispatcherTimer? _currentLaunchLogTimer; + private long _lastCurrentLaunchLogSequence; + private bool _isLogsTabSelected; + + public ObservableCollection CurrentLaunchLogEntries => _currentLaunchLogEntries; + + public bool IsLiveChatTabSelected + { + get => !_isLogsTabSelected; + set + { + if (value) + SelectLogsTab(false); + else if (!_isLogsTabSelected) + OnPropertyChanged(nameof(IsLiveChatTabSelected)); + } + } + + public bool IsLogsTabSelected + { + get => _isLogsTabSelected; + set + { + if (value) + SelectLogsTab(true); + else if (_isLogsTabSelected) + OnPropertyChanged(nameof(IsLogsTabSelected)); + } + } + + private void InitializeCurrentLaunchLogs() + { + foreach (var entry in AppLog.SnapshotCurrentLaunch() + .OrderByDescending(entry => entry.Sequence) + .Take(VisibleLogEntryLimit)) + _currentLaunchLogEntries.Add(entry); + + _lastCurrentLaunchLogSequence = _currentLaunchLogEntries.FirstOrDefault()?.Sequence ?? 0; + _currentLaunchLogTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; + _currentLaunchLogTimer.Tick += (_, _) => RefreshCurrentLaunchLogs(); + } + + private void SelectLogsTab(bool selected) + { + if (_isLogsTabSelected == selected) + { + OnPropertyChanged(nameof(IsLiveChatTabSelected)); + OnPropertyChanged(nameof(IsLogsTabSelected)); + return; + } + + _isLogsTabSelected = selected; + OnPropertyChanged(nameof(IsLiveChatTabSelected)); + OnPropertyChanged(nameof(IsLogsTabSelected)); + if (selected) + { + RefreshCurrentLaunchLogs(); + _currentLaunchLogTimer?.Start(); + } + else + { + _currentLaunchLogTimer?.Stop(); + } + } + + internal void RefreshCurrentLaunchLogs() + { + var newEntries = AppLog.SnapshotCurrentLaunch() + .Where(entry => entry.Sequence > _lastCurrentLaunchLogSequence) + .OrderBy(entry => entry.Sequence) + .ToArray(); + + foreach (var entry in newEntries) + { + _currentLaunchLogEntries.Insert(0, entry); + if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit) + _currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1); + _lastCurrentLaunchLogSequence = entry.Sequence; + } + } + + private void StopCurrentLaunchLogFeed() + => _currentLaunchLogTimer?.Stop(); +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index b576baa..9d63af4 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -192,6 +192,7 @@ public partial class MainViewModel : ViewModelBase public MainViewModel() { AppLog.Write("MainViewModel ctor begin"); + InitializeCurrentLaunchLogs(); IsAppOwned = _entitlement.IsAppOwned; _youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret, sessionChanged: ch => TokenStore.Save(ch)); @@ -524,6 +525,7 @@ public partial class MainViewModel : ViewModelBase public void Shutdown() { + StopCurrentLaunchLogFeed(); _saveDebounce?.Stop(); SaveLayoutNow(); _fullScreenDetector.StopWatching(); diff --git a/ViewModels/index.md b/ViewModels/index.md index 8ba42f3..ae6f343 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -21,6 +21,7 @@ areas' docs in their own rows keeps this index small to read. | `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). | | `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. Ctor command assignments stay in core. | | `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. | +| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and the bounded current-launch log collection; refreshes the process-local AppLog snapshot while Logs is selected and stops its dispatcher timer on shutdown. | | `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests | | **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. | | `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel | diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 17ef93a..5e2b862 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -103,7 +103,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB **Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer. -- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. +- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only, newest-first view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. - **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer. - **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings. - **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode. diff --git a/ytLive.Tests/AppLogRotationTests.cs b/ytLive.Tests/AppLogRotationTests.cs index 9230a7a..f7252b5 100644 --- a/ytLive.Tests/AppLogRotationTests.cs +++ b/ytLive.Tests/AppLogRotationTests.cs @@ -13,6 +13,7 @@ namespace ytLive.Tests; /// startup.1.log, the live file starts fresh with the newest entry, and the SET of /// files stays bounded — a long stream cannot grow the log without limit. (The old /// behaviour appended forever; the file was 23 MB and growing every few seconds.) +[Collection("RealApp")] public class AppLogRotationTests { [Fact] diff --git a/ytLive.Tests/LogsPanelTests.cs b/ytLive.Tests/LogsPanelTests.cs new file mode 100644 index 0000000..8babbb5 --- /dev/null +++ b/ytLive.Tests/LogsPanelTests.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Linq; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Helpers; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +[Collection("RealApp")] +public sealed class LogsPanelTests +{ + private readonly RealAppHost _app; + + public LogsPanelTests(RealAppHost app) => _app = app; + + [Fact] + public void NonEditPanel_DefaultsToLiveChat_AndShowsCurrentLaunchLogsNewestFirst() + { + var directory = Path.Combine(Path.GetTempPath(), $"ytlive-logs-panel-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var databasePath = Path.Combine(directory, "layout.db"); + var logPath = Path.Combine(directory, "startup.log"); + File.WriteAllText(logPath, "previous-launch-sentinel" + Environment.NewLine); + AppLog.PathOverride = logPath; + MainViewModel.LayoutPathOverride = databasePath; + + try + { + _app.Run(() => + { + var window = new MainWindow(); + try + { + window.Show(); + window.UpdateLayout(); + var vm = (MainViewModel)window.DataContext; + var panel = (UserControl)window.FindName("LeftPanel")!; + var chatTab = (ToggleButton)panel.FindName("LiveChatTabButton")!; + var logsTab = (ToggleButton)panel.FindName("LogsTabButton")!; + var logList = (ListBox)panel.FindName("LogListBox")!; + + Assert.True(vm.IsLiveChatTabSelected); + Assert.True(chatTab.IsChecked); + Assert.False(logsTab.IsChecked); + Assert.Equal(System.Windows.Visibility.Collapsed, logList.Visibility); + + vm.IsLogsTabSelected = true; + window.UpdateLayout(); + Assert.True(logsTab.IsChecked); + Assert.False(chatTab.IsChecked); + Assert.Equal(System.Windows.Visibility.Visible, logList.Visibility); + + AppLog.Write("LogsPanel older marker"); + AppLog.Write("LogsPanel newer marker"); + vm.RefreshCurrentLaunchLogs(); + window.UpdateLayout(); + + var entries = vm.CurrentLaunchLogEntries.Select(entry => entry.Text).ToArray(); + var newerIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel newer marker", StringComparison.Ordinal)); + var olderIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel older marker", StringComparison.Ordinal)); + Assert.True(newerIndex >= 0 && olderIndex >= 0, "new log writes did not reach the Logs tab"); + Assert.True(newerIndex < olderIndex, "the Logs tab must put the newest entry first"); + Assert.DoesNotContain(entries, text => text.Contains("previous-launch-sentinel", StringComparison.Ordinal)); + Assert.Same(vm.CurrentLaunchLogEntries, logList.ItemsSource); + } + finally + { + window.Close(); + } + }); + } + finally + { + MainViewModel.LayoutPathOverride = null; + AppLog.PathOverride = null; + SqliteConnection.ClearAllPools(); + try { Directory.Delete(directory, recursive: true); } catch { } + } + } +}