From 4a5719e00c9bef6134dcff43ffd8574ee85f9e11 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Fri, 26 Jun 2026 23:28:25 +0100 Subject: [PATCH 1/5] refactor: update CLAUDE.md for clarity and structure; add non-negotiable rules feat: add original C# developer guidelines file fix: update settings.json to include 'graphify' in spell check --- .claude/agents/c-sharp-architect.md | 2 +- .claude/agents/c-sharp-dev.md | 50 ++++---- .claude/agents/c-sharp-dev.original.md | 151 +++++++++++++++++++++++++ .vscode/settings.json | 1 + CLAUDE.md | 38 ++++--- CLAUDE.original.md | 65 +++++++++++ 6 files changed, 264 insertions(+), 43 deletions(-) create mode 100644 .claude/agents/c-sharp-dev.original.md create mode 100644 CLAUDE.original.md diff --git a/.claude/agents/c-sharp-architect.md b/.claude/agents/c-sharp-architect.md index 7a47c06..0013841 100644 --- a/.claude/agents/c-sharp-architect.md +++ b/.claude/agents/c-sharp-architect.md @@ -1,7 +1,7 @@ --- name: c-sharp-architect description: Senior C# / .NET 10 architect for the AStar.Dev mono-repo. Designs solution structure, package boundaries, Blazor web app architecture, and Avalonia desktop app architecture. Use for technology selection, cross-cutting concerns design, ADRs, integration contracts, and any decision that affects multiple projects or the shape of the solution. -tools: Read, Grep, Glob, Bash +tools: Read, Grep, Glob, Bash, Write model: sonnet color: red --- diff --git a/.claude/agents/c-sharp-dev.md b/.claude/agents/c-sharp-dev.md index 516a905..fdd1840 100644 --- a/.claude/agents/c-sharp-dev.md +++ b/.claude/agents/c-sharp-dev.md @@ -6,13 +6,13 @@ model: sonnet color: red --- -You are a senior C# 14 / .NET 10 engineer in the AStar.Dev mono-repo. Follow @CLAUDE.md at all times. +Senior C# 14 / .NET 10 engineer in AStar.Dev mono-repo. Follow @CLAUDE.md always. ## Readability > Code is read far more often than it is written. -See @/.claude/rules/c-sharp-code-style.md for naming, classes, immutability, record, and control-flow conventions. Additional rules: +See @/.claude/rules/c-sharp-code-style.md for naming, classes, immutability, record, control-flow conventions. - Explicit over clever. Clear `if` beats obscure one-liner. @@ -33,7 +33,7 @@ See @/.claude/rules/c-sharp-code-style.md for naming, classes, immutability, rec | `await foreach` | Async streams (`IAsyncEnumerable`) | | `ConfigureAwait(false)` | All `await` in library/package code | -File-scoped namespaces and implicit usings are global — never add redundant `using` for `Xunit`, `Shouldly`, or `NSubstitute`. +File-scoped namespaces and implicit usings global — never add redundant `using` for `Xunit`, `Shouldly`, or `NSubstitute`. ## Functional patterns (AStar.Dev.Functional.Extensions) @@ -45,9 +45,9 @@ File-scoped namespaces and implicit usings are global — never add redundant `u | Chain operations that each can fail | `.Bind` / `.Map` | - Don't wrap `void` side-effects in `Result`. -- Don't chain more than ~5 `.Bind`/`.Map` without naming intermediate results — extract a method. -- Never let a chain obscure a business rule; a named method beats an anonymous lambda. -- **Never await a `Task>` into an intermediate variable just to call `.Match()` on the next line.** Chain `.MatchAsync()` directly on the task. The intermediate variable pattern is always wrong: +- Don't chain more than ~5 `.Bind`/`.Map` without naming intermediate results — extract method. +- Named method beats anonymous lambda when chain obscures business rule. +- **Never await `Task>` into intermediate variable just to call `.Match()` next line.** Chain `.MatchAsync()` directly. Intermediate variable pattern always wrong: ```csharp // ❌ wrong — unnecessary intermediate var result = await service.GetAsync(ct); @@ -57,7 +57,7 @@ File-scoped namespaces and implicit usings are global — never add redundant `u var value = await service.GetAsync(ct) .MatchAsync(ok => ok.Value, _ => null); ``` -- Error-branch code (logging, setting error properties) belongs **inside** the error lambda, not after the Match in a separate `if` block. A Match followed by a null-check where the null-check body duplicates what the error branch should have done is the same mistake. +- Error-branch code (logging, setting error properties) belongs **inside** error lambda, not after Match in separate `if` block. Match followed by null-check that duplicates error branch = same mistake. ## Project conventions @@ -76,33 +76,33 @@ Organise by **business feature**, not technical artefact type. Namespace mirrors Exceptions: genuinely cross-cutting infrastructure (`Middleware/`, `Extensions/`, `Abstractions/`). -For legacy code: apply if the refactor is small; otherwise raise a GitHub issue. +Legacy code: apply if refactor small; otherwise raise GitHub issue. ## Architecture ### Dependency injection -- Primary constructors for injection; no explicit field unless needed in an expression-bodied member. -- **ReactiveUI exception**: `ReactiveCommand.CreateFromTask(InstanceMethod)` requires `this` — use an explicit constructor with `private readonly` fields. Not a violation; do not flag. +- Primary constructors for injection; no explicit field unless needed in expression-bodied member. +- **ReactiveUI exception**: `ReactiveCommand.CreateFromTask(InstanceMethod)` requires `this` — use explicit constructor with `private readonly` fields. Not a violation; don't flag. - Register in `IServiceCollection` extension methods, one file per feature area. ### Avalonia XAML (compiled bindings) -`AvaloniaUseCompiledBindingsByDefault=true` is set globally. Every view with bindings **must** declare `x:DataType`: +`AvaloniaUseCompiledBindingsByDefault=true` set globally. Every view with bindings **must** declare `x:DataType`: ```xml ``` -Omitting it causes `AVLN2100` build errors. +Omitting causes `AVLN2100` build errors. -- Tree controls: use `TreeDataTemplate`, **not** `HierarchicalDataTemplate` — the latter is WPF and does not exist in Avalonia. -- When `CompiledBinding` causes binding errors that cannot be resolved statically, fall back to `ReflectionBinding` on the specific binding — document with a comment why. +- Tree controls: use `TreeDataTemplate`, **not** `HierarchicalDataTemplate` — latter is WPF, doesn't exist in Avalonia. +- When `CompiledBinding` causes unresolvable static binding errors, fall back to `ReflectionBinding` on specific binding — document with comment why. ### Avalonia DI lifetimes - No HTTP scope — register `DbContext` and ViewModels as `Transient`. -- Never `AddScoped` outside a web host (maps to app lifetime = singleton). +- Never `AddScoped` outside web host (maps to app lifetime = singleton). ### HTTP (Refit + Polly) @@ -112,34 +112,34 @@ Omitting it causes `AVLN2100` build errors. ### EF Core 10 -- No raw SQL except read-model queries where performance demands it; document why. +- No raw SQL except read-model queries where performance demands; document why. - `AsNoTracking()` on all read-only queries. -- Entity IDs etc should be strongly-typed wherever possible; do not use GUID, string, int when the entity type is a key part of the domain -- Migrations in the infra project that owns the `DbContext`. +- Entity IDs: strongly-typed wherever possible; don't use GUID, string, int when entity type is domain key. +- Migrations in infra project owning `DbContext`. - Value objects via `OwnsOne` / `OwnsMany`; no primitive obsession on entity keys. - Always `IEntityTypeConfiguration`; always load via `ApplyConfigurationsFromAssembly`. -- **Concurrent access**: inject `IDbContextFactory` and call `CreateDbContextAsync()` per operation — never inject `DbContext` directly into services that may be called concurrently (Avalonia has no HTTP scope; a shared `DbContext` is not thread-safe). +- **Concurrent access**: inject `IDbContextFactory`, call `CreateDbContextAsync()` per operation — never inject `DbContext` directly into services called concurrently (Avalonia has no HTTP scope; shared `DbContext` not thread-safe). ### Logging (Serilog) - Structured only — no string interpolation in log messages. -- Log at the boundary and error site; no redundant intermediate logs. +- Log at boundary and error site; no redundant intermediate logs. - No PII/secrets — use `HashedUserId` pattern; redact with `Serilog.Expressions` if needed. ### Validation (FluentValidation) -- Validators are `sealed`, registered via assembly scanning. +- Validators `sealed`, registered via assembly scanning. - Return `Result.Failure(validationErrors)` from pipeline behaviour; never throw. ## Tests -- All new public methods must have full unit tests exercising all branches wherever possible. +All new public methods need full unit tests exercising all branches wherever possible. ## Code review checklist - [ ] Mid-level dev understands in 30 s without comments? -- [ ] No inline comments describing **what** — extract a named method instead -- [ ] No suppressions without a comment +- [ ] No inline comments describing **what** — extract named method instead +- [ ] No suppressions without comment - [ ] No `async void` (except Avalonia event handlers — documented) - [ ] `CancellationToken` propagated through all async chains - [ ] No blocking calls (`.Result`, `.Wait()`, `.GetAwaiter().GetResult()`) in async context @@ -148,4 +148,4 @@ Omitting it causes `AVLN2100` build errors. - [ ] Structured log messages (no interpolated strings to Serilog) - [ ] New package `.csproj` has required metadata fields - [ ] User Story checklist items marked done -- [ ] One Class / Interface / Record per file +- [ ] One Class / Interface / Record per file \ No newline at end of file diff --git a/.claude/agents/c-sharp-dev.original.md b/.claude/agents/c-sharp-dev.original.md new file mode 100644 index 0000000..516a905 --- /dev/null +++ b/.claude/agents/c-sharp-dev.original.md @@ -0,0 +1,151 @@ +--- +name: c-sharp-dev +description: Senior C# 14 / .NET 10 developer for the AStar.Dev mono-repo. Writes clean, readable, idiomatic C# code following repo conventions, functional-first patterns via AStar.Dev.Functional.Extensions, and fully-tested discipline. Use for implementing C# features, designing APIs, and extracting C# shared utilities. +tools: Read, Grep, Glob, Bash, Write +model: sonnet +color: red +--- + +You are a senior C# 14 / .NET 10 engineer in the AStar.Dev mono-repo. Follow @CLAUDE.md at all times. + +## Readability + +> Code is read far more often than it is written. + +See @/.claude/rules/c-sharp-code-style.md for naming, classes, immutability, record, and control-flow conventions. Additional rules: + +- Explicit over clever. Clear `if` beats obscure one-liner. + +## C# 14 / .NET 10 — use these, flag their absence + +| Feature | When | +| ----------------------------------------------- | ---------------------------------------------------- | +| Primary constructors | Constructor injection | +| Collection expressions `[x, y]` / `[..src, z]` | Replacing `new List { }`, `new[] { }` | +| `field` keyword | Semi-auto properties needing one customised accessor | +| `params ReadOnlySpan` | Helpers formerly using `params T[]` | +| `required` properties | DTOs and builders | +| `nameof` + `ArgumentNullException.ThrowIfNull` | All public-API null guards | +| `using` declarations (not blocks) | Short-lived `IDisposable` in method scope | +| Pattern matching (`is T x`, switch expressions) | Replacing `as` casts and type checks | +| `FrozenDictionary` / `FrozenSet` | Read-only lookup tables built at startup | +| `[GeneratedRegex]` | All `Regex` usage — never `new Regex(...)` | +| `await foreach` | Async streams (`IAsyncEnumerable`) | +| `ConfigureAwait(false)` | All `await` in library/package code | + +File-scoped namespaces and implicit usings are global — never add redundant `using` for `Xunit`, `Shouldly`, or `NSubstitute`. + +## Functional patterns (AStar.Dev.Functional.Extensions) + +| Scenario | Use | +| ------------------------------------------- | ------------------------ | +| Can succeed or fail with a meaningful error | `Result` | +| Value may or may not be present | `Option` | +| Branch on success/failure | `.Match` / `.MatchAsync` | +| Chain operations that each can fail | `.Bind` / `.Map` | + +- Don't wrap `void` side-effects in `Result`. +- Don't chain more than ~5 `.Bind`/`.Map` without naming intermediate results — extract a method. +- Never let a chain obscure a business rule; a named method beats an anonymous lambda. +- **Never await a `Task>` into an intermediate variable just to call `.Match()` on the next line.** Chain `.MatchAsync()` directly on the task. The intermediate variable pattern is always wrong: + ```csharp + // ❌ wrong — unnecessary intermediate + var result = await service.GetAsync(ct); + var value = result.Match(ok => ok.Value, _ => null); + + // ✅ correct — chain directly + var value = await service.GetAsync(ct) + .MatchAsync(ok => ok.Value, _ => null); + ``` +- Error-branch code (logging, setting error properties) belongs **inside** the error lambda, not after the Match in a separate `if` block. A Match followed by a null-check where the null-check body duplicates what the error branch should have done is the same mistake. + +## Project conventions + +### Folder and namespace — feature over artefact type + +Organise by **business feature**, not technical artefact type. Namespace mirrors folder path. + +``` +✅ AccountManagement/ + AccountManagementEditViewModel.cs + EditAccountCommand.cs + EditAccountCommandHandler.cs + +❌ ViewModels/ Commands/ Validators/ ← tells you nothing about the domain +``` + +Exceptions: genuinely cross-cutting infrastructure (`Middleware/`, `Extensions/`, `Abstractions/`). + +For legacy code: apply if the refactor is small; otherwise raise a GitHub issue. + +## Architecture + +### Dependency injection + +- Primary constructors for injection; no explicit field unless needed in an expression-bodied member. +- **ReactiveUI exception**: `ReactiveCommand.CreateFromTask(InstanceMethod)` requires `this` — use an explicit constructor with `private readonly` fields. Not a violation; do not flag. +- Register in `IServiceCollection` extension methods, one file per feature area. + +### Avalonia XAML (compiled bindings) + +`AvaloniaUseCompiledBindingsByDefault=true` is set globally. Every view with bindings **must** declare `x:DataType`: + +```xml + +``` + +Omitting it causes `AVLN2100` build errors. + +- Tree controls: use `TreeDataTemplate`, **not** `HierarchicalDataTemplate` — the latter is WPF and does not exist in Avalonia. +- When `CompiledBinding` causes binding errors that cannot be resolved statically, fall back to `ReflectionBinding` on the specific binding — document with a comment why. + +### Avalonia DI lifetimes + +- No HTTP scope — register `DbContext` and ViewModels as `Transient`. +- Never `AddScoped` outside a web host (maps to app lifetime = singleton). + +### HTTP (Refit + Polly) + +- `[Headers("Accept: application/json")]` at interface level. +- Polly pipelines at registration, not call sites. +- Wrap Refit results in `Result` at service layer — callers never see `ApiException`. + +### EF Core 10 + +- No raw SQL except read-model queries where performance demands it; document why. +- `AsNoTracking()` on all read-only queries. +- Entity IDs etc should be strongly-typed wherever possible; do not use GUID, string, int when the entity type is a key part of the domain +- Migrations in the infra project that owns the `DbContext`. +- Value objects via `OwnsOne` / `OwnsMany`; no primitive obsession on entity keys. +- Always `IEntityTypeConfiguration`; always load via `ApplyConfigurationsFromAssembly`. +- **Concurrent access**: inject `IDbContextFactory` and call `CreateDbContextAsync()` per operation — never inject `DbContext` directly into services that may be called concurrently (Avalonia has no HTTP scope; a shared `DbContext` is not thread-safe). + +### Logging (Serilog) + +- Structured only — no string interpolation in log messages. +- Log at the boundary and error site; no redundant intermediate logs. +- No PII/secrets — use `HashedUserId` pattern; redact with `Serilog.Expressions` if needed. + +### Validation (FluentValidation) + +- Validators are `sealed`, registered via assembly scanning. +- Return `Result.Failure(validationErrors)` from pipeline behaviour; never throw. + +## Tests + +- All new public methods must have full unit tests exercising all branches wherever possible. + +## Code review checklist + +- [ ] Mid-level dev understands in 30 s without comments? +- [ ] No inline comments describing **what** — extract a named method instead +- [ ] No suppressions without a comment +- [ ] No `async void` (except Avalonia event handlers — documented) +- [ ] `CancellationToken` propagated through all async chains +- [ ] No blocking calls (`.Result`, `.Wait()`, `.GetAwaiter().GetResult()`) in async context +- [ ] `ConfigureAwait(false)` on all `await` in library code +- [ ] NO magic strings / numbers etc; use constants or enums. Extract to project-level when shared. +- [ ] Structured log messages (no interpolated strings to Serilog) +- [ ] New package `.csproj` has required metadata fields +- [ ] User Story checklist items marked done +- [ ] One Class / Interface / Record per file diff --git a/.vscode/settings.json b/.vscode/settings.json index 2901269..e4bbcbb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "Consolas", "Deserialisation", "Dtos", + "graphify", "Initialise", "KHTML", "safebrowsing", diff --git a/CLAUDE.md b/CLAUDE.md index b461c2f..eddbe6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,6 @@ - - # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code when working in this repo. ## Commands @@ -20,32 +18,38 @@ dotnet test --filter "FullyQualifiedName~DownloadImagesFromSearchResults" dotnet test AStar.Dev.Wallpaper.Scrapper/AStar.Dev.Wallpaper.Scrapper.csproj ``` +## NON-NEGOTIABLE RULES + +ALWAYS use serena / graphify to aid understanding / you need to explore the code-base. NO EXCEPTIONS +ALWAYS use TDD - use the c-sharp-qa subagent to write failing tests and COMMIT before using c-sharp-dev subagent to implement the production code. Ensure all tests (new and existing) pass before reporting success. +ALWAYS use the c-sharp-reviewer subagent to review tests and production code once the c-sharp-dev subagent reports completion. ALWAYS fix the issues reported - use a new c-sharp-dev subagent to fix. + ## Architecture -Solution targets **net10.0** across all projects. `TreatWarningsAsErrors` is enabled in both Debug and Release configurations. +Targets **net10.0** across all projects. `TreatWarningsAsErrors` enabled in Debug and Release. ### Projects -**`AStar.Dev.Wallpaper.Scrapper`** — the main executable. Structured as a **Reqnroll (BDD) test project** — there is no `Main()`; the test runner executes Gherkin scenarios that drive the scraping workflow. +**`AStar.Dev.Wallpaper.Scrapper`** — main executable. Structured as **Reqnroll (BDD) test project** — no `Main()`; test runner executes Gherkin scenarios driving scrape workflow. -- `Features/` — Gherkin `.feature` files (three scraping workflows: search results, subscriptions, top wallpapers) +- `Features/` — Gherkin `.feature` files (three workflows: search results, subscriptions, top wallpapers) - `StepDefinitions/` — Reqnroll step bindings wired to Playwright page objects - `Pages/` — Page Object Model classes wrapping Playwright `IPage` (login, search results, image page, subscriptions, top wallpapers) -- `Hooks/Hooks.cs` — Reqnroll lifecycle: spins up Playwright Chromium/Edge browser, builds Serilog logger, registers all DI instances into Reqnroll's `ObjectContainer` -- `Support/` — helpers: `ConfigurationFactory` (loads config), `ConfigurationSaver`, `DirectoryHelper`, `ImageRetrieverHelper`, `ImageSaveHelper`, `TagsFactory` +- `Hooks/Hooks.cs` — Reqnroll lifecycle: spins up Playwright Chromium/Edge, builds Serilog logger, registers DI into Reqnroll's `ObjectContainer` +- `Support/` — helpers: `ConfigurationFactory`, `ConfigurationSaver`, `DirectoryHelper`, `ImageRetrieverHelper`, `ImageSaveHelper`, `TagsFactory` - `Models/` — strongly-typed bindings for `appsettings.json` sections (`ScrapeConfiguration`, `SearchConfiguration`, `ScrapeDirectories`, `UserConfiguration`, etc.) - `DTOs/` — deserialization models for `tagsTextToIgnore.json` and `tagsToIgnoreCompletely.json` **`AStar.Dev.Guard.Clauses`** — NuGet package. Single `GuardAgainst.Null()` helper. -**`AStar.Dev.Infrastructure.FilesDb`** — NuGet package. EF Core (SQL Server) infrastructure for file metadata. `EnumerableExtensions` provides filtering/sorting/duplicate-detection over `FileDetail` collections. Note: marked with `[Refactor]` — may duplicate logic in the files API project. +**`AStar.Dev.Infrastructure.FilesDb`** — NuGet package. EF Core (SQL Server) infrastructure for file metadata. `EnumerableExtensions` provides filtering/sorting/duplicate-detection over `FileDetail` collections. Marked `[Refactor]` — may duplicate logic in files API project. -**`AStar.Dev.Utilities`** — NuGet package. Extension methods and helpers shared across AStar packages (`StringExtensions`, `LinqExtensions`, `EncryptionExtensions`, `EnumExtensions`, `PathOperationExtensions`, `RegexExtensions`, `ObjectExtensions`, `ApplicationPathsProvider`). +**`AStar.Dev.Utilities`** — NuGet package. Shared extension methods/helpers (`StringExtensions`, `LinqExtensions`, `EncryptionExtensions`, `EnumExtensions`, `PathOperationExtensions`, `RegexExtensions`, `ObjectExtensions`, `ApplicationPathsProvider`). ### Configuration -`ConfigurationFactory` merges two sources (in order, later overrides earlier): -1. `appsettings.json` in the project root — non-secret defaults +`ConfigurationFactory` merges two sources (later overrides earlier): +1. `appsettings.json` in project root — non-secret defaults 2. **User Secrets** (`UserSecretsId: c35e09dc-dc30-416a-95a6-ec1a5ba1b43f`) — SQL connection string, login credentials Sensitive fields (`connectionStrings.sqlServer`, `userConfiguration.password`) must live in User Secrets, not `appsettings.json`. @@ -54,12 +58,12 @@ Sensitive fields (`connectionStrings.sqlServer`, `userConfiguration.password`) m ### Logging -Serilog, configured via `appsettings.json`. Writes to Console and Seq (`http://localhost:5341`). The `Hooks` constructor also hard-codes a Seq sink at the same address. +Serilog via `appsettings.json`. Writes to Console and Seq (`http://localhost:5341`). `Hooks` constructor also hard-codes Seq sink at same address. ### Tag filtering -Two JSON files control which wallpapers are skipped: -- `tagsToIgnoreCompletely.json` — skip any image whose tags include these values -- `tagsTextToIgnore.json` — skip based on partial text match in tag names +Two JSON files control skipped wallpapers: +- `tagsToIgnoreCompletely.json` — skip images whose tags include these values +- `tagsTextToIgnore.json` — skip on partial text match in tag names -Both are loaded by `TagsFactory` and registered into Reqnroll's DI container in `Hooks`. +Both loaded by `TagsFactory`, registered into Reqnroll DI in `Hooks`. \ No newline at end of file diff --git a/CLAUDE.original.md b/CLAUDE.original.md new file mode 100644 index 0000000..b461c2f --- /dev/null +++ b/CLAUDE.original.md @@ -0,0 +1,65 @@ + + +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Build entire solution +dotnet build AStar.Dev.Web.Scrapper.slnx + +# Run all Reqnroll scenarios +dotnet test AStar.Dev.Web.Scrapper.slnx + +# Run a specific scenario by name +dotnet test --filter "FullyQualifiedName~DownloadImagesFromSearchResults" + +# Run a specific project's tests +dotnet test AStar.Dev.Wallpaper.Scrapper/AStar.Dev.Wallpaper.Scrapper.csproj +``` + +## Architecture + +Solution targets **net10.0** across all projects. `TreatWarningsAsErrors` is enabled in both Debug and Release configurations. + +### Projects + +**`AStar.Dev.Wallpaper.Scrapper`** — the main executable. Structured as a **Reqnroll (BDD) test project** — there is no `Main()`; the test runner executes Gherkin scenarios that drive the scraping workflow. + +- `Features/` — Gherkin `.feature` files (three scraping workflows: search results, subscriptions, top wallpapers) +- `StepDefinitions/` — Reqnroll step bindings wired to Playwright page objects +- `Pages/` — Page Object Model classes wrapping Playwright `IPage` (login, search results, image page, subscriptions, top wallpapers) +- `Hooks/Hooks.cs` — Reqnroll lifecycle: spins up Playwright Chromium/Edge browser, builds Serilog logger, registers all DI instances into Reqnroll's `ObjectContainer` +- `Support/` — helpers: `ConfigurationFactory` (loads config), `ConfigurationSaver`, `DirectoryHelper`, `ImageRetrieverHelper`, `ImageSaveHelper`, `TagsFactory` +- `Models/` — strongly-typed bindings for `appsettings.json` sections (`ScrapeConfiguration`, `SearchConfiguration`, `ScrapeDirectories`, `UserConfiguration`, etc.) +- `DTOs/` — deserialization models for `tagsTextToIgnore.json` and `tagsToIgnoreCompletely.json` + +**`AStar.Dev.Guard.Clauses`** — NuGet package. Single `GuardAgainst.Null()` helper. + +**`AStar.Dev.Infrastructure.FilesDb`** — NuGet package. EF Core (SQL Server) infrastructure for file metadata. `EnumerableExtensions` provides filtering/sorting/duplicate-detection over `FileDetail` collections. Note: marked with `[Refactor]` — may duplicate logic in the files API project. + +**`AStar.Dev.Utilities`** — NuGet package. Extension methods and helpers shared across AStar packages (`StringExtensions`, `LinqExtensions`, `EncryptionExtensions`, `EnumExtensions`, `PathOperationExtensions`, `RegexExtensions`, `ObjectExtensions`, `ApplicationPathsProvider`). + +### Configuration + +`ConfigurationFactory` merges two sources (in order, later overrides earlier): +1. `appsettings.json` in the project root — non-secret defaults +2. **User Secrets** (`UserSecretsId: c35e09dc-dc30-416a-95a6-ec1a5ba1b43f`) — SQL connection string, login credentials + +Sensitive fields (`connectionStrings.sqlServer`, `userConfiguration.password`) must live in User Secrets, not `appsettings.json`. + +`ConfigurationFactory` also normalises `SearchConfiguration.SearchString` and `Subscriptions` URLs (strips base URL prefix and trailing page number) before returning. + +### Logging + +Serilog, configured via `appsettings.json`. Writes to Console and Seq (`http://localhost:5341`). The `Hooks` constructor also hard-codes a Seq sink at the same address. + +### Tag filtering + +Two JSON files control which wallpapers are skipped: +- `tagsToIgnoreCompletely.json` — skip any image whose tags include these values +- `tagsTextToIgnore.json` — skip based on partial text match in tag names + +Both are loaded by `TagsFactory` and registered into Reqnroll's DI container in `Hooks`. From 003414c1273f7781cabe772bc0900319ebaca36a Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Fri, 26 Jun 2026 23:41:24 +0100 Subject: [PATCH 2/5] test: add failing tests for ScrapedTag export/import (TDD red phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 new tests in GivenAnImportExportService covering tag file I/O paths. 4 new tests in GivenAScrapedTagService covering export and upsert behaviour. All fail to compile — production interfaces/classes do not exist yet. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/GivenAScrapedTagService.cs | 60 ++++++ .../Services/GivenAnImportExportService.cs | 174 +++++++++++++++++- 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAScrapedTagService.cs diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAScrapedTagService.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAScrapedTagService.cs new file mode 100644 index 0000000..76a0200 --- /dev/null +++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAScrapedTagService.cs @@ -0,0 +1,60 @@ +using AStar.Dev.Wallpaper.Scrapper.Repositories; +using AStar.Dev.Wallpaper.Scrapper.Services; +using ScrapedTagDomain = AStar.Dev.Infrastructure.FilesDb.Models.ScrapedTag; + +namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.Services; + +public sealed class GivenAScrapedTagService +{ + private const string ActionTagValue = "Action"; + private const string ActionTagCategory = "Genre"; + + [Fact] + public async Task when_exporting_then_repository_get_all_is_called() + { + var repo = Substitute.For(); + repo.GetAllAsync(Arg.Any()).Returns(Task.FromResult>([])); + var sut = new ScrapedTagService(repo); + + await sut.ExportScrapedTagsAsync(CancellationToken.None); + + await repo.Received(1).GetAllAsync(Arg.Any()); + } + + [Fact] + public async Task when_exporting_then_returned_tags_are_passed_through() + { + var repo = Substitute.For(); + var expected = new List { new() { Value = ActionTagValue, Category = ActionTagCategory, IncludeInSearch = true } }; + repo.GetAllAsync(Arg.Any()).Returns(Task.FromResult(expected)); + var sut = new ScrapedTagService(repo); + + var result = await sut.ExportScrapedTagsAsync(CancellationToken.None); + + result.ShouldBe(expected); + } + + [Fact] + public async Task when_importing_new_tag_then_it_is_added() + { + var repo = Substitute.For(); + var sut = new ScrapedTagService(repo); + var tags = new List { new() { Value = ActionTagValue, Category = ActionTagCategory, IncludeInSearch = true } }; + + await sut.ImportScrapedTagsAsync(tags, CancellationToken.None); + + await repo.Received(1).UpsertAsync(Arg.Is>(list => list.Count == 1), Arg.Any()); + } + + [Fact] + public async Task when_importing_existing_tag_then_include_in_search_is_updated() + { + var repo = Substitute.For(); + var sut = new ScrapedTagService(repo); + var tags = new List { new() { Value = ActionTagValue, Category = ActionTagCategory, IncludeInSearch = false } }; + + await sut.ImportScrapedTagsAsync(tags, CancellationToken.None); + + await repo.Received(1).UpsertAsync(Arg.Is>(list => !list[0].IncludeInSearch), Arg.Any()); + } +} diff --git a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAnImportExportService.cs b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAnImportExportService.cs index 11a7793..0f4e2b8 100644 --- a/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAnImportExportService.cs +++ b/test/AStar.Dev.Wallpaper.Scrapper.Tests.Unit/Services/GivenAnImportExportService.cs @@ -4,16 +4,22 @@ using Serilog; using System.IO.Abstractions; using FileClassificationDomain = AStar.Dev.Infrastructure.FilesDb.Models.FileClassification; +using ScrapedTagDomain = AStar.Dev.Infrastructure.FilesDb.Models.ScrapedTag; namespace AStar.Dev.Wallpaper.Scrapper.Tests.Unit.Services; public sealed class GivenAnImportExportService { - private static readonly string ScrapperDirectory = Path.GetDirectoryName(ApplicationMetadata.FileClassificationsExportFilePath)!; + private static readonly string ScrapperDirectory = Path.GetDirectoryName(ApplicationMetadata.FileClassificationsExportFilePath)!; + private static readonly string ScrapperTagsDirectory = Path.GetDirectoryName(ApplicationMetadata.ScrapedTagsExportFilePath)!; private const string CelebrityClassificationName = "Test Celebrity"; private const string NormalClassificationName = "Test Normal"; + private const string ActionTagValue = "Action"; + private const string ActionTagCategory = "Genre"; + private const string ComedyTagValue = "Comedy"; + private const string ValidClassificationsJson = """ [ { @@ -53,6 +59,21 @@ public sealed class GivenAnImportExportService ] """; + private const string ValidTagsJson = """ + [ + { + "value": "Action", + "category": "Genre", + "includeInSearch": true + }, + { + "value": "Comedy", + "category": "Genre", + "includeInSearch": false + } + ] + """; + private readonly MockFileSystem mockFileSystem; private readonly ILogger mockLogger; private readonly IImportExportService sut; @@ -183,15 +204,166 @@ public void when_file_system_throws_during_export_then_logger_receives_error_cal mockLogger.Received(1).Error(Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public void when_importing_tags_and_file_does_not_exist_then_failure_result_is_returned() => + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>(); + + [Fact] + public void when_importing_tags_and_file_does_not_exist_then_logger_receives_error_call() + { + sut.ImportScrapedTagsFromFile(); + + mockLogger.Received(1).Error(Arg.Any(), Arg.Any()); + } + + [Fact] + public void when_importing_tags_and_file_contains_null_json_then_failure_result_is_returned() + { + mockFileSystem.Directory.CreateDirectory(ScrapperTagsDirectory); + mockFileSystem.File.WriteAllText(ApplicationMetadata.ScrapedTagsExportFilePath, "null"); + + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>(); + } + + [Fact] + public void when_importing_tags_and_file_contains_null_json_then_logger_receives_error_call() + { + mockFileSystem.Directory.CreateDirectory(ScrapperTagsDirectory); + mockFileSystem.File.WriteAllText(ApplicationMetadata.ScrapedTagsExportFilePath, "null"); + + sut.ImportScrapedTagsFromFile(); + + mockLogger.Received(1).Error(Arg.Any(), Arg.Any()); + } + + [Fact] + public void when_importing_valid_tags_then_result_is_ok() + { + SetupValidTagsImportFile(); + + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>(); + } + + [Fact] + public void when_importing_valid_tags_then_correct_count_is_returned() + { + SetupValidTagsImportFile(); + + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>() + .Value.Count.ShouldBe(2); + } + + [Fact] + public void when_importing_valid_tags_then_first_tag_value_is_mapped() + { + SetupValidTagsImportFile(); + + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>() + .Value[0].Value.ShouldBe(ActionTagValue); + } + + [Fact] + public void when_importing_valid_tags_then_first_tag_category_is_mapped() + { + SetupValidTagsImportFile(); + + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>() + .Value[0].Category.ShouldBe(ActionTagCategory); + } + + [Fact] + public void when_importing_valid_tags_then_first_tag_include_in_search_is_mapped() + { + SetupValidTagsImportFile(); + + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>() + .Value[0].IncludeInSearch.ShouldBeTrue(); + } + + [Fact] + public void when_importing_valid_tags_then_second_tag_value_is_mapped() + { + SetupValidTagsImportFile(); + + sut.ImportScrapedTagsFromFile() + .ShouldBeOfType, string>>() + .Value[1].Value.ShouldBe(ComedyTagValue); + } + + [Fact] + public void when_exporting_tags_then_file_is_written_to_expected_path() + { + mockFileSystem.Directory.CreateDirectory(ScrapperTagsDirectory); + + sut.ExportScrapedTagsToFile(CreateDomainTags()); + + mockFileSystem.File.Exists(ApplicationMetadata.ScrapedTagsExportFilePath).ShouldBeTrue(); + } + + [Fact] + public void when_exporting_tags_then_logger_receives_information_call() + { + mockFileSystem.Directory.CreateDirectory(ScrapperTagsDirectory); + + sut.ExportScrapedTagsToFile(CreateDomainTags()); + + mockLogger.Received(1).Information(Arg.Any(), Arg.Any()); + } + + [Fact] + public void when_file_system_throws_during_tag_export_then_exception_is_rethrown() + { + var throwingFileSystem = Substitute.For(); + throwingFileSystem.File.When(f => f.WriteAllText(Arg.Any(), Arg.Any())) + .Throw(new IOException("Disk full")); + var throwingSut = new ImportExportService(throwingFileSystem, mockLogger); + + var act = () => throwingSut.ExportScrapedTagsToFile([]); + + act.ShouldThrow(); + } + + [Fact] + public void when_file_system_throws_during_tag_export_then_logger_receives_error_call() + { + var throwingFileSystem = Substitute.For(); + throwingFileSystem.File.When(f => f.WriteAllText(Arg.Any(), Arg.Any())) + .Throw(new IOException("Disk full")); + var throwingSut = new ImportExportService(throwingFileSystem, mockLogger); + + Should.Throw(() => throwingSut.ExportScrapedTagsToFile([])); + + mockLogger.Received(1).Error(Arg.Any(), Arg.Any(), Arg.Any()); + } + private void SetupValidImportFile() { mockFileSystem.Directory.CreateDirectory(ScrapperDirectory); mockFileSystem.File.WriteAllText(ApplicationMetadata.FileClassificationsExportFilePath, ValidClassificationsJson); } + private void SetupValidTagsImportFile() + { + mockFileSystem.Directory.CreateDirectory(ScrapperTagsDirectory); + mockFileSystem.File.WriteAllText(ApplicationMetadata.ScrapedTagsExportFilePath, ValidTagsJson); + } + private static List CreateDomainClassifications() => [ new() { Id = 1, Name = CelebrityClassificationName, Celebrity = true, IncludeInSearch = true }, new() { Id = 2, Name = NormalClassificationName, Celebrity = false, IncludeInSearch = true } ]; + + private static List CreateDomainTags() => + [ + new() { Value = ActionTagValue, Category = ActionTagCategory, IncludeInSearch = true }, + new() { Value = ComedyTagValue, Category = ActionTagCategory, IncludeInSearch = false } + ]; } From 023210a2b53986d61403d9ef3703a28d33cc959b Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Sat, 27 Jun 2026 03:15:10 +0100 Subject: [PATCH 3/5] feat: export/import for ScrapedTags (#46) - ApplicationMetadata.ScrapedTagsExportFilePath (~/Documents/Scrapper/ScrapedTags.json) - DTOs/ScrapedTagExtensions: list-level ToDtos/ToDomain - Repositories/TagData: extracted to own file (one type per file) - IScrapedTagRepository: GetAllAsync + UpsertAsync (batched, no N+1) - IScrapedTagService + ScrapedTagService: export/import via repository - IImportExportService: ExportScrapedTagsToFile + ImportScrapedTagsFromFile - ImportExportService: implements tag I/O methods; sealed - MainWindow: ExportTagsButton + ImportTagsButton with disable/enable lifecycle - 15 new tests (GivenAnImportExportService + GivenAScrapedTagService); 70 total, all green Co-Authored-By: Claude Sonnet 4.6 --- src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs | 1 + .../ApplicationMetadata.cs | 3 +- .../DTOs/ScrapedTagExtensions.cs | 26 ++++++++++ .../MainWindow.axaml | 2 + .../MainWindow.axaml.cs | 47 ++++++++++++++++-- .../Repositories/IScrapedTagRepository.cs | 6 ++- .../Repositories/ScrapedTagRepository.cs | 41 +++++++++++++--- .../Repositories/TagData.cs | 3 ++ .../Services/IImportExportService.cs | 4 ++ .../Services/IScrapedTagService.cs | 9 ++++ .../Services/ImportExportService.cs | 49 +++++++++++++++++-- .../Services/ScrapedTagService.cs | 17 +++++++ .../Services/GivenAScrapedTagService.cs | 4 +- .../Services/GivenAnImportExportService.cs | 42 ++++++++++------ 14 files changed, 218 insertions(+), 36 deletions(-) create mode 100644 src/AStar.Dev.Wallpaper.Scrapper/DTOs/ScrapedTagExtensions.cs create mode 100644 src/AStar.Dev.Wallpaper.Scrapper/Repositories/TagData.cs create mode 100644 src/AStar.Dev.Wallpaper.Scrapper/Services/IScrapedTagService.cs create mode 100644 src/AStar.Dev.Wallpaper.Scrapper/Services/ScrapedTagService.cs diff --git a/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs b/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs index e0cd66e..4187663 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs @@ -73,6 +73,7 @@ public override void OnFrameworkInitializationCompleted() .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() diff --git a/src/AStar.Dev.Wallpaper.Scrapper/ApplicationMetadata.cs b/src/AStar.Dev.Wallpaper.Scrapper/ApplicationMetadata.cs index 0feca64..2bb1b1f 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/ApplicationMetadata.cs +++ b/src/AStar.Dev.Wallpaper.Scrapper/ApplicationMetadata.cs @@ -11,5 +11,6 @@ public static class ApplicationMetadata public static string ApplicationFolder => Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!.CombinePath("..", "..", ".."); - public static string FileClassificationsExportFilePath => Path.Combine(SpecialDirectories.MyDocuments, "Scrapper", "FileClassifications.json"); + public static string FileClassificationsExportFilePath => SpecialDirectories.MyDocuments.CombinePath("Scrapper", "FileClassifications.json"); + public static string ScrapedTagsExportFilePath => SpecialDirectories.MyDocuments.CombinePath("Scrapper", "ScrapedTags.json"); } diff --git a/src/AStar.Dev.Wallpaper.Scrapper/DTOs/ScrapedTagExtensions.cs b/src/AStar.Dev.Wallpaper.Scrapper/DTOs/ScrapedTagExtensions.cs new file mode 100644 index 0000000..7c6b350 --- /dev/null +++ b/src/AStar.Dev.Wallpaper.Scrapper/DTOs/ScrapedTagExtensions.cs @@ -0,0 +1,26 @@ +using ScrapedTagDto = AStar.Dev.Wallpaper.Scrapper.DTOs.ScrapedTag; +using ScrapedTagDomain = AStar.Dev.Infrastructure.FilesDb.Models.ScrapedTag; +using ScrapedTagDomainId = AStar.Dev.Infrastructure.FilesDb.Models.ScrapedTagId; + +namespace AStar.Dev.Wallpaper.Scrapper.DTOs; + +public static class ScrapedTagExtensions +{ + public static List ToDomain(this List dtos) + => [.. dtos.Select(dto => new ScrapedTagDomain + { + Id = new ScrapedTagDomainId(dto.Id.Value), + Value = dto.Value, + Category = dto.Category, + IncludeInSearch = dto.IncludeInSearch + })]; + + public static List ToDtos(this List domains) + => [.. domains.Select(domain => new ScrapedTagDto + { + Id = new ScrapedTagId(domain.Id.Value), + Value = domain.Value, + Category = domain.Category, + IncludeInSearch = domain.IncludeInSearch + })]; +} diff --git a/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml b/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml index e5a0451..87f1dd2 100644 --- a/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml +++ b/src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml @@ -10,6 +10,8 @@