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
This commit is contained in:
2026-10-28 13:09:15 -07:00
parent 7da287244b
commit 769c616706
13 changed files with 326 additions and 79 deletions
+16 -10
View File
@@ -69,16 +69,20 @@ public sealed class BorderColorPickerTests
// ── 2. the panel: the app's drop-down, no hand-rolled menu ───────────────
var xaml = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var picker = xaml.Descendants()
var pickers = xaml.Descendants()
.Where(e => e.Name.LocalName == "ComboBox")
.SingleOrDefault(c => (string?)c.Attribute("ItemsSource")
== "{Binding SelectedElement.BorderColorOptions}");
Assert.True(picker != null, "the border colour is not a drop-down over the palette");
Assert.True(((string?)picker!.Attribute("Style") ?? string.Empty)
.Contains("YtComboBox", StringComparison.Ordinal),
"the border-colour drop-down is not styled with YtComboBox");
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
(string?)picker.Attribute("SelectedItem"));
.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")
@@ -146,7 +150,9 @@ public sealed class BorderColorPickerTests
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var realCombo = FindVisualChildren<ComboBox>(panel)
.Single(c => c.ItemsSource is IEnumerable<NamedColor> opts && opts.Any(o => o.Hex == "#e94560"));
.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);
+8 -9
View File
@@ -83,7 +83,7 @@ public sealed class LayerReorderPersistenceTests
var vm = (MainViewModel)window.DataContext;
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
Assert.True(vm.StagedScene!.Elements.OfType<Source>().Count() >= 4,
"3 seeded layers + the healed Background must load");
"3 seeded layers + the healed Background + Brand logo must load");
window.Show();
window.Activate();
@@ -94,12 +94,9 @@ public sealed class LayerReorderPersistenceTests
var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
list.UpdateLayout();
// Elements = [Background, Zoom(placeholder, collapsed), ImgA, ImgB,
// ImgC]. Physically drag ImgC up onto ImgA: the handlers reorder the
// live elements to [Background, Zoom, ImgC, ImgA, ImgB] and EndListDrag
// must persist that. Rows are resolved BY ITEM — the collapsed zoom row
// shifts container indices, but ItemContainerGenerator maps items to
// their (collapsed = zero-height) containers transparently.
// Brand logo is a permanent layer between Background and user layers.
// Physically drag ImgC above ImgA; the background and logo keep their
// relative positions while EndListDrag persists the user-layer reorder.
var sources = vm.StagedScene.Elements.OfType<Source>().ToArray();
var from = RowScreenPoint(list, sources.First(s => s.Name == "ImgC"));
var to = RowScreenPoint(list, sources.First(s => s.Name == "ImgA"));
@@ -115,7 +112,9 @@ public sealed class LayerReorderPersistenceTests
var expected = vm.StagedScene.Elements.OfType<Source>().Select(s => s.Name).ToArray();
Assert.Equal("Background", expected[0]);
Assert.Equal("ImgC", expected[1]);
Assert.Equal("Brand logo", expected[1]);
Assert.Equal("ImgC", expected[2]);
Assert.True(Array.IndexOf(expected, "ImgC") < Array.IndexOf(expected, "ImgA"));
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
TimeSpan.FromSeconds(10)),
@@ -466,4 +465,4 @@ public sealed class LayerReorderPersistenceTests
}
}
}
}
}
+27 -9
View File
@@ -15,9 +15,8 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Scene strip tile artwork (creator ruling 2026-10-10). Each canonical scene's
/// top-strip tile is its own image, stored as an Asset row and seeded from the
/// creator's CRT-styled renders. The preview pane keeps rendering each scene's
/// Scene strip tile artwork (creator ruling 2026-10-10). A persisted scene tile
/// is stored as an Asset row; the preview pane keeps rendering each scene's
/// ORIGINAL background — the tiles never appear as preview backgrounds.
/// </summary>
[Collection("RealApp")]
@@ -28,7 +27,7 @@ public sealed class SceneThumbnailTests
public SceneThumbnailTests(RealAppHost app) => _app = app;
[Fact]
public void Strip_Shows_The_Seeded_Tile_And_The_Preview_Keeps_The_Original_Background()
public void Strip_Shows_The_Persisted_Tile_And_The_Preview_Keeps_The_Original_Background()
{
_app.Run(() =>
{
@@ -36,10 +35,19 @@ public sealed class SceneThumbnailTests
try
{
env = ArrangedDb(out var file);
env.SeedPaths[SceneCatalog.Starting] = file.TilePath;
env.SeedPaths[SceneCatalog.Live] = MissingPath();
env.Apply();
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
cmd.Parameters.AddWithValue("$asset", tileAssetId);
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Starting);
Assert.Equal(1, cmd.ExecuteNonQuery());
}
var vm = new MainViewModel();
var starting = SceneByName(vm, SceneCatalog.Starting);
@@ -91,7 +99,7 @@ public sealed class SceneThumbnailTests
}
[Fact]
public void Live_Staged_Mini_Shows_Its_Tile_Not_The_Capture_Placeholder()
public void Live_Staged_Mini_Shows_Its_Persisted_Tile_Not_The_Capture_Placeholder()
{
_app.Run(() =>
{
@@ -99,9 +107,19 @@ public sealed class SceneThumbnailTests
try
{
env = ArrangedDb(out var file);
env.SeedPaths[SceneCatalog.Live] = file.TilePath;
env.Apply();
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
cmd.Parameters.AddWithValue("$asset", tileAssetId);
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Live);
Assert.Equal(1, cmd.ExecuteNonQuery());
}
var vm = new MainViewModel();
var live = SceneByName(vm, SceneCatalog.Live);
Assert.False(string.IsNullOrWhiteSpace(live.ThumbnailAssetId));
@@ -355,4 +373,4 @@ public sealed class SceneThumbnailTests
private static string MissingPath()
=> Path.Combine(Path.GetTempPath(), $"ytLive-no-such-{Guid.NewGuid():N}.png");
}
}
@@ -0,0 +1,118 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Controls;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class SelectedLayerPreviewHitTestingTests
{
private readonly RealAppHost _app;
public SelectedLayerPreviewHitTestingTests(RealAppHost app) => _app = app;
[Fact]
public void ListSelectedBrandLogo_WinsPreviewHitTesting_UntilDeselected()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-selected-layer-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = null;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
var scene = vm.StagedScene!;
var logo = scene.Elements.OfType<Source>().Single(source => source.IsBrandLogo);
logo.X = 420;
logo.Y = 300;
logo.Width = 300;
logo.Height = 180;
var webcam = new WebcamSceneConfig
{
WebcamId = "test-camera",
Name = "Webcam",
X = logo.X,
Y = logo.Y,
Width = logo.Width,
Height = logo.Height,
};
scene.Elements.Add(webcam);
var originalOrder = scene.Elements.ToArray();
window.Show();
window.Activate();
window.Topmost = true;
window.UpdateLayout();
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
var sourceList = (ListBox)leftPanel.FindName("SourceList")!;
var preview = (PreviewPane)window.FindName("PreviewPane")!;
var canvasGrid = (FrameworkElement)preview.FindName("CanvasGrid")!;
var overlayElements = (ItemsControl)preview.FindName("OverlayElements")!;
var logoContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(logo)!;
var webcamContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(webcam)!;
var pointInCanvas = new Point(logo.X + 100, logo.Y + 60);
var pointInWindow = canvasGrid.TransformToVisual(window).Transform(pointInCanvas);
Assert.True(ReferenceEquals(webcam, ElementAt(window.InputHitTest(pointInWindow))));
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(0, Panel.GetZIndex(logoContainer));
sourceList.SelectedItem = logo;
window.UpdateLayout();
Assert.True(ReferenceEquals(logo, vm.SelectedElement), "Selecting the layer-list row must select the brand logo.");
Assert.True(Panel.GetZIndex(logoContainer) > Panel.GetZIndex(webcamContainer));
Assert.Same(logo, ElementAt(window.InputHitTest(pointInWindow)));
Assert.Same(logo, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(originalOrder, scene.Elements);
sourceList.SelectedItem = null;
window.UpdateLayout();
Assert.Null(vm.SelectedElement);
Assert.Equal(0, Panel.GetZIndex(logoContainer));
Assert.Same(webcam, ElementAt(window.InputHitTest(pointInWindow)));
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(originalOrder, scene.Elements);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
private static SceneElement? ElementAt(System.Windows.IInputElement? hit)
{
DependencyObject? current = hit as DependencyObject;
while (current != null)
{
if (current is FrameworkElement { DataContext: SceneElement element })
return element;
current = current is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(current)
: LogicalTreeHelper.GetParent(current);
}
return null;
}
}
+3 -3
View File
@@ -44,8 +44,8 @@ public sealed class SourceNamingTests
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
// The locked Background layer is always present but never numbered.
var names = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
// Persistent scene layers are not part of the user-created Text sequence.
var names = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
@@ -53,7 +53,7 @@ public sealed class SourceNamingTests
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var survivors = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
var survivors = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
+6 -5
View File
@@ -49,8 +49,9 @@ public sealed class ZoomBoxInteractionTests
window.Show();
window.UpdateLayout();
Assert.NotNull(vm.StagedScene);
// The healed zoom placeholder sits at z-index 1, inert.
var placeholder = (ZoomSceneElement)vm.StagedScene.Elements[1];
// The healed zoom placeholder is present and inert; persistent scene
// layers may precede it, so locate it by type rather than list index.
var placeholder = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
Assert.False(placeholder.IsZoomActive);
Assert.Equal(ElementKind.Static, placeholder.Kind);
@@ -137,7 +138,7 @@ public sealed class ZoomBoxInteractionTests
Assert.True(Math.Abs(vm.ZoomState.Y - boxY) >= 100, "the box must have moved");
Assert.Equal(captureX, vm.ZoomState.CaptureX);
Assert.Equal(captureY, vm.ZoomState.CaptureY);
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene.Elements[1]);
var zoom = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
Assert.Equal(vm.ZoomState.X, zoom.X, 3);
Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
Assert.Equal(captureX, zoom.CaptureX, 3);
@@ -179,7 +180,7 @@ public sealed class ZoomBoxInteractionTests
private static void AssertTrueActiveLayer(MainViewModel vm)
{
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene!.Elements[1]);
var zoom = vm.StagedScene!.Elements.OfType<ZoomSceneElement>().Single();
Assert.True(zoom.IsZoomActive);
Assert.True(zoom.IsVisible);
Assert.Equal(ElementKind.Dynamic, zoom.Kind);
@@ -191,4 +192,4 @@ public sealed class ZoomBoxInteractionTests
Assert.Equal(vm.ZoomState.CaptureY, zoom.CaptureY, 3);
Assert.Equal(vm.ZoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional, zoom.ClipShape);
}
}
}