| name | animation-editor-testing |
| description | Headless AE tests — Core first, [AvaloniaFact] only for real UI. Triggers: AnimationEditor.App.Tests, Core.Tests, TestServices, CreateMainWindow, Browser.Ui. |
Animation Editor — Testing
Headless-test discipline for the Avalonia Animation Editor. Tool layout lives in the animation-editor skill. Browser/WASM smoke lives in animation-editor-browser-verify (do not mirror Core/App suites there).
dotnet test tools/AnimationEditorAvalonia/tests/AnimationEditor.Core.Tests/
dotnet test tools/AnimationEditorAvalonia/tests/AnimationEditor.Views.Tests/
dotnet test tools/AnimationEditorAvalonia/tests/AnimationEditor.App.Tests/
Pick the right layer (do not duplicate)
| Layer | Project | Use when | Do not use for |
|---|
| Core | AnimationEditor.Core.Tests | Commands, undo Descriptions, selection/state, pure logic | Layout, pointer routing, pixels |
| Headless control | AnimationEditor.Views.Tests ([AvaloniaFact]) | A single AnimationEditor.Views control in isolation (e.g. ProjectPanelControl), using FakeFolder/FakeFile doubles — no MainWindow/DI | Cross-control wiring, MainWindow integration, real service graph |
| Headless integration | AnimationEditor.App.Tests ([AvaloniaFact], TestServices) | Desktop visual tree through MainWindow, input routing, real DI-wired services — the bug involves wiring, not just one control | Re-proving Core math or a single control's own logic; Browser/WASM |
| Browser smoke | AnimationEditor.Browser.Ui (Playwright) | Browser-only gaps (WASM boot, Browser host wiring, Debug automation bridge). See that folder’s README | Cloning Core/App/Views tests; primary label gate |
Default: Core [Fact]. Reach for [AvaloniaFact] only when the behavior under test genuinely is UI. Reach for Browser Playwright only when Headless/desktop cannot catch it — a small smoke set, not a 1:1 port.
[AvaloniaFact] is a last resort
[AvaloniaFact] (from Avalonia.Headless.XUnit) runs the test on a headless Avalonia UI thread. It is slow and deadlocks on anything that blocks the UI thread waiting for the UI — a code path reaching Window.ShowDialog hangs forever with nothing to close the dialog. The MainWindow constructor also overwrites injected delegates (AppCommands.ConfirmAsync, PromptStringAsync, FileDialogService), so a stub installed before construction is silently lost.
Logic reachable only by reflecting into a private MainWindow method is a signal to move it into Core, not to write an [AvaloniaFact].
Tests that do need it construct MainWindow and drive it with Dispatcher.UIThread.RunJobs() between actions — see WireframePanZoomTests.cs for the established pattern.
Reflection-invoking a handler proves the logic, not that input reaches it
GetMethod(..., NonPublic).Invoke(window, [sender, fakeArgs]) calls a handler directly, bypassing Avalonia's routed-event dispatch — it proves the handler's body is correct, not that real input ever reaches it. This missed a real bug (#716): OnAnimTreeDoubleTapped was Bubble-registered on AnimTree.DoubleTapped and looked correct under reflection, but a real double-click on a TreeViewItem row never fired it — the control's own Tunnel-phase pointer handling toggles IsExpanded on the second click first, and that native behavior isn't visible from reading the handler's source. The fix had to move into OnTreePointerPressed's existing Tunnel-phase PointerPressed branch instead.
When the question is whether a gesture reaches a handler — not just what it does once it's there — drive it for real: window.MouseDown(point, MouseButton.Left) / MouseUp (twice for a double-click, both from Avalonia.Headless), with point computed from a real control's Bounds via TranslatePoint. Reflection is fine for asserting the body once the real path is confirmed; it does not substitute for confirming that path exists.
The same trap applies to directly assigning ISelectedState properties (ctx.SelectedState.SelectedChain = chain) instead of clicking the tree: a real click also runs MainWindow.OnTreeSelectionChanged, which syncs AnimTree.SelectedItems into SelectedState.SelectedNodes as a side effect — so SelectedChains is never empty after a real click, even a plain single-click single-selection. A follow-up bug on #716 slipped through exactly this way: a direct-assignment test asserted the reveal fired correctly, but a real click left every frame IsSelected=false because the selection-derived SelectedChains (populated only by the real click's SelectedNodes sync, not by direct assignment) fed a branch the direct-assignment test never exercised. When a test's setup is "select this thing," prefer clicking the real TreeViewItem/header label over setting the model field directly — the click's side effects on sibling SelectedState fields are often exactly what's under test.
A third #716 facet: two window.MouseDown/MouseUp pairs at the same point back-to-back register as ClickCount==2 (a double-click), not two independent single clicks — Thread.Sleep past the OS double-click window (or click a different point) between them if the test needs two genuine single clicks on the same target. Separately, any content-based dedup keyed off "did the selected set change" (e.g. comparing frame lists for a reveal-restart check) silently swallows a click that reselects the same already-selected item, since the resulting set is identical — even though the click itself is a real, distinct user action that should still fire whatever "just happened" behavior (a replay, a re-focus). If a feature must fire on every click regardless of whether the underlying model value actually changed, drive it from the click handler directly, not from a diff against previous selection state.
A fourth: window.MouseDown/MouseUp fully pump any Dispatcher.UIThread.InvokeAsync-queued continuation before returning (no RunJobs() needed) — so a test cannot freeze-frame the gap between a synchronous Tunnel-phase click handler and an async SelectionChanged reaction the way a real 60fps render loop can catch mid-flight (exactly the #716 flash: an unconditional reveal-restart call at the click site reset progress on the previous selection's still-rendered frames a beat before the async catch-up moved the highlight). Guard fixes for this class of race by asserting the condition that prevents the early call (e.g. "only replay when the clicked item already equals the current selection"), not by trying to observe the intermediate state headless can't expose. Also: Console.WriteLine from application code is invisible in dotnet test output even on failure; System.IO.File.AppendAllText to a scratch path is the reliable way to trace through a headless run when reasoning alone isn't converging.
A fifth: a TreeViewItem's Bounds spans its own header row plus the rendered content of any expanded children — a folder row with children visible is much taller than one row, so a click point computed at Bounds.Height / 2 lands inside a child row instead of the folder itself (a real click there resolves the wrong node, not a routing failure). Compute the click point from a small fixed Y offset from the top (inside the header's own MinHeight), not the vertical center of the whole container, whenever the target row might have expanded children.
Undo labels vs screenshots
- Correctness of a command's
Description: Core.Tests (CommandDescriptionTests / FeatureDemosTests / BrowserUiDriveLabelTests).
- "Show me the History panel" (desktop): DocScreenshots +
FeatureDemos — animation-editor-screenshots.
- Browser/WASM smoke only: Playwright —
animation-editor-browser-verify + tests/AnimationEditor.Browser.Ui/README.md. Not a substitute for Core asserts.
Never seed History UI models with hand-written strings to "prove" a label.
Service wiring in tests
Services (ProjectManager, SelectedState, AppCommands, AppState, ApplicationEvents, IoManager, ObjectFinder, UndoManager) are constructor-injected — no static Self accessors, no global state. Production wires them through a Microsoft.Extensions.DependencyInjection container in App.axaml.cs.
Tests build their own fresh graph per test via TestHelpers.BuildServices() (App) / TestHelpers.SetupFreshAcls() (Core), which returns a TestServices context exposing every service. Tests then address services through that context (ctx.AppCommands.Foo()) rather than statics. Each test gets a brand-new graph, so cross-test selection leakage is impossible.
When constructing an Avalonia control directly (WireframeControl / PreviewControl) — because Avalonia requires a parameterless constructor for XAML — call ctx.CreateWireframeControl() / ctx.CreatePreviewControl(), which wraps new WireframeControl() and InitializeServices(...) so the control's injected fields are populated before any method runs.
Assign ProjectManager.AnimationChainListSave after window.Show() (+ a RunJobs()), not before: MainWindow.OnOpened resets it to a fresh empty AnimationChainListSave when there's no CLI file / saved tabs, silently discarding a project set before the window opened. A pre-Show assignment leaves your chain orphaned, so SelectedState.SelectedFrame's parent-chain lookup (FindChainForFrame) returns null and SelectedChain comes back null even though the frame is in the chain. Selecting only SelectedChain masks this (it doesn't consult the project). Assigning the project repopulates the model but not the tree view; for tree-dependent behavior (selection routing, the SyncTreeSelection→OnTreeSelectionChanged re-entrancy) reflect-invoke the private RebuildTreeView afterward — see TimelineStripRebuildTests.
Names inside an extracted UserControl are invisible to window.FindControl
A UserControl (e.g. ZoomControl) defines its own namescope, so window.FindControl<T>("Combo") — from a test or from MainWindow — cannot resolve a control named inside it; only names in the same namescope resolve. Test an extracted control through its public surface: fetch the control itself (window.FindControl<ZoomControl>("PngZoom")) and assert on public members it exposes (ZoomControl.Text, .StepUp(), .StepDown()). This is the tax on pulling a widget into a reusable control — any test that reached it by an inner element's name must re-target the wrapper's public API.
The update check is one mechanism now, not two — test it as IApplicationUpdater
Both the persistent startup banner and the About dialog's "Check for Updates"/"Restart Now" (issue #1033) drive the exact same IApplicationUpdater.DownloadUpdateAsync (AnimationEditor.App/Services/VelopackApplicationUpdater.cs) through MainWindow.RunApplicationUpdateCheckAsync. There is no separate GitHub-release-comparison path to force a fake "update available" state via a -p:Version override anymore (that whole subsystem — UpdateChecker/UpdateCheckResult/UpdateCheckPolicy — was deleted as a duplicate implementation). Cover NoUpdate/ReadyToRestart/Failed states headlessly with FakeApplicationUpdater (AnimationEditor.App.Tests/TestHelpers.cs) — see UpdateCheckStartupTests.cs and AboutDialogTests.cs for the pattern. To exercise the real Velopack path end-to-end against a local release feed instead of GitHub, point ANIMATION_EDITOR_TEST_UPDATE_SOURCE (Debug builds only, see ApplicationUpdateSource.ForCurrentBuild) at one; building that feed is a vpk/Velopack releasify step outside this skill's scope.
A test .achx opened through the real load path needs CoordinateType = Pixel
AnimationChainListSave's default CoordinateType is the legacy UV format. A fixture written with the default and then opened via OpenFileAsTab/LoadAnimationFileAsync (not just scanned into ProjectPanel.TreeRoots) routes through AppCommands.OpenAchxWorkflowAsync's UvLoadGate, which calls ConfirmAsync to ask about converting to pixel coordinates. Unless the test stubbed ConfirmAsync, this hits the real Avalonia dialog and hangs forever — indistinguishable from a genuine deadlock. Always set CoordinateType = TextureCoordinateType.Pixel on fixtures that get opened through the real load path (a fixture only ever scanned into the Project tree doesn't need it).
For an unexplained hang, dotnet test ... --blame-hang-timeout 45s kills and reports after 45s instead of waiting indefinitely — much cheaper than guessing.
Tests must never write the developer's real settings
MainWindow persists app settings (recent files, open tabs, theme) to %APPDATA%\AnimationEditor\AESettings.json in its Closed handler. A headless test that constructs and closes a window would otherwise overwrite the developer's real settings with test fixtures. The application-data root is a MainWindow constructor parameter precisely so tests can redirect it: ctx.CreateMainWindow() passes ctx.SettingsRoot (a unique temp dir), while production (App.axaml.cs) passes Environment.GetFolderPath(SpecialFolder.ApplicationData). Build the window through ctx.CreateMainWindow() — never reconstruct one with the production root in a test. General rule: any component that reads or writes a real per-user location (config, registry, recent-files) takes its root as an injected dependency, so tests land in temp and never on real user data.