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
256 lines
13 KiB
C#
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);
|
|
}
|
|
}
|