TASK 7 UI polish batch: scene/source row cleanup, dedup naming, full social handles — scenes are now pure selection rows (edit/trash/visibility icons and inline rename removed with EditSceneCommand/RemoveSceneCommand/ToggleSceneVisibilityCommand + Scene.IsEditing); source rows gained the trio (new EditElementCommand → inline rename via SceneElement.IsEditing, ToggleElementVisibilityCommand → eye flips IsVisible, open/slashed style rebound, hidden rows dim to 45%); duplicate resource names get a no-space incrementing suffix via shared NextSourceName (Image, Image2, Image3…) derived from actual names so deletions never collide (AddSource + AddReusedImage); social bar renders the full validated handle (MaxWidth=200 + TextTrimming removed from SocialBarRenderer and the preview template); side panels stay fixed 220/300; focus-loss capture lag documented in ai.md as a known OS limit (deferred) — the two real-App tests now share RealAppHost, a dedicated STA thread owning the single WPF App, instead of each calling new App(); new SourceNamingTests integration test (Text/Text2/Text3, delete-middle re-add no-collision) — 170 tests passing, 0 warnings

This commit is contained in:
2026-08-13 18:50:56 -07:00
parent 8292663791
commit d90b5ded0d
13 changed files with 333 additions and 174 deletions
+83
View File
@@ -0,0 +1,83 @@
using System;
using System.Threading;
using System.Windows.Threading;
using Xunit;
namespace ytLive.Tests;
/// <summary>
/// Tests that spin up the real WPF App need exactly one Application instance per
/// AppDomain (WPF enforces it) — so they share this serial collection and a
/// single App created on one dedicated STA thread. Marshal test bodies onto it
/// via <see cref="RealAppHost.Run"/>, never `new App()` per test.
/// </summary>
[CollectionDefinition("RealApp", DisableParallelization = true)]
public sealed class RealAppCollection : ICollectionFixture<RealAppHost>
{
}
/// <summary>Owns the one-and-only WPF App on a dedicated STA thread.</summary>
public sealed class RealAppHost : IDisposable
{
private readonly Thread _thread;
private readonly Dispatcher _dispatcher;
public RealAppHost()
{
Exception? boot = null;
Dispatcher? dispatcher = null;
_thread = new Thread(() =>
{
try
{
var app = new App();
app.InitializeComponent();
dispatcher = Dispatcher.CurrentDispatcher;
Dispatcher.Run();
}
catch (Exception ex)
{
boot = ex;
}
});
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
while (dispatcher == null && boot == null)
Thread.Sleep(5);
if (boot != null)
throw new Xunit.Sdk.XunitException("Real WPF App failed to start: " + boot);
_dispatcher = dispatcher!;
}
/// <summary>Runs <paramref name="action"/> on the App's STA thread and rethrows any failure.</summary>
public void Run(Action action)
{
Exception? failure = null;
_dispatcher.Invoke(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
});
if (failure != null)
throw failure;
}
public void Dispose()
{
try
{
_dispatcher.InvokeShutdown();
}
catch
{
// The App never ran its message loop — shutdown is best-effort.
}
_thread.Join(2000);
}
}
+9 -21
View File
@@ -18,36 +18,24 @@ namespace ytLive.Tests;
/// (doesn't fall through and deselect the source) and whether the round clip
/// renders as a circle rather than an oval.
/// </summary>
[Collection("RealApp")]
public sealed class RoundClipInteractionTests
{
private readonly RealAppHost _app;
public RoundClipInteractionTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
Run();
}
catch (Exception ex)
{
failure = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (failure != null)
throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure);
_app.Run(Run);
}
private void Run()
{
var app = new App();
app.InitializeComponent();
// Never let the real MainWindow read/write the user's actual layout DB —
// Shutdown() saves the layout, which would persist these test sources
// over the real ones. Point it at a throwaway temp DB instead.
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.Linq;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Duplicate source names get an incrementing suffix with no space (Image,
/// Image2, Image3…) and the next free number is derived from the names actually
/// in the scene — deleting a middle resource never collides with a survivor.
/// Drives the real VM through the Add Source command (TextOverlay needs no
/// dialog), the same real-App + temp-DB pattern as the round-clip test.
/// </summary>
[Collection("RealApp")]
public sealed class SourceNamingTests
{
private readonly RealAppHost _app;
public SourceNamingTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void Duplicate_Sources_Get_Next_Free_Numbered_Name()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-srcname-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
var scene = vm.ActiveScene!;
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var names = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
var middle = scene.Elements.OfType<Source>().Single(s => s.Name == "Text2");
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var survivors = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}