feat/postit-acl-members #41
15 changed files with 431 additions and 499 deletions
Roll back refacto on Posit.Tests
commit
88461786ee
|
|
@ -1,4 +1,6 @@
|
|||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
|
|
@ -29,15 +31,8 @@ namespace PostIt.Tests;
|
|||
/// click via <c>button.Command?.Execute(...)</c> + flush
|
||||
/// any async command before asserting.</para>
|
||||
/// </summary>
|
||||
[Collection("PostIt Headless")]
|
||||
public class AddCircleMemberDialogTests
|
||||
{
|
||||
private PostItHeadlessCollection fixture;
|
||||
|
||||
public AddCircleMemberDialogTests(PostItHeadlessCollection fixture, ITestOutputHelper output)
|
||||
{
|
||||
this.fixture = fixture;
|
||||
}
|
||||
/// <summary>
|
||||
/// Stand-in <see cref="IUserDirectory"/> that returns an
|
||||
/// empty list. The dialog's "Rechercher" button is never
|
||||
|
|
@ -78,10 +73,7 @@ public class AddCircleMemberDialogTests
|
|||
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
|
||||
/// (so <c>ViewLocator</c> can resolve it from the VM).
|
||||
/// </summary>
|
||||
private static (MainWindow window,
|
||||
CirclesPage page,
|
||||
AddCircleMemberDialog dialog)
|
||||
Mount()
|
||||
private static (MainWindow window, CirclesPage page, AddCircleMemberDialog dialog) Mount()
|
||||
{
|
||||
var api = new ThrowingApi();
|
||||
var circleClient = new CircleApiClient(api, "http://localhost/");
|
||||
|
|
@ -125,7 +117,7 @@ public class AddCircleMemberDialogTests
|
|||
public void Close_button_pops_dialog_off_nav_stack()
|
||||
{
|
||||
// Arrange: stack starts at 2 (CirclesPage + dialog).
|
||||
var window = fixture.Window;
|
||||
var (window, _, _) = Mount();
|
||||
var stackBefore = window.NavRoot.NavigationStack.Count;
|
||||
Assert.Equal(2, stackBefore);
|
||||
|
||||
|
|
@ -64,29 +64,3 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
|
|||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="YavscApiClient"/> stand-in whose constructor
|
||||
/// points at <c>https://stub.invalid</c> so any HTTP traffic
|
||||
/// that escapes a test (misconfigured command, missing fake
|
||||
/// handler) raises a clear <see cref="System.Net.Http.HttpRequestException"/>
|
||||
/// instead of silently hitting a real endpoint. Used by tests
|
||||
/// that don't actually exercise the API client (they click a
|
||||
/// button, assert on the nav stack, end of story) but whose
|
||||
/// VMs require one in their constructor.
|
||||
/// </summary>
|
||||
internal sealed class ThrowingApi : YavscApiClient
|
||||
{
|
||||
public ThrowingApi() : base(
|
||||
new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = "https://stub.invalid",
|
||||
ClientId = "stub",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
},
|
||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||
{ }
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the user-reported bug:
|
||||
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
|
||||
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
|
||||
/// dialog opened (the dropdown showed "Choisir un cercle..." and
|
||||
/// the list was blank, with no error to hint at why).
|
||||
///
|
||||
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
|
||||
/// to trigger <c>LoadAsync</c> on the first
|
||||
/// <c>DataContextChanged</c>, and the VM guards re-entry via
|
||||
/// <c>_loaded</c>. Two tests pin that contract:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>LoadAsync_runs_once_on_DataContext_changed</c>: HTTP
|
||||
/// traffic shows up after the dialog is mounted.</item>
|
||||
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
|
||||
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
|
||||
/// once (the <c>_loaded</c> gate).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>HTTP is stubbed with a counter
|
||||
/// <see cref="HttpMessageHandler"/> that returns canned JSON
|
||||
/// <c>[]</c> for every request. The handler counts calls so the
|
||||
/// tests can assert "exactly one round-trip on mount" and
|
||||
/// "exactly one round-trip after two calls to LoadAsync". This
|
||||
/// is the same shape used by <c>BearerScopeTests</c>: real
|
||||
/// <see cref="YavscApiClient"/> subclass, real
|
||||
/// <see cref="HttpClient"/> with an injected handler, real
|
||||
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
|
||||
/// talking to it.</para>
|
||||
///
|
||||
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
|
||||
/// provides the <see cref="MainWindow"/> already wired to
|
||||
/// <see cref="App"/>. Each test builds its own DI graph with
|
||||
/// the counting HTTP handler and swaps it in via
|
||||
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
|
||||
/// graph exposes <c>PostAclDialog</c> so the
|
||||
/// <see cref="ViewLocator"/> resolves it from
|
||||
/// <see cref="PostAclDialogViewModel"/>.</para>
|
||||
/// </summary>
|
||||
[Collection("PostIt Headless")]
|
||||
public sealed class PostAclDialogTests
|
||||
{
|
||||
private readonly PostItHeadlessCollection _host;
|
||||
|
||||
public PostAclDialogTests(PostItHeadlessCollection host)
|
||||
{
|
||||
_host = host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="HttpMessageHandler"/> that replies 200 with
|
||||
/// <c>[]</c> (a valid JSON empty array, which both
|
||||
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
|
||||
/// deserialize) and counts the number of requests.
|
||||
/// </summary>
|
||||
private sealed class CountingHttpHandler : HttpMessageHandler
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestCount++;
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
|
||||
/// traffic through a caller-supplied
|
||||
/// <see cref="HttpMessageHandler"/>. Same recipe as
|
||||
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
|
||||
/// override <c>CallAsync{T}</c> to talk to our own
|
||||
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
|
||||
/// because the load-on-attach bug has nothing to do with
|
||||
/// token refresh.
|
||||
/// </summary>
|
||||
private sealed class TestableYavscApiClient : YavscApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public TestableYavscApiClient(
|
||||
Settings settings,
|
||||
TokenStore store,
|
||||
HttpMessageHandler handler)
|
||||
: base(settings, store, oidc: null!)
|
||||
{
|
||||
_http = new HttpClient(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
public override Task<T> CallAsync<T>(
|
||||
HttpMethod method, string path, object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
||||
using var req = new HttpRequestMessage(method, absolute);
|
||||
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
using var stream = resp.Content.ReadAsStream();
|
||||
var dto = JsonSerializer.Deserialize<T>(stream,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return Task.FromResult(dto!);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
|
|
@ -48,43 +50,43 @@ namespace PostIt.Tests;
|
|||
/// <item>"[DEV] Signature" — click pushes a page onto the
|
||||
/// stack.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Lifecycle: shared <see cref="PostItHeadlessFixture"/>
|
||||
/// owns the <see cref="MainWindow"/> and the production DI graph.
|
||||
/// Each test builds a local <see cref="ServiceCollection"/> with
|
||||
/// the fake <see cref="YavscApiClient"/> + the page VMs and
|
||||
/// registers the destination pages, then swaps it in via
|
||||
/// <see cref="PostItHeadlessFixture.UseServiceProvider"/>. The
|
||||
/// fixture re-attaches the ViewLocator and the MainWindow so
|
||||
/// subsequent <see cref="App.PushPageAsync"/> calls route through
|
||||
/// the overridden graph.</para>
|
||||
/// </summary>
|
||||
[Collection("PostIt Headless")]
|
||||
public sealed class MainPageButtonsTests
|
||||
public class MainPageButtonsTests
|
||||
{
|
||||
private readonly PostItHeadlessCollection _host;
|
||||
|
||||
public MainPageButtonsTests(PostItHeadlessCollection host)
|
||||
/// <summary>
|
||||
/// Fake <see cref="YavscApiClient"/> that throws on any
|
||||
/// wire call. These tests never invoke a command that hits
|
||||
/// the API — only the click → nav side of the pipeline is
|
||||
/// asserted.
|
||||
/// </summary>
|
||||
private sealed class ThrowingApi : YavscApiClient
|
||||
{
|
||||
_host = host;
|
||||
public ThrowingApi() : base(
|
||||
new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = "https://stub.invalid",
|
||||
ClientId = "stub",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
},
|
||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||
{ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the test DI graph: <see cref="ThrowingApi"/> for
|
||||
/// the API clients (the click tests never hit the wire;
|
||||
/// any traffic would be a wiring bug), the real
|
||||
/// <see cref="BlogApiClient"/> / <see cref="CircleApiClient"/>
|
||||
/// / <see cref="BlogAclApiClient"/> that the page VM
|
||||
/// resolves, and the page + dialog + VM registrations the
|
||||
/// <see cref="ViewLocator"/> needs to resolve the three
|
||||
/// push targets.
|
||||
/// </summary>
|
||||
private MainPageViewModel BuildViewModel(BlogPostDto? selectedPost = null)
|
||||
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
|
||||
{
|
||||
var api = new ThrowingApi();
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var circle = new CircleApiClient(api, "http://localhost/");
|
||||
var acl = new BlogAclApiClient(api, "http://localhost/");
|
||||
// Minimal DI graph: only what MainPageViewModel resolves
|
||||
// when the user clicks a navigation button. Today that's
|
||||
// SignaturePageViewModel / CirclesPageViewModel / ACL
|
||||
// dependencies. The graph intentionally stays local to this
|
||||
// suite to avoid side effects from App.BuildServices() (real
|
||||
// token-store wiring).
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(new Settings());
|
||||
services.AddSingleton(circle);
|
||||
|
|
@ -94,31 +96,51 @@ public sealed class MainPageButtonsTests
|
|||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
services.AddTransient<PostAclDialog>();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var vm = new MainPageViewModel(blog, services: sp);
|
||||
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
|
||||
if (selectedPost is not null) vm.SelectedPost = selectedPost;
|
||||
return vm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a <see cref="MainPage"/> with the given VM onto
|
||||
/// the shared <see cref="MainWindow"/>'s nav stack. Clears
|
||||
/// any pages the previous test left behind (the fixture's
|
||||
/// MainWindow is shared across every test class). Returns
|
||||
/// the live page so the test can access its named buttons.
|
||||
/// Mount a real <see cref="MainWindow"/> (as
|
||||
/// <c>SessionStatusBannerTests</c> does), push a
|
||||
/// <see cref="MainPage"/> with the given VM onto
|
||||
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
|
||||
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
|
||||
/// nav stack before the test tries to interact with its
|
||||
/// named buttons. The window is shown so the visual tree is
|
||||
/// realised and <c>KeyPressQwerty</c> has a real
|
||||
/// <see cref="TopLevel"/> to dispatch against.
|
||||
/// </summary>
|
||||
private MainPage MountAsync(MainPageViewModel vm)
|
||||
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
|
||||
{
|
||||
var window = new MainWindow();
|
||||
var page = new MainPage { DataContext = vm };
|
||||
_host.PushAsync(page);
|
||||
return page;
|
||||
var app = (PostIt.App)Application.Current!;
|
||||
if (vm.Services is not null)
|
||||
{
|
||||
app.DataTemplates.Clear();
|
||||
app.DataTemplates.Add(new ViewLocator(vm.Services));
|
||||
}
|
||||
app.AttachMainWindow(window);
|
||||
window.Show();
|
||||
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
||||
return (window, page);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click a button by executing its <see cref="Button.Command"/>
|
||||
/// and draining any <see cref="IAsyncRelayCommand"/> so the
|
||||
/// caller can assert on the resulting nav stack immediately.
|
||||
/// Click a button by focusing it and pressing Enter — the
|
||||
/// supported headless pattern (cf. CalculatorTests in the
|
||||
/// Avalonia.Samples repo). Returns the nav-stack count
|
||||
/// before the click so the caller can assert on the delta.
|
||||
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
|
||||
/// itself — it is the <see cref="TopLevel"/> that owns the
|
||||
/// headless implementation, and routing the key through any
|
||||
/// descendant TopLevel (e.g. one obtained via
|
||||
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
|
||||
/// <c>NullReferenceException</c> from the headless impl
|
||||
/// because the descendant does not carry the
|
||||
/// <c>PlatformHandle</c> the harness expects.
|
||||
/// </summary>
|
||||
private static int ClickAndCapture(MainWindow window, Button button)
|
||||
{
|
||||
|
|
@ -143,8 +165,8 @@ public sealed class MainPageButtonsTests
|
|||
Title = "An existing post",
|
||||
AuthorId = "u-alice"
|
||||
};
|
||||
var vm = BuildViewModel(post);
|
||||
var page = MountAsync(vm);
|
||||
var vm = MakeViewModel(post);
|
||||
var (window, page) = MountMainPage(vm);
|
||||
|
||||
// Sanity: the button's command is bound and CanExecute
|
||||
// is true. If this fails, the bug is upstream (XAML
|
||||
|
|
@ -154,12 +176,12 @@ public sealed class MainPageButtonsTests
|
|||
Assert.True(aclButton.Command.CanExecute(null));
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(_host.Window, aclButton);
|
||||
var stackBefore = ClickAndCapture(window, aclButton);
|
||||
|
||||
// Assert γ + sniff léger: stack grew, new top is a Page.
|
||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {_host.Window.NavRoot.NavigationStack.Count}.");
|
||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
||||
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
|
||||
var pushed = window.NavRoot.NavigationStack.Last();
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
|
|
@ -169,19 +191,19 @@ public sealed class MainPageButtonsTests
|
|||
{
|
||||
// Arrange: OpenCircles has no CanExecute guard today —
|
||||
// any click should fire it and push the page.
|
||||
var vm = BuildViewModel();
|
||||
var page = MountAsync(vm);
|
||||
var vm = MakeViewModel();
|
||||
var (window, page) = MountMainPage(vm);
|
||||
|
||||
var circlesButton = page.OpenCirclesButton;
|
||||
Assert.NotNull(circlesButton.Command);
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(_host.Window, circlesButton);
|
||||
var stackBefore = ClickAndCapture(window, circlesButton);
|
||||
|
||||
// Assert
|
||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
"Click on 'Mes cercles' must push a new page onto the nav stack.");
|
||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
||||
var pushed = window.NavRoot.NavigationStack.Last();
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
|
|
@ -192,25 +214,25 @@ public sealed class MainPageButtonsTests
|
|||
// Arrange: the "[DEV] Signature" button is bound to the
|
||||
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
|
||||
// The click must push SignaturePage on top of NavRoot.
|
||||
// The ServiceCollection registered in BuildViewModel
|
||||
// provides SignaturePageViewModel so the command can
|
||||
// resolve it via DI and call App.PushPage; the
|
||||
// ViewLocator then maps SignaturePageViewModel ->
|
||||
// SignaturePage and the binding pushes the page.
|
||||
var vm = BuildViewModel();
|
||||
var page = MountAsync(vm);
|
||||
// The ServiceCollection registered in MakeViewModel provides
|
||||
// SignaturePageViewModel so the command can resolve it via
|
||||
// DI and call App.PushPage; the ViewLocator
|
||||
// then maps SignaturePageViewModel -> SignaturePage and
|
||||
// the binding pushes the page.
|
||||
var vm = MakeViewModel();
|
||||
var (window, page) = MountMainPage(vm);
|
||||
|
||||
var signatureButton = page.OpenSignatureDevButton;
|
||||
Assert.NotNull(signatureButton.Command);
|
||||
Assert.True(signatureButton.Command.CanExecute(null));
|
||||
|
||||
// Act
|
||||
var stackBefore = ClickAndCapture(_host.Window, signatureButton);
|
||||
var stackBefore = ClickAndCapture(window, signatureButton);
|
||||
|
||||
// Assert
|
||||
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
|
||||
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
|
||||
var pushed = _host.Window.NavRoot.NavigationStack[^1];
|
||||
var pushed = window.NavRoot.NavigationStack.Last();
|
||||
Assert.NotNull(pushed);
|
||||
Assert.IsAssignableFrom<Page>(pushed);
|
||||
}
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.VisualTree;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
|
||||
/// Uses the shared <see cref="PostItHeadlessFixture"/> (a real
|
||||
/// <see cref="MainWindow"/> with the production DI graph attached
|
||||
/// to <see cref="App"/>) plus a local
|
||||
/// <see cref="ServiceCollection"/> that swaps
|
||||
/// <see cref="YavscApiClient"/> for the recording fake.
|
||||
/// The pattern is the one <c>SessionStatusBannerTests</c>
|
||||
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
|
||||
/// hosting the page (via a <see cref="Frame"/> because
|
||||
/// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
|
||||
/// controls through their public surface and assert on what
|
||||
/// <see cref="RecordingYavscApiClient"/> saw go on the wire.
|
||||
///
|
||||
/// <para>The bug we are pinning: the title <c>TextBox</c> is
|
||||
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
|
||||
|
|
@ -31,38 +31,41 @@ namespace PostIt.Tests;
|
|||
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
|
||||
/// buffer that the XAML binds to and the Save command consumes.</para>
|
||||
/// </summary>
|
||||
[Collection("PostIt Headless")]
|
||||
public sealed class MainPageSaveTests
|
||||
public class MainPageSaveTests
|
||||
{
|
||||
private readonly PostItHeadlessCollection _host;
|
||||
|
||||
public MainPageSaveTests(PostItHeadlessCollection host)
|
||||
{
|
||||
_host = host;
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
|
||||
public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
|
||||
{
|
||||
// Arrange: VM with a recording API client, mounted on
|
||||
// the shared MainWindow's nav stack.
|
||||
// Arrange: VM with a recording API client, mounted in a
|
||||
// headless window via a Frame (MainPage is a ContentPage,
|
||||
// not a Control, so it needs a navigation host).
|
||||
var recorder = new CallRecorder();
|
||||
|
||||
var blog = _host.Services.GetRequiredService<BlogApiClient>();
|
||||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var viewModel = new MainPageViewModel(blog);
|
||||
|
||||
var page = new MainPage { DataContext = viewModel };
|
||||
_host.PushAsync(page);
|
||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||
// must be hosted in a navigation surface. The production
|
||||
// MainWindow.axaml uses NavigationPage, and the API is the
|
||||
// same one App.axaml.cs drives at boot (PushAsync, fire-
|
||||
// and-forget in prod because the page is the top of the
|
||||
// stack immediately).
|
||||
var nav = new NavigationPage();
|
||||
_ = nav.PushAsync(page);
|
||||
var window = new Window { Content = nav };
|
||||
window.Show();
|
||||
|
||||
// Act: type a title into the editor's TextBox without
|
||||
// first selecting a post in the list — the only state
|
||||
// in which a new post can be created. Then click Save.
|
||||
var titleBox = _host.Window.GetVisualDescendants()
|
||||
// first selecting a post in the list — the only state in
|
||||
// which a new post can be created. Then click Save.
|
||||
var titleBox = window.GetVisualDescendants()
|
||||
.OfType<TextBox>()
|
||||
.First(t => t.PlaceholderText == "Title");
|
||||
const string typed = "Mon premier billet";
|
||||
titleBox.Text = typed;
|
||||
|
||||
var saveButton = _host.Window.GetVisualDescendants()
|
||||
var saveButton = window.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Save");
|
||||
saveButton.Command!.Execute(null);
|
||||
|
|
@ -72,11 +75,7 @@ public sealed class MainPageSaveTests
|
|||
// task on the dispatcher. Give the dispatcher a chance to
|
||||
// run so the awaited CallAsync has actually fired before
|
||||
// we inspect the recorder.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (recorder.Calls.Count == 0 && DateTime.UtcNow < deadline)
|
||||
{
|
||||
Task.Delay(20).GetAwaiter().GetResult();
|
||||
}
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert: the first POST to "blog" carried a BlogPostDto
|
||||
// whose Title is exactly what the user typed. The bug
|
||||
234
src/PostIt.Tests/PostAclDialogTests.cs
Normal file
234
src/PostIt.Tests/PostAclDialogTests.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Abstract.Identity.Security;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Api.Client.Dtos;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for the user-reported bug:
|
||||
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
|
||||
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
|
||||
/// dialog opened (the dropdown showed "Choisir un cercle..." and
|
||||
/// the list was blank, with no error to hint at why).
|
||||
///
|
||||
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
|
||||
/// to trigger <c>LoadAsync</c> on the first
|
||||
/// <c>AttachedToVisualTree</c>, and the VM guards re-entry via
|
||||
/// <c>_loaded</c>. Two tests pin that contract:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>LoadAsync_runs_once_on_visual_attachment</c>: HTTP
|
||||
/// traffic shows up after the dialog is mounted.</item>
|
||||
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
|
||||
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
|
||||
/// once (the <c>_loaded</c> gate).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>HTTP is stubbed with a counter
|
||||
/// <see cref="HttpMessageHandler"/> that returns canned JSON
|
||||
/// <c>[]</c> for every request. The handler counts calls so the
|
||||
/// tests can assert "exactly one round-trip on mount" and
|
||||
/// "exactly one round-trip after two calls to LoadAsync". This
|
||||
/// is the same shape used by <c>BearerScopeTests</c>: real
|
||||
/// <see cref="YavscApiClient"/> subclass, real
|
||||
/// <see cref="HttpClient"/> with an injected handler, real
|
||||
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
|
||||
/// talking to it.</para>
|
||||
/// </summary>
|
||||
public class PostAclDialogTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="HttpMessageHandler"/> that replies 200 with
|
||||
/// <c>[]</c> (a valid JSON empty array, which both
|
||||
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
|
||||
/// deserialize) and counts the number of requests.
|
||||
/// </summary>
|
||||
private sealed class CountingHttpHandler : HttpMessageHandler
|
||||
{
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestCount++;
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
|
||||
/// traffic through a caller-supplied
|
||||
/// <see cref="HttpMessageHandler"/>. Same recipe as
|
||||
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
|
||||
/// override <c>CallAsync{T}</c> to talk to our own
|
||||
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
|
||||
/// because the load-on-attach bug has nothing to do with
|
||||
/// token refresh.
|
||||
/// </summary>
|
||||
private sealed class TestableYavscApiClient : YavscApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public TestableYavscApiClient(
|
||||
Settings settings,
|
||||
TokenStore store,
|
||||
HttpMessageHandler handler)
|
||||
: base(settings, store, oidc: null!)
|
||||
{
|
||||
_http = new HttpClient(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
public override Task<T> CallAsync<T>(
|
||||
HttpMethod method, string path, object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
||||
using var req = new HttpRequestMessage(method, absolute);
|
||||
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
using var stream = resp.Content.ReadAsStream();
|
||||
var dto = JsonSerializer.Deserialize<T>(stream,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return Task.FromResult(dto!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal DI graph exposing the two API clients
|
||||
/// (backed by a stub HTTP handler) and the page itself, so
|
||||
/// <c>ViewLocator</c> can resolve the dialog from the VM.
|
||||
/// Returns the handler, the API clients, and the window so
|
||||
/// the test can assert on request counts and push the
|
||||
/// dialog via the canonical <c>App.PushPageAsync</c> path.
|
||||
/// The DI graph is built into a local <see cref="IServiceProvider"/>
|
||||
/// that is NOT attached to <see cref="App.ServiceProvider"/>:
|
||||
/// rebinding the global DI mid-test would trample the
|
||||
/// Settings singleton the rest of the harness depends on.
|
||||
/// </summary>
|
||||
private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
|
||||
{
|
||||
var handler = new CountingHttpHandler();
|
||||
var settings = new Settings();
|
||||
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
|
||||
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(settings);
|
||||
services.AddSingleton(api);
|
||||
services.AddSingleton(aclClient);
|
||||
services.AddSingleton(circleClient);
|
||||
services.AddTransient<PostAclDialog>();
|
||||
var sp = services.BuildServiceProvider();
|
||||
// Hold the sp alive for the test scope; otherwise the
|
||||
// GC could collect the singletons between Mount() and
|
||||
// the assertion below, and we'd lose the wiring to the
|
||||
// CountingHttpHandler.
|
||||
GC.KeepAlive(sp);
|
||||
|
||||
var window = new MainWindow();
|
||||
var app = (App)Application.Current!;
|
||||
app.DataTemplates.Clear();
|
||||
app.DataTemplates.Add(new ViewLocator(sp));
|
||||
app.AttachMainWindow(window);
|
||||
window.Show();
|
||||
|
||||
return (window, aclClient, circleClient, handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bug: opening the dialog never called LoadAsync, so
|
||||
/// MyCircles/AclEntries were empty. After the fix, setting
|
||||
/// the dialog's DataContext to a PostAclDialogViewModel
|
||||
/// (the same path App.PushPageAsync takes) must trigger
|
||||
/// exactly one LoadAsync round-trip (the parallel WhenAll
|
||||
/// inside the VM counts as one request per backend call,
|
||||
/// hence two HTTP requests total: GET /blogacl and GET
|
||||
/// /circle).
|
||||
/// </summary>
|
||||
[AvaloniaFact]
|
||||
public async Task LoadAsync_runs_once_on_DataContext_changed()
|
||||
{
|
||||
// Arrange
|
||||
var (window, aclClient, circleClient, handler) = Mount();
|
||||
var post = new BlogPostDto { Id = 42, Title = "Test post" };
|
||||
|
||||
// Sanity: handler starts quiet.
|
||||
Assert.Equal(0, handler.RequestCount);
|
||||
|
||||
// Act: push the dialog via the canonical VM-first pipeline.
|
||||
// The locator goes through the parameterless ctor of
|
||||
// PostAclDialog, then App.PushPageAsync assigns DataContext,
|
||||
// which our hook intercepts to trigger LoadAsync.
|
||||
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
|
||||
await ((App)Application.Current!).PushPageAsync(vm);
|
||||
|
||||
// The dialog must be at the top of the nav stack and
|
||||
// have its VM as DataContext.
|
||||
var dialog = window.NavRoot.NavigationStack[^1] as PostAclDialog
|
||||
?? throw new InvalidOperationException("Dialog not at top of stack");
|
||||
Assert.Same(vm, dialog.DataContext);
|
||||
|
||||
// Drain pending async work. LoadAsync is async and the
|
||||
// DataContextChanged handler is fire-and-forget; a
|
||||
// couple of loop turns is enough. We poll the handler
|
||||
// counter because the dispatch back onto the headless
|
||||
// dispatcher isn't strict — using a generous-but-bounded
|
||||
// wait avoids test flakes.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (handler.RequestCount < 2 && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
// Assert: exactly two GETs went out (one to /blogacl,
|
||||
// one to /circle), both from the LoadAsync call.
|
||||
Assert.Equal(2, handler.RequestCount);
|
||||
|
||||
// And the VM's idempotency gate has flipped.
|
||||
Assert.True(vm.Loaded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fix exposes a guard on the VM too: a second call to
|
||||
/// LoadAsync on the same instance must NOT issue more HTTP
|
||||
/// traffic. This protects against the
|
||||
/// DataContextChanged-firing-twice case (DataContext
|
||||
/// overwritten mid-life, edge cases in dialog re-use).
|
||||
/// </summary>
|
||||
[AvaloniaFact]
|
||||
public async Task LoadAsync_is_idempotent()
|
||||
{
|
||||
// Arrange
|
||||
var (_, aclClient, circleClient, handler) = Mount();
|
||||
var post = new BlogPostDto { Id = 99, Title = "Idempotency" };
|
||||
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
|
||||
|
||||
// Act: invoke LoadAsync twice in a row.
|
||||
await vm.LoadAsync();
|
||||
await vm.LoadAsync();
|
||||
|
||||
// Assert: the second call short-circuited on _loaded.
|
||||
Assert.Equal(2, handler.RequestCount);
|
||||
Assert.True(vm.Loaded);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection grouping every headless UI test in
|
||||
/// <c>PostIt.Tests</c>. The Avalonia headless harness instantiates
|
||||
/// a single <see cref="PostItHeadlessFixture"/> per test class
|
||||
/// (<c>IClassFixture<PostItHeadlessFixture></c>); the
|
||||
/// collection marker here exists for two reasons:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>It documents the shared lifecycle
|
||||
/// contract: every test class that opts in gets the same
|
||||
/// <see cref="MainWindow"/>, the same DI service provider,
|
||||
/// the same <see cref="ViewLocator"/> on
|
||||
/// <see cref="Application.DataTemplates"/>, and the same
|
||||
/// <see cref="PostIt.App.MainWindow"/> attachment that
|
||||
/// <c>App.PushPageAsync</c> relies on.</description></item>
|
||||
/// <item><description>It disables parallelisation across the
|
||||
/// whole collection. The Avalonia headless platform is
|
||||
/// process-global (one <see cref="Application.Current"/> per
|
||||
/// process, one dispatcher per thread), so two collection
|
||||
/// members running in parallel would race on the same
|
||||
/// static state and produce flaky failures with no useful
|
||||
/// diagnostic. Same rationale as
|
||||
/// <c>JwtClaimMappingCollection</c>.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Mirrors the convention used by
|
||||
/// <c>Yavsc.Org.Tests.WebServerFixture</c> (collection
|
||||
/// <c>"Yavsc Server"</c>) and
|
||||
/// <c>Yavsc.Blogs.Tests.JwtClaimMappingCollection</c>.
|
||||
/// </summary>
|
||||
[CollectionDefinition("PostIt Headless")]
|
||||
public sealed class PostItHeadlessCollection: IDisposable
|
||||
{
|
||||
|
||||
/// <summary>The DI service provider the fixture booted
|
||||
/// (production graph from <see cref="App.BuildServices"/>).
|
||||
/// Identical across every <see cref="PostItHeadlessFixture"/>
|
||||
/// instance — see class remarks.</summary>
|
||||
public IServiceProvider Services { get; private set; }
|
||||
|
||||
/// <summary>The headless <see cref="MainWindow"/> for this
|
||||
/// fixture instance. Already <see cref="WindowBase.Show"/>n,
|
||||
/// so its visual tree is realised and
|
||||
/// <see cref="Button.Command"/> bindings have been
|
||||
/// evaluated. The window is per-instance, not
|
||||
/// process-shared, so each test class gets a clean nav
|
||||
/// stack out of the box.</summary>
|
||||
public MainWindow Window { get; private set; }
|
||||
|
||||
/// <summary>The <see cref="App"/> instance the Avalonia
|
||||
/// headless harness set as <see cref="Application.Current"/>.
|
||||
/// Convenience accessor for tests that need to call
|
||||
/// <c>App.PushPageAsync</c> directly.</summary>
|
||||
public App App { get; private set; }
|
||||
|
||||
/// <summary>The navigation surface the
|
||||
/// <see cref="MainWindow"/> hosts. Tests can read
|
||||
/// <c>NavigationStack</c> directly or call
|
||||
/// <see cref="PushAsync"/> to push onto it.</summary>
|
||||
public NavigationPage NavRoot { get; private set; }
|
||||
|
||||
public PostItHeadlessCollection()
|
||||
{
|
||||
|
||||
// First fixture to construct in this process:
|
||||
// build the production DI container and attach
|
||||
// it to the App.
|
||||
App = (App)Application.Current!;
|
||||
|
||||
var testingServices = new ServiceCollection();
|
||||
var api = new RecordingYavscApiClient(new CallRecorder());
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
|
||||
// The recording fake is sufficient on its own — no
|
||||
// production wiring needed. Swap it in via the fixture
|
||||
// so the App.PushPageAsync path resolves the same way
|
||||
// it would in production (minus the token store).
|
||||
|
||||
testingServices.AddSingleton(api);
|
||||
testingServices.AddSingleton(blog);
|
||||
|
||||
Services = App.BuildServices(testingServices);
|
||||
|
||||
App.AttachServiceProvider(Services);
|
||||
|
||||
// The ViewLocator is the single entry point
|
||||
// App.PushPageAsync uses to map a VM to a Page.
|
||||
App.DataTemplates.Clear();
|
||||
App.DataTemplates.Add(new ViewLocator(Services));
|
||||
|
||||
// Per-instance window: each test class gets its own.
|
||||
Window = new MainWindow();
|
||||
App.AttachMainWindow(Window);
|
||||
Window.Show();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Push a view model or page onto <see cref="NavRoot"/>.
|
||||
/// Awaits the push asynchronously so the headless
|
||||
/// dispatcher can pump frames while the push is in flight;
|
||||
/// the caller can then assert on the resulting stack
|
||||
/// (<c>NavRoot.NavigationStack[^1]</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tests that want a clean stack (most of them, since
|
||||
/// the fixture's <see cref="MainWindow"/> is shared
|
||||
/// across every test class) should call
|
||||
/// <see cref="ClearNavigationStack"/> before pushing, or
|
||||
/// use <see cref="MountAsync"/> which clears by default.
|
||||
/// </remarks>
|
||||
/// <returns>The page that was pushed, so the caller can
|
||||
/// assert on its type or bind a <c>DataContext</c>.</returns>
|
||||
public Page PushAsync(object vmOrPage)
|
||||
{
|
||||
if (vmOrPage is null) throw new ArgumentNullException(nameof(vmOrPage));
|
||||
|
||||
|
||||
// Synchronous push: the Avalonia headless
|
||||
// NavigationPage.PushAsync returns a Task that
|
||||
// completes once the transition animation finishes,
|
||||
// and in headless that animation is driven by the
|
||||
// dispatcher pump. We block on the Task with
|
||||
// GetAwaiter().GetResult() rather than awaiting it
|
||||
// because the test body is itself running on the
|
||||
// dispatcher thread (the [AvaloniaFact] attribute
|
||||
// schedules the test there); an await would capture
|
||||
// the dispatcher as the continuation target and
|
||||
// deadlock waiting for the push to complete on a
|
||||
// thread that's already busy running the test.
|
||||
if (vmOrPage is Page page)
|
||||
{
|
||||
Window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
|
||||
// Pump the dispatcher once so the pushed page
|
||||
// is actually on NavigationStack (the Awaiter
|
||||
// above unblocks before the stack is updated).
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
return page;
|
||||
}
|
||||
|
||||
// VM push: route through the production
|
||||
// App.PushPageAsync pipeline.
|
||||
var app = (App)Application.Current!;
|
||||
var vm = (ViewModelBase)vmOrPage;
|
||||
app.PushPageAsync(vm).GetAwaiter().GetResult();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
return Window.NavRoot.NavigationStack[^1];
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Last fixture out: tear the shared state down so
|
||||
// the next test run starts clean. We don't shut
|
||||
// down the Avalonia headless platform — that's
|
||||
// owned by the [AvaloniaTestApplication] attribute
|
||||
// on TestAppBuilder and gets torn down when the
|
||||
// process exits.
|
||||
try
|
||||
{
|
||||
var app = (App)Application.Current!;
|
||||
app.DataTemplates.Clear();
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
Services = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +1,46 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// UI tests for <see cref="SessionStatusBanner"/>. The shared
|
||||
/// <see cref="PostItHeadlessFixture"/> provides the headless
|
||||
/// <see cref="MainWindow"/> already attached to <see cref="App"/>
|
||||
/// and shown, so each test only has to wire its
|
||||
/// <see cref="SessionStatusViewModel"/> onto
|
||||
/// <c>MainWindow.SessionBanner</c> and assert on the rendered
|
||||
/// tree.
|
||||
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
|
||||
/// a real <see cref="MainWindow"/> via the headless Avalonia
|
||||
/// platform declared in <c>TestApp.cs</c>.
|
||||
///
|
||||
/// <para>The session banner's <c>DataContext</c> is not wired
|
||||
/// by <see cref="App.OnFrameworkInitializationCompleted"/> in
|
||||
/// these tests: production wires it at composition time, but a
|
||||
/// unit test runs against a freshly-built <see cref="App"/> so
|
||||
/// we set the <c>DataContext</c> on the banner directly. The
|
||||
/// production code path is exercised end-to-end by the manual
|
||||
/// launch, not here.</para>
|
||||
///
|
||||
/// <para>Pattern: <c>[AvaloniaFact]</c> (from
|
||||
/// <c>Avalonia.Headless.XUnit</c>) instead of plain
|
||||
/// <c>[Fact]</c> because the AvaloniaFact attribute schedules
|
||||
/// the test body inside a dispatcher, which is the precondition
|
||||
/// for the headless Window's
|
||||
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
|
||||
/// established: a test attribute <c>[AvaloniaFact]</c> (from
|
||||
/// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
|
||||
/// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
|
||||
/// attribute schedules the test body inside a dispatcher, which
|
||||
/// is the precondition for the headless Window's
|
||||
/// <c>PlatformManager.CreateWindow()</c> to find a registered
|
||||
/// service. A plain <c>[Fact]</c> test that calls
|
||||
/// <c>new Window().Show()</c> throws because the harness has
|
||||
/// not been initialised for that thread.</para>
|
||||
/// <c>new Window().Show()</c> throws because the harness has not
|
||||
/// been initialised for that thread.</para>
|
||||
///
|
||||
/// <para>The session banner's <c>DataContext</c> is not wired in
|
||||
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
|
||||
/// not called in a unit test, so we set the DataContext on the
|
||||
/// banner directly. The production code path is exercised
|
||||
/// end-to-end by the manual launch, not here.</para>
|
||||
/// </summary>
|
||||
[Collection("PostIt Headless")]
|
||||
public sealed class SessionStatusBannerTests
|
||||
public class SessionStatusBannerTests
|
||||
{
|
||||
private readonly PostItHeadlessCollection _host;
|
||||
|
||||
public SessionStatusBannerTests(PostItHeadlessCollection host)
|
||||
{
|
||||
_host = host;
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Banner_renders_three_buttons_in_the_visual_tree()
|
||||
{
|
||||
var banner = _host.Window.SessionBanner;
|
||||
banner.DataContext = new SessionStatusViewModel();
|
||||
var window = new MainWindow();
|
||||
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||
window.Show();
|
||||
|
||||
var buttons = banner.GetVisualDescendants()
|
||||
var buttons = window.SessionBanner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.ToList();
|
||||
|
||||
|
|
@ -65,30 +57,31 @@ public sealed class SessionStatusBannerTests
|
|||
[AvaloniaFact]
|
||||
public void Banner_login_button_is_visible_when_logged_out()
|
||||
{
|
||||
var banner = _host.Window.SessionBanner;
|
||||
var window = new MainWindow();
|
||||
var vm = new SessionStatusViewModel();
|
||||
Assert.True(vm.IsLoggedOut); // VM default
|
||||
banner.DataContext = vm;
|
||||
window.SessionBanner.DataContext = vm;
|
||||
window.Show();
|
||||
|
||||
var login = banner.GetVisualDescendants()
|
||||
var login = window.SessionBanner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Se connecter");
|
||||
|
||||
// The XAML binds IsVisible to IsLoggedOut. After the
|
||||
// banner is on the realised visual tree, the binding
|
||||
// has been evaluated.
|
||||
// The XAML binds IsVisible to IsLoggedOut. After Show,
|
||||
// the binding has been evaluated.
|
||||
Assert.True(login.IsVisible);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Banner_logout_button_is_hidden_when_logged_out()
|
||||
{
|
||||
var banner = _host.Window.SessionBanner;
|
||||
var window = new MainWindow();
|
||||
var vm = new SessionStatusViewModel();
|
||||
Assert.False(vm.IsLoggedIn); // VM default
|
||||
banner.DataContext = vm;
|
||||
window.SessionBanner.DataContext = vm;
|
||||
window.Show();
|
||||
|
||||
var logout = banner.GetVisualDescendants()
|
||||
var logout = window.SessionBanner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Se déconnecter");
|
||||
|
||||
|
|
@ -98,10 +91,11 @@ public sealed class SessionStatusBannerTests
|
|||
[AvaloniaFact]
|
||||
public void Banner_settings_button_is_visible_regardless_of_session()
|
||||
{
|
||||
var banner = _host.Window.SessionBanner;
|
||||
banner.DataContext = new SessionStatusViewModel();
|
||||
var window = new MainWindow();
|
||||
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||
window.Show();
|
||||
|
||||
var settings = banner.GetVisualDescendants()
|
||||
var settings = window.SessionBanner.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(b => b.Content as string == "Paramètres");
|
||||
|
||||
|
|
@ -114,10 +108,11 @@ public sealed class SessionStatusBannerTests
|
|||
[AvaloniaFact]
|
||||
public void Banner_session_label_reflects_DataContext()
|
||||
{
|
||||
var banner = _host.Window.SessionBanner;
|
||||
banner.DataContext = new SessionStatusViewModel();
|
||||
var window = new MainWindow();
|
||||
window.SessionBanner.DataContext = new SessionStatusViewModel();
|
||||
window.Show();
|
||||
|
||||
var label = banner.GetVisualDescendants()
|
||||
var label = window.SessionBanner.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");
|
||||
|
||||
|
|
|
|||
16
src/PostIt.Tests/UnitTest1.cs
Normal file
16
src/PostIt.Tests/UnitTest1.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Controls;
|
||||
using PostIt.Views;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class MainPageTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public void MainPage_Should_Load()
|
||||
{
|
||||
var window = new MainWindow();
|
||||
window.Show();
|
||||
Assert.NotNull(window);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ public partial class AddCircleMemberDialog : ContentPage
|
|||
public AddCircleMemberDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
|
|
|
|||
30
yavsc.sln
30
yavsc.sln
|
|
@ -31,14 +31,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Browser", "src\PostI
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostIt\PostIt.Desktop\PostIt.Desktop.csproj", "{EFE24256-9335-44C5-8B77-E180C2DB3C0B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{838B9737-88CA-432E-835C-F96817CF8085}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -181,18 +181,6 @@ Global
|
|||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
|
@ -229,6 +217,18 @@ Global
|
|||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x64.Build.0 = Release|Any CPU
|
||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.Build.0 = Release|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x64.Build.0 = Release|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{838B9737-88CA-432E-835C-F96817CF8085}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -246,9 +246,9 @@ Global
|
|||
{4C092CF8-524A-494D-AAFC-69383DFBA31D} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||
{AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||
{EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE}
|
||||
{4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||
{0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||
{838B9737-88CA-432E-835C-F96817CF8085} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue