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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user