feat/postit-acl-members #41

Merged
notazof merged 15 commits from feat/postit-acl-members into release/1.0.8-rc1 2026-08-21 22:32:02 +01:00
15 changed files with 431 additions and 499 deletions
Showing only changes of commit 88461786ee - Show all commits

Roll back refacto on Posit.Tests

Paul Schneider 2026-08-21 16:18:20 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
@ -29,15 +31,8 @@ namespace PostIt.Tests;
/// click via <c>button.Command?.Execute(...)</c> + flush /// click via <c>button.Command?.Execute(...)</c> + flush
/// any async command before asserting.</para> /// any async command before asserting.</para>
/// </summary> /// </summary>
[Collection("PostIt Headless")]
public class AddCircleMemberDialogTests public class AddCircleMemberDialogTests
{ {
private PostItHeadlessCollection fixture;
public AddCircleMemberDialogTests(PostItHeadlessCollection fixture, ITestOutputHelper output)
{
this.fixture = fixture;
}
/// <summary> /// <summary>
/// Stand-in <see cref="IUserDirectory"/> that returns an /// Stand-in <see cref="IUserDirectory"/> that returns an
/// empty list. The dialog's "Rechercher" button is never /// empty list. The dialog's "Rechercher" button is never
@ -78,10 +73,7 @@ public class AddCircleMemberDialogTests
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c> /// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
/// (so <c>ViewLocator</c> can resolve it from the VM). /// (so <c>ViewLocator</c> can resolve it from the VM).
/// </summary> /// </summary>
private static (MainWindow window, private static (MainWindow window, CirclesPage page, AddCircleMemberDialog dialog) Mount()
CirclesPage page,
AddCircleMemberDialog dialog)
Mount()
{ {
var api = new ThrowingApi(); var api = new ThrowingApi();
var circleClient = new CircleApiClient(api, "http://localhost/"); var circleClient = new CircleApiClient(api, "http://localhost/");
@ -125,7 +117,7 @@ public class AddCircleMemberDialogTests
public void Close_button_pops_dialog_off_nav_stack() public void Close_button_pops_dialog_off_nav_stack()
{ {
// Arrange: stack starts at 2 (CirclesPage + dialog). // Arrange: stack starts at 2 (CirclesPage + dialog).
var window = fixture.Window; var (window, _, _) = Mount();
var stackBefore = window.NavRoot.NavigationStack.Count; var stackBefore = window.NavRoot.NavigationStack.Count;
Assert.Equal(2, stackBefore); Assert.Equal(2, stackBefore);

View file

@ -64,29 +64,3 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
return Task.FromResult(default(T)!); 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()))
{ }
}

View file

@ -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!);
}
}
}

View file

@ -1,4 +1,6 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@ -48,43 +50,43 @@ namespace PostIt.Tests;
/// <item>"[DEV] Signature" — click pushes a page onto the /// <item>"[DEV] Signature" — click pushes a page onto the
/// stack.</item> /// stack.</item>
/// </list> /// </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> /// </summary>
[Collection("PostIt Headless")] public class MainPageButtonsTests
public sealed class MainPageButtonsTests
{ {
private readonly PostItHeadlessCollection _host; /// <summary>
/// Fake <see cref="YavscApiClient"/> that throws on any
public MainPageButtonsTests(PostItHeadlessCollection host) /// 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> private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
/// 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)
{ {
var api = new ThrowingApi(); var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/"); var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(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(); var services = new ServiceCollection();
services.AddSingleton(new Settings()); services.AddSingleton(new Settings());
services.AddSingleton(circle); services.AddSingleton(circle);
@ -94,31 +96,51 @@ public sealed class MainPageButtonsTests
services.AddTransient<SignaturePage>(); services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>(); services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>(); services.AddTransient<PostAclDialog>();
var sp = services.BuildServiceProvider(); var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
var vm = new MainPageViewModel(blog, services: sp);
if (selectedPost is not null) vm.SelectedPost = selectedPost; if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm; return vm;
} }
/// <summary> /// <summary>
/// Push a <see cref="MainPage"/> with the given VM onto /// Mount a real <see cref="MainWindow"/> (as
/// the shared <see cref="MainWindow"/>'s nav stack. Clears /// <c>SessionStatusBannerTests</c> does), push a
/// any pages the previous test left behind (the fixture's /// <see cref="MainPage"/> with the given VM onto
/// MainWindow is shared across every test class). Returns /// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// the live page so the test can access its named buttons. /// <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> /// </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 }; var page = new MainPage { DataContext = vm };
_host.PushAsync(page); var app = (PostIt.App)Application.Current!;
return page; 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> /// <summary>
/// Click a button by executing its <see cref="Button.Command"/> /// Click a button by focusing it and pressing Enter — the
/// and draining any <see cref="IAsyncRelayCommand"/> so the /// supported headless pattern (cf. CalculatorTests in the
/// caller can assert on the resulting nav stack immediately. /// 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> /// </summary>
private static int ClickAndCapture(MainWindow window, Button button) private static int ClickAndCapture(MainWindow window, Button button)
{ {
@ -143,8 +165,8 @@ public sealed class MainPageButtonsTests
Title = "An existing post", Title = "An existing post",
AuthorId = "u-alice" AuthorId = "u-alice"
}; };
var vm = BuildViewModel(post); var vm = MakeViewModel(post);
var page = MountAsync(vm); var (window, page) = MountMainPage(vm);
// Sanity: the button's command is bound and CanExecute // Sanity: the button's command is bound and CanExecute
// is true. If this fails, the bug is upstream (XAML // is true. If this fails, the bug is upstream (XAML
@ -154,12 +176,12 @@ public sealed class MainPageButtonsTests
Assert.True(aclButton.Command.CanExecute(null)); Assert.True(aclButton.Command.CanExecute(null));
// Act // Act
var stackBefore = ClickAndCapture(_host.Window, aclButton); var stackBefore = ClickAndCapture(window, aclButton);
// Assert γ + sniff léger: stack grew, new top is a Page. // Assert γ + sniff léger: stack grew, new top is a Page.
Assert.True(_host.Window.NavRoot.NavigationStack.Count > stackBefore, Assert.True(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}."); $"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
var pushed = _host.Window.NavRoot.NavigationStack[^1]; var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed); Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed); Assert.IsAssignableFrom<Page>(pushed);
} }
@ -169,19 +191,19 @@ public sealed class MainPageButtonsTests
{ {
// Arrange: OpenCircles has no CanExecute guard today — // Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page. // any click should fire it and push the page.
var vm = BuildViewModel(); var vm = MakeViewModel();
var page = MountAsync(vm); var (window, page) = MountMainPage(vm);
var circlesButton = page.OpenCirclesButton; var circlesButton = page.OpenCirclesButton;
Assert.NotNull(circlesButton.Command); Assert.NotNull(circlesButton.Command);
// Act // Act
var stackBefore = ClickAndCapture(_host.Window, circlesButton); var stackBefore = ClickAndCapture(window, circlesButton);
// Assert // 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."); "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.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed); Assert.IsAssignableFrom<Page>(pushed);
} }
@ -192,25 +214,25 @@ public sealed class MainPageButtonsTests
// Arrange: the "[DEV] Signature" button is bound to the // Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand]. // MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot. // The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in BuildViewModel // The ServiceCollection registered in MakeViewModel provides
// provides SignaturePageViewModel so the command can // SignaturePageViewModel so the command can resolve it via
// resolve it via DI and call App.PushPage; the // DI and call App.PushPage; the ViewLocator
// ViewLocator then maps SignaturePageViewModel -> // then maps SignaturePageViewModel -> SignaturePage and
// SignaturePage and the binding pushes the page. // the binding pushes the page.
var vm = BuildViewModel(); var vm = MakeViewModel();
var page = MountAsync(vm); var (window, page) = MountMainPage(vm);
var signatureButton = page.OpenSignatureDevButton; var signatureButton = page.OpenSignatureDevButton;
Assert.NotNull(signatureButton.Command); Assert.NotNull(signatureButton.Command);
Assert.True(signatureButton.Command.CanExecute(null)); Assert.True(signatureButton.Command.CanExecute(null));
// Act // Act
var stackBefore = ClickAndCapture(_host.Window, signatureButton); var stackBefore = ClickAndCapture(window, signatureButton);
// Assert // 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."); "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.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed); Assert.IsAssignableFrom<Page>(pushed);
} }

View file

@ -1,22 +1,22 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
namespace PostIt.Tests; namespace PostIt.Tests;
/// <summary> /// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>. /// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
/// Uses the shared <see cref="PostItHeadlessFixture"/> (a real /// The pattern is the one <c>SessionStatusBannerTests</c>
/// <see cref="MainWindow"/> with the production DI graph attached /// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// to <see cref="App"/>) plus a local /// hosting the page (via a <see cref="Frame"/> because
/// <see cref="ServiceCollection"/> that swaps /// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
/// <see cref="YavscApiClient"/> for the recording fake. /// 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 /// <para>The bug we are pinning: the title <c>TextBox</c> is
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>. /// 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> /// 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> /// buffer that the XAML binds to and the Save command consumes.</para>
/// </summary> /// </summary>
[Collection("PostIt Headless")] public class MainPageSaveTests
public sealed class MainPageSaveTests
{ {
private readonly PostItHeadlessCollection _host;
public MainPageSaveTests(PostItHeadlessCollection host)
{
_host = host;
}
[AvaloniaFact] [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 // Arrange: VM with a recording API client, mounted in a
// the shared MainWindow's nav stack. // headless window via a Frame (MainPage is a ContentPage,
// not a Control, so it needs a navigation host).
var recorder = new CallRecorder(); var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = _host.Services.GetRequiredService<BlogApiClient>(); var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainPageViewModel(blog); var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel }; 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 // Act: type a title into the editor's TextBox without
// first selecting a post in the list — the only state // first selecting a post in the list — the only state in
// in which a new post can be created. Then click Save. // which a new post can be created. Then click Save.
var titleBox = _host.Window.GetVisualDescendants() var titleBox = window.GetVisualDescendants()
.OfType<TextBox>() .OfType<TextBox>()
.First(t => t.PlaceholderText == "Title"); .First(t => t.PlaceholderText == "Title");
const string typed = "Mon premier billet"; const string typed = "Mon premier billet";
titleBox.Text = typed; titleBox.Text = typed;
var saveButton = _host.Window.GetVisualDescendants() var saveButton = window.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Save"); .Single(b => b.Content as string == "Save");
saveButton.Command!.Execute(null); saveButton.Command!.Execute(null);
@ -72,11 +75,7 @@ public sealed class MainPageSaveTests
// task on the dispatcher. Give the dispatcher a chance to // task on the dispatcher. Give the dispatcher a chance to
// run so the awaited CallAsync has actually fired before // run so the awaited CallAsync has actually fired before
// we inspect the recorder. // we inspect the recorder.
var deadline = DateTime.UtcNow.AddSeconds(2); await Task.Delay(200);
while (recorder.Calls.Count == 0 && DateTime.UtcNow < deadline)
{
Task.Delay(20).GetAwaiter().GetResult();
}
// Assert: the first POST to "blog" carried a BlogPostDto // Assert: the first POST to "blog" carried a BlogPostDto
// whose Title is exactly what the user typed. The bug // whose Title is exactly what the user typed. The bug

View 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);
}
}

View file

@ -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&lt;PostItHeadlessFixture&gt;</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;
}
}

View file

