From 0e543c14a26810341aab573c71c721d9a18603a2 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 6 Jul 2026 20:06:22 +0100 Subject: [PATCH 1/4] test: failing tests for Phase 6 (generic decision, Validation.Match, config validator, view factories) Co-Authored-By: Claude Fable 5 --- .../GivenValidationMatch.cs | 64 ++++++ .../GivenTheScrapeConfigurationValidator.cs | 183 ++++++++++++++++++ .../GivenTheViewFactoryRegistration.cs | 53 +++++ .../GivenAScrapeSiteWorkflowDecision.cs | 59 ++++++ 4 files changed, 359 insertions(+) create mode 100644 test/AStar.Dev.FunctionalParadigm.Tests.Unit/GivenValidationMatch.cs create mode 100644 test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeConfigurationValidator.cs create mode 100644 test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Support/GivenTheViewFactoryRegistration.cs diff --git a/test/AStar.Dev.FunctionalParadigm.Tests.Unit/GivenValidationMatch.cs b/test/AStar.Dev.FunctionalParadigm.Tests.Unit/GivenValidationMatch.cs new file mode 100644 index 0000000..09b2619 --- /dev/null +++ b/test/AStar.Dev.FunctionalParadigm.Tests.Unit/GivenValidationMatch.cs @@ -0,0 +1,64 @@ +using Shouldly; +using Xunit; + +namespace AStar.Dev.FunctionalParadigm.Tests.Unit; + +public class GivenValidationMatch +{ + [Fact] + public void when_validation_is_valid_then_the_valid_branch_is_invoked_with_the_value() + { + var validation = Validation.Valid(9); + + var actual = validation.Match(value => value * 2, _ => -1); + + actual.ShouldBe(18); + } + + [Fact] + public void when_validation_is_invalid_then_the_invalid_branch_is_invoked_with_all_errors() + { + var errors = new List + { + ValidationErrorFactory.Create("Name", "required"), + ValidationErrorFactory.Create("Age", "must be positive") + }; + var validation = Validation.Invalid(errors); + + var actual = validation.Match(_ => "valid", invalidErrors => string.Join(",", invalidErrors.Select(error => error.Message))); + + actual.ShouldBe("required,must be positive"); + } + + [Fact] + public void when_validation_is_valid_then_the_invalid_branch_is_never_invoked() + { + var validation = Validation.Valid("value"); + int invalidBranchInvocationCount = 0; + + _ = validation.Match(value => value, _ => + { + invalidBranchInvocationCount++; + + return "invalid"; + }); + + invalidBranchInvocationCount.ShouldBe(0); + } + + [Fact] + public void when_validation_is_invalid_then_the_valid_branch_is_never_invoked() + { + var validation = Validation.Invalid(ValidationErrorFactory.Create("Name", "required")); + int validBranchInvocationCount = 0; + + _ = validation.Match(value => + { + validBranchInvocationCount++; + + return value; + }, _ => "invalid"); + + validBranchInvocationCount.ShouldBe(0); + } +} diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeConfigurationValidator.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeConfigurationValidator.cs new file mode 100644 index 0000000..65bc3a7 --- /dev/null +++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Models/GivenTheScrapeConfigurationValidator.cs @@ -0,0 +1,183 @@ +using AStar.Dev.FunctionalParadigm; +using AStar.Dev.Wallpaper.Scrapper.Models; +using AStar.Dev.Wallpaper.Scrapper.Tests.Unit.TestData; + +namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.Models; + +public sealed class GivenTheScrapeConfigurationValidator +{ + [Fact] + public void when_the_configuration_is_fully_populated_then_the_result_is_valid_and_carries_the_configuration() + { + var configuration = new ScrapeConfigurationBuilder().Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + actual.ShouldBeOfType>().Value.ShouldBeSameAs(configuration); + } + + [Fact] + public void when_the_search_configuration_is_missing_then_a_single_error_is_reported_for_it() + { + var configuration = new ScrapeConfigurationBuilder().Build() with { SearchConfiguration = null! }; + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe(nameof(ScrapeConfiguration.SearchConfiguration)); + } + + [Fact] + public void when_the_scrape_directories_are_missing_then_a_single_error_is_reported_for_them() + { + var configuration = new ScrapeConfigurationBuilder().Build() with { ScrapeDirectories = null! }; + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe(nameof(ScrapeConfiguration.ScrapeDirectories)); + } + + [Fact] + public void when_the_user_configuration_is_missing_then_a_single_error_is_reported_for_it() + { + var configuration = new ScrapeConfigurationBuilder().Build() with { UserConfiguration = null! }; + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe(nameof(ScrapeConfiguration.UserConfiguration)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void when_the_base_url_is_missing_then_an_error_is_reported_for_it(string? baseUrl) + { + var configuration = new ScrapeConfigurationBuilder { SearchConfiguration = new SearchConfigurationBuilder { BaseUrl = baseUrl! }.Build() }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("SearchConfiguration.BaseUrl"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void when_the_login_url_is_missing_then_an_error_is_reported_for_it(string? loginUrl) + { + var configuration = new ScrapeConfigurationBuilder { SearchConfiguration = new SearchConfigurationBuilder { LoginUrl = loginUrl! }.Build() }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("SearchConfiguration.LoginUrl"); + } + + [Fact] + public void when_the_image_pause_is_negative_then_an_error_is_reported_for_it() + { + var configuration = new ScrapeConfigurationBuilder { SearchConfiguration = new SearchConfigurationBuilder { ImagePauseInSeconds = -1 }.Build() }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("SearchConfiguration.ImagePauseInSeconds"); + } + + [Fact] + public void when_the_starting_page_number_is_below_one_then_an_error_is_reported_for_it() + { + var configuration = new ScrapeConfigurationBuilder { SearchConfiguration = new SearchConfigurationBuilder { StartingPageNumber = 0 }.Build() }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("SearchConfiguration.StartingPageNumber"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void when_the_login_email_address_is_missing_then_an_error_is_reported_for_it(string? loginEmailAddress) + { + var configuration = new ScrapeConfigurationBuilder { UserConfiguration = new UserConfiguration(loginEmailAddress!, "username", "password", "session-cookie") }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("UserConfiguration.LoginEmailAddress"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void when_the_password_is_missing_then_an_error_is_reported_for_it(string? password) + { + var configuration = new ScrapeConfigurationBuilder { UserConfiguration = new UserConfiguration("user@example.test", "username", password!, "session-cookie") }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("UserConfiguration.Password"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void when_the_root_directory_is_missing_then_an_error_is_reported_for_it(string? rootDirectory) + { + var configuration = new ScrapeConfigurationBuilder { ScrapeDirectories = new ScrapeDirectories(rootDirectory!, "base-save-directory", "base-directory", "base-directory-famous", "sub-directory") }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("ScrapeDirectories.RootDirectory"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void when_the_base_save_directory_is_missing_then_an_error_is_reported_for_it(string? baseSaveDirectory) + { + var configuration = new ScrapeConfigurationBuilder { ScrapeDirectories = new ScrapeDirectories("root-directory", baseSaveDirectory!, "base-directory", "base-directory-famous", "sub-directory") }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.ShouldHaveSingleItem().Property.ShouldBe("ScrapeDirectories.BaseSaveDirectory"); + } + + [Fact] + public void when_multiple_fields_are_invalid_then_all_errors_are_accumulated() + { + var configuration = new ScrapeConfigurationBuilder + { + SearchConfiguration = new SearchConfigurationBuilder { BaseUrl = "", LoginUrl = "", ImagePauseInSeconds = -1 }.Build(), + UserConfiguration = new UserConfiguration("", "username", "", "session-cookie") + }.Build(); + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.Select(error => error.Property).ShouldBe(["SearchConfiguration.BaseUrl", "SearchConfiguration.LoginUrl", "SearchConfiguration.ImagePauseInSeconds", "UserConfiguration.LoginEmailAddress", "UserConfiguration.Password"]); + } + + [Fact] + public void when_all_sections_are_missing_then_one_error_is_reported_per_section() + { + var configuration = new ScrapeConfigurationBuilder().Build() with { SearchConfiguration = null!, UserConfiguration = null!, ScrapeDirectories = null! }; + + var actual = ScrapeConfigurationValidator.Validate(configuration); + + var invalid = actual.ShouldBeOfType>(); + invalid.Errors.Select(error => error.Property).ShouldBe([nameof(ScrapeConfiguration.SearchConfiguration), nameof(ScrapeConfiguration.UserConfiguration), nameof(ScrapeConfiguration.ScrapeDirectories)]); + } +} diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Support/GivenTheViewFactoryRegistration.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Support/GivenTheViewFactoryRegistration.cs new file mode 100644 index 0000000..6a68aa7 --- /dev/null +++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Support/GivenTheViewFactoryRegistration.cs @@ -0,0 +1,53 @@ +using AStar.Dev.Wallpaper.Scrapper.Support; +using Microsoft.Extensions.DependencyInjection; + +namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.Support; + +public sealed class GivenTheViewFactoryRegistration +{ + [Fact] + public void when_a_view_factory_is_registered_then_the_view_itself_is_resolvable() + { + var services = new ServiceCollection().AddViewFactory(); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().ShouldNotBeNull(); + } + + [Fact] + public void when_a_view_factory_is_registered_then_the_factory_is_resolvable() + { + var services = new ServiceCollection().AddViewFactory(); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService>().ShouldNotBeNull(); + } + + [Fact] + public void when_the_factory_is_invoked_then_it_creates_a_view_instance() + { + var services = new ServiceCollection().AddViewFactory(); + using var provider = services.BuildServiceProvider(); + + var view = provider.GetRequiredService>()(); + + view.ShouldNotBeNull(); + } + + [Fact] + public void when_the_factory_is_invoked_twice_then_each_invocation_creates_a_new_instance() + { + var services = new ServiceCollection().AddViewFactory(); + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService>(); + + var firstView = factory(); + var secondView = factory(); + + firstView.ShouldNotBeSameAs(secondView); + } + + public sealed class FakeView; +} diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Workflows/GivenAScrapeSiteWorkflowDecision.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Workflows/GivenAScrapeSiteWorkflowDecision.cs index 61b335e..80634ba 100644 --- a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Workflows/GivenAScrapeSiteWorkflowDecision.cs +++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Workflows/GivenAScrapeSiteWorkflowDecision.cs @@ -38,4 +38,63 @@ public async Task when_the_setup_step_succeeds_then_the_workflow_is_invoked() workflowInvocationCount.ShouldBe(1); } + + [Fact] + public async Task when_the_setup_step_fails_then_the_result_is_a_failure_carrying_the_setup_error_message() + { + static Task> Workflow(CancellationToken ct) + => Task.FromResult(global::AStar.Dev.FunctionalParadigm.Result.Success("exported")); + + var actual = await ScrapeSiteWorkflowDecision.DecideAsync(new InvalidOperationException("setup failed"), Workflow); + + actual.ShouldBe(global::AStar.Dev.FunctionalParadigm.Result.Failure("setup failed")); + } + + [Fact] + public async Task when_the_setup_step_fails_then_a_non_unit_workflow_is_never_invoked() + { + int workflowInvocationCount = 0; + + Task> Workflow(CancellationToken ct) + { + workflowInvocationCount++; + + return Task.FromResult(global::AStar.Dev.FunctionalParadigm.Result.Success("exported")); + } + + await ScrapeSiteWorkflowDecision.DecideAsync(new InvalidOperationException("setup failed"), Workflow); + + workflowInvocationCount.ShouldBe(0); + } + + [Fact] + public async Task when_the_setup_step_succeeds_then_the_token_is_forwarded_to_the_workflow() + { + using var cts = new CancellationTokenSource(); + CancellationToken forwardedToken = default; + + Task> Workflow(CancellationToken ct) + { + forwardedToken = ct; + + return Task.FromResult(global::AStar.Dev.FunctionalParadigm.Result.Success("exported")); + } + + await ScrapeSiteWorkflowDecision.DecideAsync(cts.Token, Workflow); + + forwardedToken.ShouldBe(cts.Token); + } + + [Fact] + public async Task when_the_setup_step_succeeds_then_the_workflow_result_is_returned() + { + using var cts = new CancellationTokenSource(); + + static Task> Workflow(CancellationToken ct) + => Task.FromResult(global::AStar.Dev.FunctionalParadigm.Result.Success("exported")); + + var actual = await ScrapeSiteWorkflowDecision.DecideAsync(cts.Token, Workflow); + + actual.ShouldBe(global::AStar.Dev.FunctionalParadigm.Result.Success("exported")); + } } From 73e19c8f87fd734b2a83de0b47e02034c0af0cc8 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 6 Jul 2026 20:13:37 +0100 Subject: [PATCH 2/4] Phase 6: MainWindow + composition (functional refactor plan) - ScrapeSiteWorkflowDecision generalised to DecideAsync; Tags/Classifications/ScrapeConfiguration views now use the same short-circuit + try/finally ResetUI treatment as MainWindow (cts!.Token reads removed) - App startup: blocking Task.Run(...).GetAwaiter().GetResult() replaced with async initialisation (window shown explicitly once init completes) - View + Func factory registrations collapsed into AddViewFactory() - ScrapeConfiguration validated at startup via Validation; all errors broadcast to the UI status log - Validation gains Match extension Co-Authored-By: Claude Fable 5 --- .../ValidationExtensions.cs | 12 +++ src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs | 30 +++++--- .../ClassificationsView.axaml.cs | 72 ++++++++++-------- .../MainWindow.axaml.cs | 15 ++-- .../Models/ScrapeConfigurationValidator.cs | 76 +++++++++++++++++++ .../ScrapeConfigurationView.axaml.cs | 73 ++++++++++-------- .../ViewFactoryServiceCollectionExtensions.cs | 19 +++++ .../Tags/TagsView.axaml.cs | 72 ++++++++++-------- .../Workflows/ScrapeSiteWorkflowDecision.cs | 12 +-- 9 files changed, 261 insertions(+), 120 deletions(-) create mode 100644 src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeConfigurationValidator.cs create mode 100644 src/AStar.Dev.Wallpaper.Scrapper/Support/ViewFactoryServiceCollectionExtensions.cs diff --git a/src/AStar.Dev.FunctionalParadigm/ValidationExtensions.cs b/src/AStar.Dev.FunctionalParadigm/ValidationExtensions.cs index 0b6e542..2818160 100644 --- a/src/AStar.Dev.FunctionalParadigm/ValidationExtensions.cs +++ b/src/AStar.Dev.FunctionalParadigm/ValidationExtensions.cs @@ -55,6 +55,18 @@ public static Validation> Combine(this IEnumerable>(values); } + /// + /// Reduces a to a single value by invoking + /// with the validated value, or with the accumulated errors. + /// + public static TOut Match(this Validation validation, Func onValid, Func, TOut> onInvalid) + => validation switch + { + Valid valid => onValid(valid.Value), + Invalid invalid => onInvalid(invalid.Errors), + _ => throw new InvalidOperationException(_unexpectedValidationTypeMessage) + }; + /// /// Lifts a into a , mapping the /// accumulated errors to a domain error via . diff --git a/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs index 6aa7c43..d0f2fb4 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.IO.Abstractions; +using AStar.Dev.FunctionalParadigm; using AStar.Dev.Infrastructure.AppDb; using AStar.Dev.Infrastructure.AppDb.Entities; using AStar.Dev.Wallpaper.Scrapper.Classifications; @@ -33,7 +34,7 @@ public partial class App : Application public override void Initialize() => AvaloniaXamlLoader.Load(this); - public override void OnFrameworkInitializationCompleted() + public override async void OnFrameworkInitializationCompleted() { var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { @@ -86,9 +87,9 @@ public override void OnFrameworkInitializationCompleted() .AddTransient() .AddTransient() .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() + .AddViewFactory() + .AddViewFactory() + .AddViewFactory() .AddSingleton() .AddTransient() .AddTransient() @@ -104,10 +105,7 @@ public override void OnFrameworkInitializationCompleted() .AddTransient() .AddSingleton() .AddSingleton() - .AddTransient>(sp => () => sp.GetRequiredService()) - .AddTransient>(sp => () => sp.GetRequiredService()) .AddTransient() - .AddTransient>(sp => () => sp.GetRequiredService()) .AddTransient(_ => TimeProvider.System) .AddTransient() .AddTransient() @@ -115,20 +113,32 @@ public override void OnFrameworkInitializationCompleted() _host = builder.Build(); - Task.Run(async () => - await _host.Services.GetRequiredService().InitialiseAsync() - ).GetAwaiter().GetResult(); + await _host.Services.GetRequiredService().InitialiseAsync(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = _host.Services.GetRequiredService(); desktop.Exit += OnExit; + desktop.MainWindow.Show(); } _host.Start(); + SurfaceConfigurationErrors(); base.OnFrameworkInitializationCompleted(); } + private void SurfaceConfigurationErrors() + => ScrapeConfigurationValidator.Validate(_host.Services.GetRequiredService()) + .Match(_ => Unit.Value, errors => + { + var broadcaster = _host.Services.GetRequiredService(); + + foreach (var error in errors) + broadcaster.Broadcast($"Configuration error - {error.Property}: {error.Message}"); + + return Unit.Value; + }); + private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) => _host.StopAsync().GetAwaiter().GetResult(); } diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs index ee11cbd..44e3036 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/Classifications/ClassificationsView.axaml.cs @@ -2,6 +2,7 @@ using AStar.Dev.Wallpaper.Scrapper.Models; using AStar.Dev.Wallpaper.Scrapper.Services; using AStar.Dev.Wallpaper.Scrapper.Support; +using AStar.Dev.Wallpaper.Scrapper.Workflows; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; @@ -29,37 +30,40 @@ public ClassificationsView(FileClassificationService fileClassificationService, } private async void OnExportClicked(object? sender, RoutedEventArgs e) - => _ = await ResetCancellationTokenSource() - .Match( - onSuccess: DisableControlsAndClearStatus, - onFailure: ex => - { - logger.Error(ex, "Failed to reset cancellation token source"); - UpdateStatus($"Error: {ex.Message}"); - return ex.Message; - } - ) - .Tap(_ => logger.Information("Exporting classifications...")) - .MapAsync(_ => fileClassificationService.ExportClassificationsAsync(cts!.Token)) - .Tap(importExportService.ExportFileClassificationsToFile) - .TapAsync(_ => logger.Information("Export completed...")) - .EnsureAsync(() => ResetUI()); + { + try + { + _ = await ScrapeSiteWorkflowDecision.DecideAsync( + ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure), + ct => Result.Success(ct) + .Tap(_ => logger.Information("Exporting classifications...")) + .MapAsync(_ => fileClassificationService.ExportClassificationsAsync(ct)) + .Tap(importExportService.ExportFileClassificationsToFile) + .TapAsync(_ => logger.Information("Export completed..."))); + } + finally + { + ResetUI(); + } + } private async void OnImportClicked(object? sender, RoutedEventArgs e) - => _ = await ResetCancellationTokenSource() - .Match( - onSuccess: DisableControlsAndClearStatus, - onFailure: ex => - { - logger.Error(ex, "Failed to reset cancellation token source"); - return ex.Message; - } - ) - .Tap(_ => logger.Information("Importing classifications...")) - .Bind(_ => importExportService.ImportFileClassificationsFromFile().ToStringError()) - .MapAsync(classifications => fileClassificationService.ImportClassificationsAsync(classifications, cts!.Token)) - .TapAsync(_ => logger.Information("Import completed...")) - .EnsureAsync(() => ResetUI()); + { + try + { + _ = await ScrapeSiteWorkflowDecision.DecideAsync( + ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure), + ct => Result.Success(ct) + .Tap(_ => logger.Information("Importing classifications...")) + .Bind(_ => importExportService.ImportFileClassificationsFromFile().ToStringError()) + .MapAsync(classifications => fileClassificationService.ImportClassificationsAsync(classifications, ct)) + .TapAsync(_ => logger.Information("Import completed..."))); + } + finally + { + ResetUI(); + } + } private Result ResetCancellationTokenSource() { @@ -68,13 +72,17 @@ private Result ResetCancellationTokenSource() return cts.Token; } - private Result DisableControlsAndClearStatus(CancellationToken ct = default) + private void LogSetupFailure(Exception exception) + { + logger.Error(exception, "Failed to reset cancellation token source"); + UpdateStatus($"Error: {exception.Message}"); + } + + private void DisableControlsAndClearStatus(CancellationToken ct) { ExportButton.IsEnabled = false; ImportButton.IsEnabled = false; StatusLabel.Text = string.Empty; - - return ct; } private void ResetUI() diff --git a/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs index a6e653c..4ff9cd2 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs @@ -106,14 +106,7 @@ private async void OnScrapeSiteFunctionalClicked(object? sender, RoutedEventArgs try { _ = await ScrapeSiteWorkflowDecision.DecideAsync( - ResetCancellationTokenSource().Tap( - onSuccess: DisableControlsAndClearStatus, - onFailure: ex => - { - logger.Error(ex, "Failed to reset cancellation token source"); - UpdateStatus($"Error: {ex.Message}"); - } - ), + ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure), RunScrapeWorkflowAsync ); } @@ -135,6 +128,12 @@ private Result ResetCancellationTokenSource() return cts.Token; } + private void LogSetupFailure(Exception exception) + { + logger.Error(exception, "Failed to reset cancellation token source"); + UpdateStatus($"Error: {exception.Message}"); + } + private void DisableControlsAndClearStatus(CancellationToken ct) { ScrapeSiteNewButton.IsEnabled = false; diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeConfigurationValidator.cs b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeConfigurationValidator.cs new file mode 100644 index 0000000..3cdcbc8 --- /dev/null +++ b/src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeConfigurationValidator.cs @@ -0,0 +1,76 @@ +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.Wallpaper.Scrapper.Models; + +/// +/// Validates a at startup, accumulating every problem so the UI can +/// surface all configuration errors at once instead of the scrape failing part-way through. +/// +public static class ScrapeConfigurationValidator +{ + /// + /// Validates the supplied , accumulating all errors. + /// + /// The configuration loaded at startup. + /// A carrying the configuration, or an carrying every error found. + public static Validation Validate(ScrapeConfiguration configuration) + { + var errors = new List(); + errors.AddRange(ValidateSearchConfiguration(configuration.SearchConfiguration)); + errors.AddRange(ValidateUserConfiguration(configuration.UserConfiguration)); + errors.AddRange(ValidateScrapeDirectories(configuration.ScrapeDirectories)); + + return errors.Count > 0 ? Validation.Invalid(errors) : Validation.Valid(configuration); + } + + private static IEnumerable ValidateSearchConfiguration(SearchConfiguration? searchConfiguration) + { + if (searchConfiguration is null) + { + yield return ValidationErrorFactory.Create(nameof(ScrapeConfiguration.SearchConfiguration), "The search configuration section is missing."); + yield break; + } + + if (string.IsNullOrWhiteSpace(searchConfiguration.BaseUrl)) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.BaseUrl)}", "The base URL is required."); + + if (string.IsNullOrWhiteSpace(searchConfiguration.LoginUrl)) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.LoginUrl)}", "The login URL is required."); + + if (searchConfiguration.ImagePauseInSeconds < 0) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.ImagePauseInSeconds)}", "The image pause cannot be negative."); + + if (searchConfiguration.StartingPageNumber < 1) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.StartingPageNumber)}", "The starting page number must be at least 1."); + } + + private static IEnumerable ValidateUserConfiguration(UserConfiguration? userConfiguration) + { + if (userConfiguration is null) + { + yield return ValidationErrorFactory.Create(nameof(ScrapeConfiguration.UserConfiguration), "The user configuration section is missing."); + yield break; + } + + if (string.IsNullOrWhiteSpace(userConfiguration.LoginEmailAddress)) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.UserConfiguration)}.{nameof(UserConfiguration.LoginEmailAddress)}", "The login email address is required."); + + if (string.IsNullOrWhiteSpace(userConfiguration.Password)) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.UserConfiguration)}.{nameof(UserConfiguration.Password)}", "The password is required."); + } + + private static IEnumerable ValidateScrapeDirectories(ScrapeDirectories? scrapeDirectories) + { + if (scrapeDirectories is null) + { + yield return ValidationErrorFactory.Create(nameof(ScrapeConfiguration.ScrapeDirectories), "The scrape directories section is missing."); + yield break; + } + + if (string.IsNullOrWhiteSpace(scrapeDirectories.RootDirectory)) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.ScrapeDirectories)}.{nameof(ScrapeDirectories.RootDirectory)}", "The root directory is required."); + + if (string.IsNullOrWhiteSpace(scrapeDirectories.BaseSaveDirectory)) + yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.ScrapeDirectories)}.{nameof(ScrapeDirectories.BaseSaveDirectory)}", "The base save directory is required."); + } +} diff --git a/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs index f63c031..a57c9e4 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/ScrapeConfiguration/ScrapeConfigurationView.axaml.cs @@ -1,6 +1,7 @@ using AStar.Dev.FunctionalParadigm; using AStar.Dev.Wallpaper.Scrapper.Models; using AStar.Dev.Wallpaper.Scrapper.Services; +using AStar.Dev.Wallpaper.Scrapper.Workflows; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; @@ -41,38 +42,40 @@ protected override async void OnClosed(EventArgs e) private void OnCloseClicked(object? sender, RoutedEventArgs e) => Close(); private async void OnExportScrapeConfigClicked(object? sender, RoutedEventArgs e) - => _ = await ResetCancellationTokenSource() - .Match( - onSuccess: DisableControlsAndClearStatus, - onFailure: ex => - { - logger.Error(ex, "Failed to reset cancellation token source"); - ViewModel.UpdateStatus($"Error: {ex.Message}"); - return ex.Message; - } - ) - .Tap(_ => { logger.Information("Exporting scrape configuration..."); ViewModel.UpdateStatus("Exporting scrape configuration..."); }) - .MapAsync(_ => scrapeConfigurationService.ExportScrapeConfigurationAsync(cts!.Token)) - .Tap(importExportService.ExportScrapeConfigurationToFile) - .TapAsync(_ => { logger.Information("Scrape configuration export completed..."); ViewModel.UpdateStatus("Export completed."); }) - .EnsureAsync(() => ResetUI()); + { + try + { + _ = await ScrapeSiteWorkflowDecision.DecideAsync( + ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure), + ct => Result.Success(ct) + .Tap(_ => { logger.Information("Exporting scrape configuration..."); ViewModel.UpdateStatus("Exporting scrape configuration..."); }) + .MapAsync(_ => scrapeConfigurationService.ExportScrapeConfigurationAsync(ct)) + .Tap(importExportService.ExportScrapeConfigurationToFile) + .TapAsync(_ => { logger.Information("Scrape configuration export completed..."); ViewModel.UpdateStatus("Export completed."); })); + } + finally + { + ResetUI(); + } + } private async void OnImportScrapeConfigClicked(object? sender, RoutedEventArgs e) - => _ = await ResetCancellationTokenSource() - .Match( - onSuccess: DisableControlsAndClearStatus, - onFailure: ex => - { - logger.Error(ex, "Failed to reset cancellation token source"); - ViewModel.UpdateStatus($"Error: {ex.Message}"); - return ex.Message; - } - ) - .Tap(_ => { logger.Information("Importing scrape configuration..."); ViewModel.UpdateStatus("Importing scrape configuration..."); }) - .Bind(_ => importExportService.ImportScrapeConfigurationFromFile().ToStringError()) - .MapAsync(entity => scrapeConfigurationService.ImportScrapeConfigurationAsync(entity, cts!.Token)) - .TapAsync(_ => { logger.Information("Scrape configuration import completed..."); ViewModel.UpdateStatus("Import completed."); }) - .EnsureAsync(() => ResetUI()); + { + try + { + _ = await ScrapeSiteWorkflowDecision.DecideAsync( + ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure), + ct => Result.Success(ct) + .Tap(_ => { logger.Information("Importing scrape configuration..."); ViewModel.UpdateStatus("Importing scrape configuration..."); }) + .Bind(_ => importExportService.ImportScrapeConfigurationFromFile().ToStringError()) + .MapAsync(entity => scrapeConfigurationService.ImportScrapeConfigurationAsync(entity, ct)) + .TapAsync(_ => { logger.Information("Scrape configuration import completed..."); ViewModel.UpdateStatus("Import completed."); })); + } + finally + { + ResetUI(); + } + } private Result ResetCancellationTokenSource() { @@ -81,13 +84,17 @@ private Result ResetCancellationTokenSource() return cts.Token; } - private Result DisableControlsAndClearStatus(CancellationToken ct = default) + private void LogSetupFailure(Exception exception) + { + logger.Error(exception, "Failed to reset cancellation token source"); + ViewModel.UpdateStatus($"Error: {exception.Message}"); + } + + private void DisableControlsAndClearStatus(CancellationToken ct) { ExportScrapeConfigButton.IsEnabled = false; ImportScrapeConfigButton.IsEnabled = false; ViewModel.UpdateStatus(string.Empty); - - return ct; } private void ResetUI() diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Support/ViewFactoryServiceCollectionExtensions.cs b/src/AStar.Dev.Wallpaper.Scrapper/Support/ViewFactoryServiceCollectionExtensions.cs new file mode 100644 index 0000000..dd7c67f --- /dev/null +++ b/src/AStar.Dev.Wallpaper.Scrapper/Support/ViewFactoryServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace AStar.Dev.Wallpaper.Scrapper.Support; + +/// +/// Registers a view together with a factory so windows can create fresh, +/// fully-composed view instances on demand. +/// +public static class ViewFactoryServiceCollectionExtensions +{ + /// + /// Registers as transient and a transient + /// that resolves a new instance on every invocation. + /// + public static IServiceCollection AddViewFactory(this IServiceCollection services) where TView : class + => services + .AddTransient() + .AddTransient>(sp => sp.GetRequiredService); +} diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs index 51b38a1..10a7f14 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/Tags/TagsView.axaml.cs @@ -2,6 +2,7 @@ using AStar.Dev.Wallpaper.Scrapper.Models; using AStar.Dev.Wallpaper.Scrapper.Services; using AStar.Dev.Wallpaper.Scrapper.Support; +using AStar.Dev.Wallpaper.Scrapper.Workflows; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; @@ -29,37 +30,40 @@ public TagsView(IScrapedTagService scrapedTagService, IImportExportService impor } private async void OnExportTagsClicked(object? sender, RoutedEventArgs e) - => _ = await ResetCancellationTokenSource() - .Match( - onSuccess: DisableControlsAndClearStatus, - onFailure: ex => - { - logger.Error(ex, "Failed to reset cancellation token source"); - UpdateStatus($"Error: {ex.Message}"); - return ex.Message; - } - ) - .Tap(_ => logger.Information("Exporting tags...")) - .MapAsync(_ => scrapedTagService.ExportScrapedTagsAsync(cts!.Token)) - .Tap(importExportService.ExportScrapedTagsToFile) - .TapAsync(_ => logger.Information("Tag export completed...")) - .EnsureAsync(() => ResetUI()); + { + try + { + _ = await ScrapeSiteWorkflowDecision.DecideAsync( + ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure), + ct => Result.Success(ct) + .Tap(_ => logger.Information("Exporting tags...")) + .MapAsync(_ => scrapedTagService.ExportScrapedTagsAsync(ct)) + .Tap(importExportService.ExportScrapedTagsToFile) + .TapAsync(_ => logger.Information("Tag export completed..."))); + } + finally + { + ResetUI(); + } + } private async void OnImportTagsClicked(object? sender, RoutedEventArgs e) - => _ = await ResetCancellationTokenSource() - .Match( - onSuccess: DisableControlsAndClearStatus, - onFailure: ex => - { - logger.Error(ex, "Failed to reset cancellation token source"); - return ex.Message; - } - ) - .Tap(_ => logger.Information("Importing tags...")) - .Bind(_ => importExportService.ImportScrapedTagsFromFile().ToStringError()) - .MapAsync(tags => scrapedTagService.ImportScrapedTagsAsync(tags, cts!.Token)) - .TapAsync(_ => logger.Information("Tag import completed...")) - .EnsureAsync(() => ResetUI()); + { + try + { + _ = await ScrapeSiteWorkflowDecision.DecideAsync( + ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure), + ct => Result.Success(ct) + .Tap(_ => logger.Information("Importing tags...")) + .Bind(_ => importExportService.ImportScrapedTagsFromFile().ToStringError()) + .MapAsync(tags => scrapedTagService.ImportScrapedTagsAsync(tags, ct)) + .TapAsync(_ => logger.Information("Tag import completed..."))); + } + finally + { + ResetUI(); + } + } private Result ResetCancellationTokenSource() { @@ -68,13 +72,17 @@ private Result ResetCancellationTokenSource() return cts.Token; } - private Result DisableControlsAndClearStatus(CancellationToken ct = default) + private void LogSetupFailure(Exception exception) + { + logger.Error(exception, "Failed to reset cancellation token source"); + UpdateStatus($"Error: {exception.Message}"); + } + + private void DisableControlsAndClearStatus(CancellationToken ct) { ExportTagsButton.IsEnabled = false; ImportTagsButton.IsEnabled = false; StatusLabel.Text = string.Empty; - - return ct; } private void ResetUI() diff --git a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/ScrapeSiteWorkflowDecision.cs b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/ScrapeSiteWorkflowDecision.cs index 226e89c..3e3cdd0 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/Workflows/ScrapeSiteWorkflowDecision.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/Workflows/ScrapeSiteWorkflowDecision.cs @@ -4,7 +4,8 @@ namespace AStar.Dev.Wallpaper.Scrapper.Workflows; /// -/// Models the decision made by MainWindow.OnScrapeSiteFunctionalClicked: the scrape workflow must only run +/// Models the decision shared by the UI button handlers (MainWindow, TagsView, +/// ClassificationsView, ScrapeConfigurationView): the workflow must only run /// when the preceding setup step (resetting the cancellation token source / disabling controls) succeeded. /// A failed setup step must short-circuit the chain, and the workflow must never be invoked. /// @@ -13,17 +14,18 @@ public static class ScrapeSiteWorkflowDecision /// /// Decides whether should run, based on the outcome of the setup step. /// - /// The outcome of the setup step that must precede the scrape workflow. - /// The scrape workflow to invoke only when is a success. + /// The outcome of the setup step that must precede the workflow. + /// The workflow to invoke only when is a success. + /// The success type produced by the workflow. /// /// The result of when is a success; otherwise a /// failure carrying the setup error, without invoking . /// - public static Task> DecideAsync(Result setupResult, Func>> workflow) + public static Task> DecideAsync(Result setupResult, Func>> workflow) { GuardAgainst.Null(setupResult); GuardAgainst.Null(workflow); - return setupResult.MatchAsync(workflow, exception => Result.Failure(exception.Message)).AsTask(); + return setupResult.MatchAsync(workflow, exception => Result.Failure(exception.Message)).AsTask(); } } From 3c65babda702c55381cf4a7d22f5009a4663fdb4 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 6 Jul 2026 20:43:12 +0100 Subject: [PATCH 3/4] feat: add auto-approve setting for dotnet test in VSCode configuration --- .vscode/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 33229a7..686339b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,8 @@ "tagname", "topwallpapers", "Xunit" - ] + ], + "chat.tools.terminal.autoApprove": { + "dotnet test": true + } } \ No newline at end of file From abd4f6a0647d798911ca1fe0a0b8955b9c752f73 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 6 Jul 2026 20:47:31 +0100 Subject: [PATCH 4/4] feat: update CI permissions and add auto-approve for git push in VSCode settings --- .github/workflows/ci.yaml | 3 +++ .vscode/settings.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 51317cd..fee60e5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,9 @@ on: jobs: build: + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest steps: diff --git a/.vscode/settings.json b/.vscode/settings.json index 686339b..ab108c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "Xunit" ], "chat.tools.terminal.autoApprove": { - "dotnet test": true + "dotnet test": true, + "git push": true } } \ No newline at end of file