diff --git a/ViewModels/MainViewModel.Socials.cs b/ViewModels/MainViewModel.Socials.cs index b713db3..5a9955c 100644 --- a/ViewModels/MainViewModel.Socials.cs +++ b/ViewModels/MainViewModel.Socials.cs @@ -149,7 +149,11 @@ public partial class MainViewModel _socials); // Live preview: the drawer edits the REAL config, so colour/opacity/shadow land on the // preview immediately instead of waiting for Save. Cancel restores the snapshot below. - _socialsBeforeEdit = _socials; + // Snapshot the VALUES, not the object: the drawer edits the live config in place for the + // preview, so holding the reference would "restore" the very object that was mutated. + _appearanceBeforeEdit = _socials is null ? null : ( + _socials.BarEnabled, _socials.BarTextColor, _socials.BarBackgroundEnabled, + _socials.BarBackgroundColor, _socials.BarBackgroundOpacity, _socials.BarTextShadow); dialog.PropertyChanged += (_, e) => { if (e.PropertyName is null) return; @@ -164,9 +168,17 @@ public partial class MainViewModel }; dialog.CancelRequested += () => { - _socials = _socialsBeforeEdit; - NotifySocialsChanged(); - OnPropertyChanged(nameof(SocialBarVisible)); + if (_socials is { } live && _appearanceBeforeEdit is { } before) + { + live.BarEnabled = before.BarEnabled; + live.BarTextColor = before.TextColor; + live.BarBackgroundEnabled = before.BackgroundEnabled; + live.BarBackgroundColor = before.BackgroundColor; + live.BarBackgroundOpacity = before.BackgroundOpacity; + live.BarTextShadow = before.TextShadow; + NotifySocialsChanged(); + OnPropertyChanged(nameof(SocialBarVisible)); + } CloseSocialsDrawer(); }; SocialsDrawer = dialog; @@ -178,7 +190,8 @@ public partial class MainViewModel OnPropertyChanged(nameof(SocialsDrawerOpen)); } - private SocialsConfig? _socialsBeforeEdit; + private (bool BarEnabled, string TextColor, bool BackgroundEnabled, string BackgroundColor, + double BackgroundOpacity, bool TextShadow)? _appearanceBeforeEdit; private static bool IsAppearanceProperty(string name) => name is nameof(SocialsDialogViewModel.BarEnabled) or nameof(SocialsDialogViewModel.BarTextColor) diff --git a/ytLive.Tests/SocialsPreviewTests.cs b/ytLive.Tests/SocialsPreviewTests.cs new file mode 100644 index 0000000..2802293 --- /dev/null +++ b/ytLive.Tests/SocialsPreviewTests.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Effects; +using Microsoft.Data.Sqlite; +using Xunit; +using Xunit.Sdk; +using ytLive.Controls; +using ytLive.Models; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// The hole that let two regressions reach the creator on 2026-10-30: the Socials drawer's +/// appearance controls were tested against the RENDERER, while the preview is its own XAML tree — +/// so a control could paint nothing, or hide the bar entirely, with every test green. +/// +/// This drives the real drawer on a real MainWindow and reads the real preview elements: +/// the bar stays visible when the plate is off, the text colour reaches the glyphs, the plate +/// carries its colour and opacity, the shadow switches on, and Cancel restores the pre-edit +/// look. +[Collection("RealApp")] +public class SocialsPreviewTests +{ + private readonly RealAppHost _app; + + public SocialsPreviewTests(RealAppHost app) => _app = app; + + [Fact] + public void The_preview_bar_follows_the_drawer_controls() => _app.Run(Run); + + private static void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-socialspreview-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + MainWindow? window = null; + + try + { + var seeded = new SocialsConfig { BarEnabled = true }; + seeded.Entries.Add(new SocialEntry + { + Service = SocialService.YouTube, Handle = "creator", + ProfileUrl = "https://youtube.com/@creator", + }); + using (var store = new LayoutStore(tempDb)) + store.Save(Array.Empty(), null, seeded); + SqliteConnection.ClearAllPools(); + + window = new MainWindow(); + var vm = (MainViewModel)window.DataContext!; + window.Show(); + window.UpdateLayout(); + + var preview = (PreviewPane)window.FindName("PreviewPane")!; + var bar = (FrameworkElement)preview.FindName("SocialBarElement")!; + var plate = FindChild(bar)!; + var label = FindChild(bar)!; + + Assert.Equal(Visibility.Visible, bar.Visibility); + + // 1. Drawer edits land on the preview live — no Save needed. + vm.OpenSocialDialogCommand.Execute(null); + var drawer = vm.SocialsDrawer!; + drawer.BarTextColor = "#FF0000"; + window.UpdateLayout(); + Assert.Equal(Colors.Red, ((SolidColorBrush)label.Foreground).Color); + + // 2. Plate off (the default) = transparent plate, NOT a hidden bar. + Assert.Equal(0, ((SolidColorBrush)plate.Background).Color.A); + Assert.Equal(Visibility.Visible, bar.Visibility); + + drawer.BarBackgroundEnabled = true; + drawer.BarBackgroundColor = "#0000FF"; + drawer.BarBackgroundOpacity = 0.5; + window.UpdateLayout(); + var bg = ((SolidColorBrush)plate.Background).Color; + Assert.Equal(Colors.Blue, Color.FromRgb(bg.R, bg.G, bg.B)); + Assert.InRange(bg.A, 120, 135); // 50% of 255 + Assert.Equal(new Thickness(18, 6, 18, 6), plate.Padding); + + // 3. The shadow is a real effect on the glyph, and off by default. + Assert.Null(label.Effect); + drawer.BarTextShadow = true; + window.UpdateLayout(); + Assert.IsType(label.Effect); + + // 4. Cancel restores the pre-edit look (white text, no plate). + drawer.CancelCommand.Execute(null); + window.UpdateLayout(); + Assert.Equal(Colors.White, ((SolidColorBrush)label.Foreground).Color); + Assert.Equal(0, ((SolidColorBrush)plate.Background).Color.A); + } + catch (Exception ex) + { + throw new XunitException(ex.ToString()); // the host otherwise hides the real stack + } + finally + { + window?.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort */ } + } + } + + private static T? FindChild(DependencyObject root) where T : DependencyObject + { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (child is T match) return match; + var deeper = FindChild(child); + if (deeper != null) return deeper; + } + return null; + } +}