@ -1,54 +1,46 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests; namespace PostIt.Tests;
/// <summary> /// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. The shared /// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// <see cref="PostItHeadlessFixture"/> provides the headless /// a real <see cref="MainWindow"/> via the headless Avalonia
/// <see cref="MainWindow"/> already attached to <see cref="App"/> /// platform declared in <c>TestApp.cs</c>.
/// and shown, so each test only has to wire its
/// <see cref="SessionStatusViewModel"/> onto
/// <c>MainWindow.SessionBanner</c> and assert on the rendered
/// tree.
/// ///
/// <para>The session banner's <c>DataContext</c> is not wired /// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
/// by <see cref="App.OnFrameworkInitializationCompleted"/> in /// established: a test attribute <c>[AvaloniaFact]</c> (from
/// these tests: production wires it at composition time, but a /// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
/// unit test runs against a freshly-built <see cref="App"/> so /// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
/// we set the <c>DataContext</c> on the banner directly. The /// attribute schedules the test body inside a dispatcher, which
/// production code path is exercised end-to-end by the manual /// is the precondition for the headless Window's
/// 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
/// <c>PlatformManager.CreateWindow()</c> to find a registered /// <c>PlatformManager.CreateWindow()</c> to find a registered
/// service. A plain <c>[Fact]</c> test that calls /// service. A plain <c>[Fact]</c> test that calls
/// <c>new Window().Show()</c> throws because the harness has /// <c>new Window().Show()</c> throws because the harness has not
/// not been initialised for that thread.</para> /// 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> /// </summary>
[Collection("PostIt Headless")] public class SessionStatusBannerTests
public sealed class SessionStatusBannerTests
{ {
private readonly PostItHeadlessCollection _host;
public SessionStatusBannerTests(PostItHeadlessCollection host)
{
_host = host;
}
[AvaloniaFact] [AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree() public void Banner_renders_three_buttons_in_the_visual_tree()
{ {
var banner = _host.Window.SessionBanner; var window = new MainWindow();
banner.DataContext = new SessionStatusViewModel(); window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var buttons = banner.GetVisualDescendants() var buttons = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.ToList(); .ToList();
@ -65,30 +57,31 @@ public sealed class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out() public void Banner_login_button_is_visible_when_logged_out()
{ {
var banner = _host.Window.SessionBanner; var window = new MainWindow();
var vm = new SessionStatusViewModel(); var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default 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>() .OfType<Button>()
.Single(b => b.Content as string == "Se connecter"); .Single(b => b.Content as string == "Se connecter");
// The XAML binds IsVisible to IsLoggedOut. After the // The XAML binds IsVisible to IsLoggedOut. After Show,
// banner is on the realised visual tree, the binding // the binding has been evaluated.
// has been evaluated.
Assert.True(login.IsVisible); Assert.True(login.IsVisible);
} }
[AvaloniaFact] [AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out() public void Banner_logout_button_is_hidden_when_logged_out()
{ {
var banner = _host.Window.SessionBanner; var window = new MainWindow();
var vm = new SessionStatusViewModel(); var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default 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>() .OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter"); .Single(b => b.Content as string == "Se déconnecter");
@ -98,10 +91,11 @@ public sealed class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session() public void Banner_settings_button_is_visible_regardless_of_session()
{ {
var banner = _host.Window.SessionBanner; var window = new MainWindow();
banner.DataContext = new SessionStatusViewModel(); window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var settings = banner.GetVisualDescendants() var settings = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Paramètres"); .Single(b => b.Content as string == "Paramètres");
@ -114,10 +108,11 @@ public sealed class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_session_label_reflects_DataContext() public void Banner_session_label_reflects_DataContext()
{ {
var banner = _host.Window.SessionBanner; var window = new MainWindow();
banner.DataContext = new SessionStatusViewModel(); window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var label = banner.GetVisualDescendants() var label = window.SessionBanner.GetVisualDescendants()
.OfType<TextBlock>() .OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté"); .First(t => t.Text == "Déconnecté" || t.Text == "Connecté");

View 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);
}
}

View file

@ -24,6 +24,7 @@ public partial class AddCircleMemberDialog : ContentPage
public AddCircleMemberDialog() public AddCircleMemberDialog()
{ {
InitializeComponent(); InitializeComponent();
} }
private void InitializeComponent() private void InitializeComponent()

View file

@ -31,14 +31,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Browser", "src\PostI
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostIt\PostIt.Desktop\PostIt.Desktop.csproj", "{EFE24256-9335-44C5-8B77-E180C2DB3C0B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostIt\PostIt.Desktop\PostIt.Desktop.csproj", "{EFE24256-9335-44C5-8B77-E180C2DB3C0B}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yavsc.Api.Client\Yavsc.Api.Client.csproj", "{59AF5DEA-D349-495A-BC44-FC7BD4E55099}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{838B9737-88CA-432E-835C-F96817CF8085}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{EFE24256-9335-44C5-8B77-E180C2DB3C0B}.Release|x86.ActiveCfg = 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 {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.ActiveCfg = Debug|Any CPU
{0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = 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 {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|x64.Build.0 = Release|Any CPU
{59AF5DEA-D349-495A-BC44-FC7BD4E55099}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -246,9 +246,9 @@ Global
{4C092CF8-524A-494D-AAFC-69383DFBA31D} = {E13D107F-4053-D0DE-6394-453609595BFE} {4C092CF8-524A-494D-AAFC-69383DFBA31D} = {E13D107F-4053-D0DE-6394-453609595BFE}
{AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {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} {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} {0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {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} {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{838B9737-88CA-432E-835C-F96817CF8085} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal