merge: Campaign LA gate round — headless MainWindow view tests close #399
Falsification-proven: 12/12 fail against AvaloniaXamlLoader.Load (the #398 crash shape), 12/12 pass against InitializeComponent. Launcher tests 66/66 on Windows and native Ubuntu, no display required. xunit -> xunit.v3 in the launcher test project (required by Avalonia.Headless.XUnit 12.1.1 net10.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
fb64af8b25
6 changed files with 478 additions and 11 deletions
|
|
@ -26,7 +26,8 @@ What does NOT go here:
|
|||
|
||||
## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** DONE (this commit, Campaign LA UI-test slice) — closed via
|
||||
`tests/AcDream.Launcher.Tests/MainWindowViewTests.cs`.
|
||||
**Severity:** HIGH (process class: this gap let #398 — a crash on every
|
||||
modal open/close — pass 14,012 green tests and reach the user gate)
|
||||
**Filed:** 2026-08-15 (found while launching the launcher for the LA11 gate)
|
||||
|
|
@ -38,14 +39,50 @@ view. `LauncherWindowViewModelTests` proved the modal state machine while
|
|||
the code-behind that consumes it was never executed once, which is exactly
|
||||
how #398's null `x:Name` fields survived every automated gate.
|
||||
|
||||
**Fix direction.** Add `Avalonia.Headless.XUnit` to the launcher test
|
||||
project and a focused view test that constructs `MainWindow`, asserts every
|
||||
`x:Name` field the code-behind dereferences is non-null, and drives each
|
||||
modal (editor kinds, first-run wizard, update prompt) through open and
|
||||
close so the focus/restore paths actually run. That catches the class —
|
||||
any null named control or throwing handler — not just #398's spelling.
|
||||
**Fix landed.** Added `Avalonia.Headless.XUnit` 12.1.1 to the launcher test
|
||||
project (its net10.0 dependency group targets **xunit v3**, so the project
|
||||
migrated `xunit` 2.9.3 → `xunit.v3` 3.2.2 — a drop-in swap; all 54
|
||||
pre-existing `[Fact]`/`[Theory]`/`Assert.*` tests compiled and passed
|
||||
unchanged, only two call sites needed `TestContext.Current.CancellationToken`
|
||||
per the new `xUnit1051` analyzer). `TestAppBuilder`
|
||||
(`tests/AcDream.Launcher.Tests/TestAppBuilder.cs`) wires
|
||||
`[assembly: AvaloniaTestApplication]` to a headless `AppBuilder.Configure<App>()`
|
||||
so FluentTheme (declared in the real `App.axaml`) is live for every test.
|
||||
`MainWindowViewTests.cs` adds 12 `[AvaloniaFact]`/`[AvaloniaTheory]` tests:
|
||||
an explicit non-null check of every `x:Name` field the code-behind
|
||||
dereferences, a reflection sweep over every `x:Name` in the markup (so a
|
||||
future named control without a matching non-null field fails loudly), and
|
||||
one open+close round trip per `ProfileEditorKind` (all seven, including
|
||||
`Remove`) plus the first-run wizard and the update prompt — each pumping
|
||||
`Dispatcher.UIThread.RunJobs()` so the `Dispatcher.UIThread.Post` callback
|
||||
in `OnViewModelPropertyChanged`/`FocusActiveModal` actually executes, not
|
||||
just gets queued. A dedicated test proves the `_focusBeforeModal != null`
|
||||
restore branch (not just the `ProfilesTree.Focus()` fallback) also runs
|
||||
clean, anchored on a real focusable button since `ProfilesTree` (a
|
||||
`TreeView`) has `Focusable="False"` under FluentTheme — its own tab stops
|
||||
are `TreeViewItem` rows, so the close-path assertions check "no exception
|
||||
escaped" rather than "focus landed on ProfilesTree" (that would be a false
|
||||
expectation, not the bug this issue is about).
|
||||
|
||||
**Acceptance:** a headless view test fails against the pre-#398 code
|
||||
**Falsification (required evidence).** Reverting `MainWindow`'s constructor
|
||||
to `AvaloniaXamlLoader.Load(this)` and rerunning: **12 failed / 0 passed**
|
||||
— 10 tests throw `System.NullReferenceException` at
|
||||
`AcDream.Launcher.MainWindow.FocusActiveModal` (propagating cleanly out of
|
||||
`Dispatcher.UIThread.RunJobs()`, confirming dispatcher exceptions are not
|
||||
swallowed), the 2 reflection tests fail on an explicit
|
||||
"`x:Name 'ProfilesTree' was null after construction`" message. Restoring
|
||||
`InitializeComponent()`: **12 passed / 0 failed**. Full launcher suite:
|
||||
**66 passed / 0 failed** (Windows and native Ubuntu/WSL, both post-fix).
|
||||
`tests/AcDream.Launcher.Core.Tests`: 317/317 unaffected.
|
||||
|
||||
**CI.** `.github/workflows/headless-portability.yml`'s `portable-launcher`
|
||||
job already runs `dotnet test tests/AcDream.Launcher.Tests/...` on both
|
||||
`windows-latest` and `ubuntu-latest` with no display setup — no workflow
|
||||
change was needed, since `Avalonia.Headless` requires no real windowing
|
||||
system (confirmed directly: the new tests pass unmodified under WSL/native
|
||||
Linux with no `DISPLAY` or Xvfb).
|
||||
|
||||
**Acceptance (met):** a headless view test fails against the pre-#398 code
|
||||
(`AvaloniaXamlLoader.Load`) and passes after, and runs in the portable
|
||||
Windows+Ubuntu CI lane alongside the existing launcher tests.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" Version="12.1.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ public sealed class LauncherUpdateCompositionTests : IDisposable
|
|||
Assert.False(capability.IsAvailable);
|
||||
Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal);
|
||||
LauncherUpdateException updateError = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => composition.Updater.CheckAsync());
|
||||
() => composition.Updater.CheckAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -389,7 +389,9 @@ public sealed class LauncherWindowViewModelTests
|
|||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
|
||||
Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await entered.Task.WaitAsync(
|
||||
TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null));
|
||||
Assert.False(viewModel.FirstRunWizardShell.CanEditInputs);
|
||||
viewModel.FirstRunWizardShell.CancelCommand.Execute(null);
|
||||
|
|
|
|||
406
tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
Normal file
406
tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Closes #399: no test ever constructed <see cref="MainWindow"/>, so the
|
||||
/// #398 defect class (code-behind dereferencing an x:Name field that
|
||||
/// <c>AvaloniaXamlLoader.Load(this)</c> never assigns, instead of the
|
||||
/// generated <c>InitializeComponent()</c>) reached the user gate through
|
||||
/// 14,012 green tests that were all ViewModel-only.
|
||||
///
|
||||
/// Every test here constructs a real <see cref="MainWindow"/> against the
|
||||
/// real compiled XAML and drives it exactly the way <c>App.axaml.cs</c>
|
||||
/// does: assign a live <see cref="LauncherWindowViewModel"/> as
|
||||
/// <c>DataContext</c>, then exercise the modal open/close paths that
|
||||
/// dereference the named controls (the bug class lives in
|
||||
/// MainWindow.axaml.cs's <c>OnViewModelPropertyChanged</c> and
|
||||
/// <c>FocusActiveModal</c>). That focus work is queued via
|
||||
/// <c>Dispatcher.UIThread.Post</c>, so every test pumps the headless
|
||||
/// dispatcher with <see cref="Dispatcher.RunJobs"/> before asserting — a
|
||||
/// test that only sets a property and asserts would pass vacuously
|
||||
/// without ever running <c>FocusActiveModal</c>.
|
||||
/// </summary>
|
||||
public sealed class MainWindowViewTests
|
||||
{
|
||||
// Every x:Name in MainWindow.axaml, kept in sync with the reflection
|
||||
// sweep below so a newly-added named control without a matching field
|
||||
// fails loudly instead of silently reaching InitializeComponent().
|
||||
private static readonly (string Name, Type Type)[] ExpectedNamedControls =
|
||||
[
|
||||
("ProfilesTree", typeof(TreeView)),
|
||||
("ServerNameTextBox", typeof(TextBox)),
|
||||
("AccountNameTextBox", typeof(TextBox)),
|
||||
("CharacterNameTextBox", typeof(TextBox)),
|
||||
("EditorSubmitButton", typeof(Button)),
|
||||
("FirstRunDatDirectoryTextBox", typeof(TextBox)),
|
||||
("FirstRunCloseButton", typeof(Button)),
|
||||
("UpdateCloseButton", typeof(Button)),
|
||||
];
|
||||
|
||||
[AvaloniaFact]
|
||||
public void EveryExplicitlyNamedControlIsAssignedAfterConstruction()
|
||||
{
|
||||
var window = new MainWindow();
|
||||
|
||||
foreach ((string name, Type type) in ExpectedNamedControls)
|
||||
{
|
||||
object? value = GetNamedField(window, name);
|
||||
Assert.True(
|
||||
value is not null,
|
||||
$"x:Name '{name}' was null after construction. Only the "
|
||||
+ "generated InitializeComponent() assigns x:Name backing "
|
||||
+ "fields; AvaloniaXamlLoader.Load(this) alone leaves them "
|
||||
+ "null (this is the #398 defect class).");
|
||||
Assert.IsAssignableFrom(type, value);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void ReflectionSweepOfEveryXNameInMarkupFindsANonNullBackingField()
|
||||
{
|
||||
string markupPath = Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"src",
|
||||
"AcDream.Launcher",
|
||||
"MainWindow.axaml");
|
||||
string markup = File.ReadAllText(markupPath);
|
||||
List<string> names = Regex
|
||||
.Matches(markup, "x:Name=\"([^\"]+)\"")
|
||||
.Select(match => match.Groups[1].Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// The markup must still declare at least the controls the
|
||||
// code-behind dereferences; an empty sweep would make this test
|
||||
// vacuous.
|
||||
Assert.True(names.Count >= ExpectedNamedControls.Length);
|
||||
|
||||
var window = new MainWindow();
|
||||
foreach (string name in names)
|
||||
{
|
||||
FieldInfo? field = typeof(MainWindow).GetField(
|
||||
name,
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
Assert.True(field is not null, $"No backing field found for x:Name '{name}'.");
|
||||
object? value = field!.GetValue(window);
|
||||
Assert.True(
|
||||
value is not null,
|
||||
$"x:Name '{name}' resolved to a field but its value was null "
|
||||
+ "after construction.");
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData(ProfileEditorKind.AddServer, "ServerNameTextBox")]
|
||||
[InlineData(ProfileEditorKind.EditServer, "ServerNameTextBox")]
|
||||
[InlineData(ProfileEditorKind.AddAccount, "AccountNameTextBox")]
|
||||
[InlineData(ProfileEditorKind.EditAccount, "AccountNameTextBox")]
|
||||
[InlineData(ProfileEditorKind.AddCharacter, "CharacterNameTextBox")]
|
||||
[InlineData(ProfileEditorKind.EditCharacter, "CharacterNameTextBox")]
|
||||
[InlineData(ProfileEditorKind.Remove, "EditorSubmitButton")]
|
||||
public void OpeningEachEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing(
|
||||
ProfileEditorKind kind,
|
||||
string expectedFocusFieldName)
|
||||
{
|
||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||
var window = new MainWindow { DataContext = viewModel };
|
||||
window.Show();
|
||||
|
||||
viewModel.EditorDialog.Open(kind, "Fixture title", _ => { });
|
||||
Assert.True(viewModel.EditorDialog.IsOpen);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!;
|
||||
Assert.Same(expectedFocus, CurrentFocus(window));
|
||||
|
||||
viewModel.EditorDialog.Close();
|
||||
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||
|
||||
// Nothing held focus before the dialog opened, so
|
||||
// OnViewModelPropertyChanged's close branch posts the fallback
|
||||
// (ProfilesTree.Focus()). Pumping the dispatcher is what actually
|
||||
// *runs* FocusActiveModal's caller and its ProfilesTree
|
||||
// dereference — this is the #398 defect class: with
|
||||
// AvaloniaXamlLoader.Load(this) instead of InitializeComponent(),
|
||||
// ProfilesTree is null here and this throws
|
||||
// NullReferenceException out of the dispatcher. TreeView's Fluent
|
||||
// template sets Focusable="False" (focus lives on TreeViewItem
|
||||
// rows, not the tree itself), so a successful, non-throwing
|
||||
// ProfilesTree.Focus() call still leaves focus at null — that is
|
||||
// expected, not a failure.
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.NotSame(expectedFocus, CurrentFocus(window));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing()
|
||||
{
|
||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||
var window = new MainWindow { DataContext = viewModel };
|
||||
window.Show();
|
||||
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!;
|
||||
Assert.Same(datDirectoryBox, CurrentFocus(window));
|
||||
|
||||
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
||||
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
||||
|
||||
// See the comment in the editor-kind theory above: this pump is
|
||||
// what actually executes the ProfilesTree.Focus() fallback.
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.NotSame(datDirectoryBox, CurrentFocus(window));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing()
|
||||
{
|
||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||
var window = new MainWindow { DataContext = viewModel };
|
||||
window.Show();
|
||||
|
||||
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
|
||||
Assert.True(viewModel.UpdatePrompt.IsOpen);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
|
||||
Assert.Same(closeButton, CurrentFocus(window));
|
||||
|
||||
viewModel.UpdatePrompt.CloseCommand.Execute(null);
|
||||
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
||||
|
||||
// See the comment in the editor-kind theory above: this pump is
|
||||
// what actually executes the ProfilesTree.Focus() fallback.
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.NotSame(closeButton, CurrentFocus(window));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing()
|
||||
{
|
||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||
var window = new MainWindow { DataContext = viewModel };
|
||||
window.Show();
|
||||
|
||||
// ProfilesTree itself is not a Fluent focus target (its template
|
||||
// sets Focusable="False"; individual TreeViewItem rows are the
|
||||
// real tab stops), so use another genuinely focusable, always
|
||||
// visible control from the same non-modal chrome as the
|
||||
// "previously focused" anchor for the _focusBeforeModal != null
|
||||
// branch of MainWindow.OnViewModelPropertyChanged.
|
||||
Control addServerButton = window
|
||||
.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(button => Equals(button.Content, "+ Server"));
|
||||
addServerButton.Focus();
|
||||
Assert.Same(addServerButton, CurrentFocus(window));
|
||||
|
||||
viewModel.EditorDialog.Open(ProfileEditorKind.AddServer, "Fixture title", _ => { });
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Control serverName = (Control)GetNamedField(window, "ServerNameTextBox")!;
|
||||
Assert.Same(serverName, CurrentFocus(window));
|
||||
|
||||
viewModel.EditorDialog.Close();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
// addServerButton was focused before the dialog opened, so the
|
||||
// restore branch (focusToRestore.Focus()) is what ran here, not
|
||||
// the ProfilesTree.Focus() fallback exercised by the tests above.
|
||||
Assert.Same(addServerButton, CurrentFocus(window));
|
||||
}
|
||||
|
||||
private static Control? CurrentFocus(MainWindow window) =>
|
||||
Avalonia.Controls.TopLevel.GetTopLevel(window)?.FocusManager?.GetFocusedElement()
|
||||
as Control;
|
||||
|
||||
private static object? GetNamedField(MainWindow window, string name) =>
|
||||
typeof(MainWindow)
|
||||
.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
?.GetValue(window);
|
||||
|
||||
private static LauncherWindowViewModel CreateViewModel()
|
||||
{
|
||||
var viewModel = new LauncherWindowViewModel(
|
||||
new StubOrchestrator(),
|
||||
new ImmediateUiDispatcher());
|
||||
viewModel.Initialize();
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
|
||||
{
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal no-op orchestrator. These tests exercise MainWindow's own
|
||||
/// dispatcher/focus wiring, not orchestrator behavior (already covered
|
||||
/// by <see cref="LauncherWindowViewModelTests"/>), so every mutation is
|
||||
/// a no-op and the snapshot is deliberately empty.
|
||||
/// </summary>
|
||||
private sealed class StubOrchestrator : ILauncherOrchestrator
|
||||
{
|
||||
// Never raised: these tests exercise MainWindow's dispatcher/focus
|
||||
// wiring directly and never trigger an orchestrator-side refresh.
|
||||
#pragma warning disable CS0067
|
||||
public event EventHandler? StateChanged;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public void LoadProfiles()
|
||||
{
|
||||
}
|
||||
|
||||
public LauncherStateSnapshot GetSnapshot() => new(
|
||||
Servers: [],
|
||||
Sessions: [],
|
||||
Platform: LauncherPlatformCapabilities.Detect(),
|
||||
IsInstallationReady: false,
|
||||
InstallationStatus: "Fixture: installation not ready.");
|
||||
|
||||
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
||||
LauncherCapability.Available;
|
||||
|
||||
public LauncherCapability GetAccountLaunchCapability(
|
||||
string serverName,
|
||||
string accountName,
|
||||
LaunchMode mode) => LauncherCapability.Available;
|
||||
|
||||
public LauncherCapability GetProbeCapability(string serverName, string accountName) =>
|
||||
LauncherCapability.Available;
|
||||
|
||||
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
||||
{
|
||||
}
|
||||
|
||||
public void AddServer(string name, string host, int port)
|
||||
{
|
||||
}
|
||||
|
||||
public void EditServer(string name, string newName, string newHost, int newPort)
|
||||
{
|
||||
}
|
||||
|
||||
public void RemoveServer(string name)
|
||||
{
|
||||
}
|
||||
|
||||
public void AddAccount(string serverName, string accountName, string password)
|
||||
{
|
||||
}
|
||||
|
||||
public void EditAccount(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string newAccountName,
|
||||
string? newPassword)
|
||||
{
|
||||
}
|
||||
|
||||
public void RemoveAccount(string serverName, string accountName)
|
||||
{
|
||||
}
|
||||
|
||||
public void AddCharacter(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string? characterId)
|
||||
{
|
||||
}
|
||||
|
||||
public void EditCharacterIdentity(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
string newCharacterName,
|
||||
string? newCharacterId)
|
||||
{
|
||||
}
|
||||
|
||||
public void UpdateCharacterSettings(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string characterName,
|
||||
LaunchMode launchMode,
|
||||
IReadOnlyList<string> plugins,
|
||||
IReadOnlyList<string> loginCommands)
|
||||
{
|
||||
}
|
||||
|
||||
public void RemoveCharacter(string serverName, string accountName, string characterName)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<LauncherSessionSnapshot> LaunchAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
string? characterName,
|
||||
LaunchMode mode,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CreateSession());
|
||||
|
||||
public Task<LauncherSessionSnapshot> ProbeAsync(
|
||||
string serverName,
|
||||
string accountName,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CreateSession());
|
||||
|
||||
public Task StopSessionAsync(
|
||||
string sessionId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public void PollStatus()
|
||||
{
|
||||
}
|
||||
|
||||
public void ClearFinishedSessions()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private static LauncherSessionSnapshot CreateSession() => new(
|
||||
"fixture-session",
|
||||
LauncherActivityKind.Play,
|
||||
"Fixture server",
|
||||
"fixture-account",
|
||||
"+Fixture",
|
||||
LaunchMode.Gui,
|
||||
LauncherActivityState.Connected,
|
||||
"Connected.",
|
||||
ExitCode: null,
|
||||
Error: null,
|
||||
CreatedAt: DateTimeOffset.UnixEpoch);
|
||||
}
|
||||
}
|
||||
21
tests/AcDream.Launcher.Tests/TestAppBuilder.cs
Normal file
21
tests/AcDream.Launcher.Tests/TestAppBuilder.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Headless;
|
||||
|
||||
[assembly: AvaloniaTestApplication(typeof(AcDream.Launcher.Tests.TestAppBuilder))]
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless Avalonia application entry point for <c>[AvaloniaFact]</c>/
|
||||
/// <c>[AvaloniaTheory]</c> tests (see #399). Reuses the launcher's real
|
||||
/// <see cref="App"/> so the FluentTheme styles declared in App.axaml are
|
||||
/// live for every test — <see cref="MainWindow"/>'s controls (TreeView,
|
||||
/// Button, TextBox, ...) need a real control theme to template and focus
|
||||
/// correctly, not just an unstyled visual tree.
|
||||
/// </summary>
|
||||
public static class TestAppBuilder
|
||||
{
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue