Files
LlamaCasty/ytLive.Tests/BorderColorPickerTests.cs
T
gramps 769c616706 Preview: promote selected layer for hit testing
Temporarily raise the selected preview item and prioritize it in the custom hit test without changing scene order. Refresh integration-test expectations for Brand logo layers and explicit thumbnail assets.

WPF references: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0 https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer
2026-10-28 13:09:15 -07:00

256 lines
13 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Threading;
using System.Xml.Linq;
using Xunit;
using ytLive.Controls;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>TASK 63 — the creator (2026-10-21): *"why does the drop down under the web cam
/// properties not show the same style for the border color drop down list box?"* — and the
/// reason behind it: *"one of the first things we did was define branding. We established
/// look-and-feel standards. And yet you keep ignoring these and I'm wondering why."*
///
/// <para>The border colour was a hand-rolled <c>...</c> button that built a
/// <c>ContextMenu</c> in code — never a ComboBox, so the TASK 58 sweep (which only covered
/// ComboBox/CheckBox/TextBox) never reached it. This guard holds the fix in place, in three
/// parts:</para>
///
/// <para><b>The model</b> — the border colour is a palette entry the drop-down can select by
/// VALUE (carrying the colour's name), and a custom hex the creator typed is still offered.</para>
///
/// <para><b>The panel</b> — the picker IS the app's own <c>YtComboBox</c>, the widget every
/// other drop-down wears, and the hand-rolled menu is gone from both the XAML and the
/// code-behind. The hex field stays, so a colour outside the palette can still be typed.</para>
///
/// <para><b>The widget</b> — with the app's real theme style and the panel's real templates,
/// the CLOSED box shows the colour's name and the drop-down ROWS show the hex AND the name.
/// (WPF copies <c>ItemTemplate</c> into the read-only <c>SelectionBoxItemTemplate</c>, so the
/// two can only differ through <see cref="ClosedBoxTemplateSelector"/>.)</para></summary>
[Collection("RealApp")]
public sealed class BorderColorPickerTests
{
private readonly RealAppHost _app;
public BorderColorPickerTests(RealAppHost app) => _app = app;
[Fact]
public void The_border_colour_picker_is_the_app_drop_down()
{
// ── 1. the model: the palette entry, and the custom hex ──────────────────
var element = new WebcamSceneConfig { Name = "Webcam" };
var raised = new List<string?>();
element.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
element.BorderColor = "#e94560";
Assert.Equal("Crimson", element.BorderColorEntry.Name);
Assert.Equal("#e94560", element.BorderColorEntry.Hex);
Assert.Contains("BorderColorEntry", raised);
Assert.Contains("BorderColorOptions", raised);
element.BorderColorEntry = BorderColorPalette.All[0]; // picking a row
Assert.Equal("#FFFFFF", element.BorderColor);
element.BorderColor = "#123456"; // a typed hex
Assert.Equal(string.Empty, element.BorderColorEntry.Name); // no name to show
Assert.Equal("#123456", element.BorderColorEntry.Hex);
Assert.Contains(element.BorderColorOptions, o => o.Hex == "#123456");
element.BorderColor = string.Empty; // no colour yet
Assert.Equal(BorderColorPalette.All.Count, element.BorderColorOptions.Count);
Assert.DoesNotContain(element.BorderColorOptions, o => string.IsNullOrEmpty(o.Hex));
// ── 2. the panel: the app's drop-down, no hand-rolled menu ───────────────
var xaml = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var pickers = xaml.Descendants()
.Where(e => e.Name.LocalName == "ComboBox")
.Where(c => (string?)c.Attribute("ItemsSource")
== "{Binding SelectedElement.BorderColorOptions}")
.ToArray();
Assert.NotEmpty(pickers);
foreach (var picker in pickers)
{
Assert.True(((string?)picker.Attribute("Style") ?? string.Empty)
.Contains("YtComboBox", StringComparison.Ordinal),
"a border-colour drop-down is not styled with YtComboBox");
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
(string?)picker.Attribute("SelectedItem"));
}
var hexField = xaml.Descendants()
.Where(e => e.Name.LocalName == "TextBox")
.Any(t => (string?)t.Attribute("Text")
== "{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}");
Assert.True(hexField, "the hex field is gone — a colour outside the palette can no longer be typed");
var panelSource = SourceAssert.Text("Controls", "LeftPanel.xaml");
var codeBehind = SourceAssert.Text("Controls", "LeftPanel.xaml.cs");
Assert.False(panelSource.Contains("WebcamBorderColorPicker_Click", StringComparison.Ordinal),
"the hand-rolled colour menu is back in the panel");
Assert.False(codeBehind.Contains("WebcamBorderColorPicker_Click", StringComparison.Ordinal),
"the hand-rolled colour menu is back in the code-behind");
Assert.False(codeBehind.Contains("ColorConverter.ConvertFromString", StringComparison.Ordinal),
"a colour is hand-painted in code — the theme paints it");
// ── 3. the widget: the closed box names the colour, the rows show hex + name ──
element.BorderColor = "#FFFFFF";
_app.Run(() =>
{
var panel = new LeftPanel();
var selector = panel.Resources["BorderColorTemplates"] as ClosedBoxTemplateSelector;
Assert.True(selector != null, "the panel carries no colour templates");
var combo = new ComboBox
{
Style = (Style)Application.Current.Resources["YtComboBox"],
ItemTemplateSelector = selector,
ItemsSource = element.BorderColorOptions,
SelectedItem = element.BorderColorEntry,
};
var window = new Window { Width = 420, Height = 240, Content = combo };
window.Show();
combo.UpdateLayout();
var closed = Texts(combo);
Assert.True(closed.Contains("White", StringComparison.Ordinal),
$"the closed box does not name the colour: '{closed}'");
Assert.False(closed.Contains("#FFFFFF", StringComparison.Ordinal),
$"the closed box repeats the hex the field already shows: '{closed}'");
combo.IsDropDownOpen = true;
combo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var popup = combo.Template.FindName("PART_Popup", combo) as Popup;
var rows = popup?.Child == null ? string.Empty : Texts(popup.Child);
Assert.True(rows.Contains("#FFFFFF", StringComparison.Ordinal),
$"the drop-down rows do not show the hex: '{rows}'");
Assert.True(rows.Contains("White", StringComparison.Ordinal),
$"the drop-down rows do not show the colour's name: '{rows}'");
window.Close();
// ── 4. clicking a row inside the open popup stays inside LeftPanel ───────
// MainWindow_PreviewMouseLeftButtonDown deselects SelectedElement (exiting
// the properties listing) whenever LeftPanel.IsClickInside(e.OriginalSource)
// returns false. A ComboBox's PART_Popup lives in its own PopupRoot visual
// tree whose VisualTreeHelper.GetParent is null — the only link back to the
// ComboBox and ElementsScrollViewer is PopupRoot's LOGICAL parent.
panel.DataContext = new PanelContext { SelectedElement = element };
var hostWindow = new Window { Width = 320, Height = 720, Content = panel };
hostWindow.Show();
panel.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var realCombo = FindVisualChildren<ComboBox>(panel)
.Single(c => c.IsVisible
&& c.ItemsSource is IEnumerable<NamedColor> opts
&& opts.Any(o => o.Hex == "#e94560"));
realCombo.IsDropDownOpen = true;
realCombo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var crimsonEntry = BorderColorPalette.EntryFor("#e94560");
var crimsonItem = realCombo.ItemContainerGenerator.ContainerFromItem(crimsonEntry) as ComboBoxItem;
Assert.NotNull(crimsonItem);
// The click target inside the popup row (e.g. a TextBlock inside the item)
// must be recognised as inside LeftPanel, or MainWindow clears SelectedElement.
var clickTarget = FindVisualChildren<TextBlock>(crimsonItem!).FirstOrDefault()
?? (DependencyObject)crimsonItem!;
Assert.True(panel.IsClickInside(clickTarget),
"clicking a colour row in the open ComboBox popup is treated as a click outside LeftPanel (exits properties)");
// Clicking the row in the open popup updates the webcam's BorderColor and closes the popup.
crimsonItem!.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonDownEvent,
Source = clickTarget,
});
crimsonItem.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonUpEvent,
Source = clickTarget,
});
realCombo.UpdateLayout();
Assert.Equal("#e94560", element.BorderColor);
Assert.Equal(crimsonEntry, realCombo.SelectedItem);
Assert.Contains("Crimson", Texts(realCombo));
Assert.False(realCombo.IsDropDownOpen);
// And when starting from a typed custom hex (#123456), picking a palette
// row in the open popup still lands cleanly on that palette colour.
element.BorderColor = "#123456";
realCombo.IsDropDownOpen = true;
realCombo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var emeraldEntry = BorderColorPalette.EntryFor("#22c55e");
var emeraldItem = realCombo.ItemContainerGenerator.ContainerFromItem(emeraldEntry) as ComboBoxItem;
Assert.NotNull(emeraldItem);
var emeraldTarget = FindVisualChildren<TextBlock>(emeraldItem!).FirstOrDefault()
?? (DependencyObject)emeraldItem!;
Assert.True(panel.IsClickInside(emeraldTarget));
emeraldItem!.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonDownEvent,
Source = emeraldTarget,
});
emeraldItem.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonUpEvent,
Source = emeraldTarget,
});
realCombo.UpdateLayout();
Assert.Equal("#22c55e", element.BorderColor);
Assert.Equal(emeraldEntry, realCombo.SelectedItem);
Assert.Contains("Emerald", Texts(realCombo));
hostWindow.Close();
});
}
private sealed class PanelContext
{
public SceneElement? SelectedElement { get; set; }
public bool IsWebcamSelected => true;
public bool CanToggleClipShape => true;
}
private static IEnumerable<T> FindVisualChildren<T>(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) yield return match;
foreach (var nested in FindVisualChildren<T>(child))
yield return nested;
}
}
/// <summary>Every text in a visual tree, so a template's content can be read back.</summary>
private static string Texts(DependencyObject root)
{
var texts = new List<string>();
void Walk(DependencyObject node)
{
if (node is TextBlock block) texts.Add(block.Text);
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(node); i++)
Walk(VisualTreeHelper.GetChild(node, i));
}
Walk(root);
return string.Join("|", texts);
}
}