docs(delmia-notifier): implementation plan + task persistence (8 TDD tasks)

This commit is contained in:
Joseph Doherty
2026-06-26 05:01:16 -04:00
parent 0008ca891c
commit 9ce6783139
2 changed files with 443 additions and 0 deletions
@@ -0,0 +1,429 @@
# Delmia Recipe-Download Notifier — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Build `ZB.MOM.WW.ScadaBridge.DelmiaNotifier` — a compact Native-AOT Windows console app that DELMIA shells out to per recipe download, which POSTs to the ScadaBridge Inbound API `DelmiaRecipeDownload` method and reports the legacy `YES`/`NO` + exit-code contract.
**Architecture:** Single `Program.cs` orchestrates four testable pieces (arg parser, config loader, notifier failover loop behind an `IRecipeSender` seam, result reporter). Zero third-party NuGet deps; `System.Text.Json` source generator for AOT-safe (de)serialization; raw `HttpClient`. Comma-list of base URLs with **connect-failure-only** failover.
**Tech Stack:** .NET 10, C#, Native AOT (`win-x64`), `System.Text.Json` source-gen, xUnit. Central package management (`Directory.Packages.props`); no `Directory.Build.props`.
Design doc: [`2026-06-26-delmia-recipe-notifier-design.md`](2026-06-26-delmia-recipe-notifier-design.md).
**Cross-cutting conventions (apply to every task):**
- TDD: failing test → run (fails) → minimal code → run (passes) → commit.
- Run a single test: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests --filter "FullyQualifiedName~<Name>"`.
- Build the two new projects only (fast): `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`.
- `TreatWarningsAsErrors=true` everywhere — AOT/trim analyzer warnings are build failures; fix, don't suppress.
- Native AOT cannot be published from macOS; all tasks here are managed build/test only (cross-platform). The win-x64 AOT exe is produced on Windows (Task 8).
- JSON keys are **PascalCase** (`MachineCode`, `Result`, …) to match the inbound `DelmiaRecipeDownload` contract — keep `System.Text.Json` default naming (no camelCase policy).
---
### Task 1: Scaffold the src + test projects
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (foundation)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/appsettings.json`
- Create: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj`
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (add both `<Project Path=...>` lines under the matching `/src/` and `/tests/` folders)
**Step 1: Create the src csproj**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>WWNotifier</AssemblyName>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- No <RuntimeIdentifier> here: pass -r win-x64 at publish (Task 8) so build/test stay cross-platform. -->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
```
**Step 2: Create a stub `Program.cs`**
```csharp
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal static class Program
{
public static int Main(string[] args) => 0; // replaced in Task 7
}
```
**Step 3: Create `appsettings.json`**
```json
{
"ScadaBridge": {
"BaseUrls": "http://localhost:9000",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
```
**Step 4: Create the test csproj**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />
</ItemGroup>
</Project>
```
**Step 5: Add both projects to `ZB.MOM.WW.ScadaBridge.slnx`** — one `<Project Path="src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />` under `<Folder Name="/src/">`, one `<Project Path="tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj" />` under `<Folder Name="/tests/">`.
**Step 6: Build both**`dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: both succeed.
**Step 7: Commit**`git add … && git commit -m "feat(delmia-notifier): scaffold DelmiaNotifier src + test projects"`
---
### Task 2: DTOs + JSON source-generation context
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3, Task 4
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownload.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownloadResult.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierJsonContext.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs`
**Step 1: Failing test** — assert `RecipeDownload` serializes to PascalCase keys and `RecipeDownloadResult` round-trips:
```csharp
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
public class JsonContractTests
{
[Fact]
public void RecipeDownload_serializes_pascalcase()
{
var json = JsonSerializer.Serialize(
new RecipeDownload { MachineCode = "Z28061", DownloadPath = @"C:\r.nc",
WorkOrderNumber = "W1", PartNumber = "P1", JobStepNumber = "0100", Username = "op" },
NotifierJsonContext.Default.RecipeDownload);
Assert.Contains("\"MachineCode\":\"Z28061\"", json);
Assert.Contains("\"DownloadPath\"", json);
}
[Fact]
public void RecipeDownloadResult_deserializes_pascalcase()
{
var r = JsonSerializer.Deserialize("{\"Result\":true,\"ResultText\":\"ok\"}",
NotifierJsonContext.Default.RecipeDownloadResult);
Assert.True(r!.Result);
Assert.Equal("ok", r.ResultText);
}
}
```
**Step 2: Run → fails** (types don't exist).
**Step 3: Implement** the two DTOs and the context:
```csharp
// RecipeDownload.cs
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownload
{
public string? MachineCode { get; set; }
public string? DownloadPath { get; set; }
public string? WorkOrderNumber { get; set; }
public string? PartNumber { get; set; }
public string? JobStepNumber { get; set; }
public string? Username { get; set; }
}
```
```csharp
// RecipeDownloadResult.cs
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownloadResult
{
public bool Result { get; set; }
public string? ResultText { get; set; }
}
```
```csharp
// NotifierJsonContext.cs
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
[JsonSerializable(typeof(RecipeDownload))]
[JsonSerializable(typeof(RecipeDownloadResult))]
[JsonSerializable(typeof(NotifierConfig))] // added in Task 4
internal partial class NotifierJsonContext : JsonSerializerContext;
```
> If Task 4 isn't done yet, temporarily omit the `NotifierConfig` line and add it in Task 4.
**Step 4: Run → passes.**
**Step 5: Commit**`feat(delmia-notifier): recipe DTOs + JSON source-gen context`
---
### Task 3: Command-line argument parser
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 4
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ArgParser.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs`
**Behavior:** parse `-d/--downloadpath -m/--machine -w/--workorder -p/--partnumber` (required) + `-s/--seqop -u/--username` (optional) into a `RecipeDownload`. Return a discriminated result: success(payload) or error(message). Unknown flag or missing required → error with a human reason.
**Step 1: Failing tests**
```csharp
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
public class ArgParserTests
{
[Fact]
public void Parses_all_flags()
{
var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1","-p","P1","-s","0100","-u","op" });
Assert.True(r.Ok);
Assert.Equal("Z28061", r.Payload!.MachineCode);
Assert.Equal("0100", r.Payload.JobStepNumber);
Assert.Equal("op", r.Payload.Username);
}
[Fact]
public void Missing_required_returns_error()
{
var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1" }); // no -p
Assert.False(r.Ok);
Assert.Contains("partnumber", r.Error, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Optional_flags_may_be_omitted()
{
var r = ArgParser.Parse(new[] { "-m","Z","-d","x","-w","W","-p","P" });
Assert.True(r.Ok);
Assert.Null(r.Payload!.Username);
Assert.Null(r.Payload.JobStepNumber);
}
[Fact]
public void Unknown_flag_returns_error()
{
var r = ArgParser.Parse(new[] { "-z","x" });
Assert.False(r.Ok);
}
}
```
**Step 2: Run → fails.**
**Step 3: Implement `ArgParser`** — a `ParseResult` record (`bool Ok`, `RecipeDownload? Payload`, `string? Error`) and a `Parse(string[])` that walks pairs, maps short+long flags, validates the four required fields, and returns the first missing/unknown as the error. Keep it allocation-light and reflection-free (AOT-safe).
**Step 4: Run → passes.**
**Step 5: Commit**`feat(delmia-notifier): CLI arg parser with required/optional validation`
---
### Task 4: Config loader + API key
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierConfig.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ConfigLoader.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs`
- (Add `[JsonSerializable(typeof(NotifierConfig))]` to `NotifierJsonContext` if not already present.)
**Behavior:** `NotifierConfig` POCO (`ScadaBridge` section: `BaseUrls` string, `TimeoutSeconds` int = 30, `LogPath` string?). `ConfigLoader.Load(string jsonText)` deserializes via `NotifierJsonContext`. A helper `SplitBaseUrls(string?)` → trimmed, non-empty `string[]`. `ResolveApiKey(Func<string,string?> envGet)` reads `SCADABRIDGE_API_KEY` (inject the env accessor for testability).
**Step 1: Failing tests** — comma split (`"a, b ,,c"``[a,b,c]`), default `TimeoutSeconds == 30` when omitted, empty/whitespace key → null, present key → value.
**Step 2: Run → fails.**
**Step 3: Implement** POCO + loader. `Load` uses `JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig)`. Separate file read (`File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))`) into a thin wrapper so the parse logic is unit-tested from a string.
**Step 4: Run → passes.**
**Step 5: Commit**`feat(delmia-notifier): config loader + SCADABRIDGE_API_KEY resolution`
---
### Task 5: Notifier failover loop (behind `IRecipeSender` seam)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (core logic; needs Task 2)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/IRecipeSender.cs` (seam: `AttemptKind`, `AttemptOutcome`, `IRecipeSender`)
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Notifier.cs` (the failover loop + `NotifyResult`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs`
**Seam:**
```csharp
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal enum AttemptKind { Connected, ConnectFailed }
internal sealed record AttemptOutcome(AttemptKind Kind, int StatusCode, RecipeDownloadResult? Body, string? Error);
internal interface IRecipeSender
{
Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct);
}
```
**Loop rule (connect-failure-only failover):** iterate base URLs in order; `ConnectFailed` → record error, continue; `Connected` → authoritative, stop:
- 2xx + `Result == true` → success.
- 2xx + `Result == false` → failure(`ResultText`).
- non-2xx → failure(`HTTP <status>`).
- All `ConnectFailed` → failure(`all URLs unreachable: <lastError>`).
**Step 1: Failing tests** using a fake `IRecipeSender` (a queue of scripted outcomes), assert `NotifyResult` (Ok + Reason):
- first URL `ConnectFailed`, second `Connected 200 Result=true` → Ok.
- first `Connected 200 Result=false` → not Ok, reason from `ResultText`, **second sender never called**.
- first `Connected 500` → not Ok, reason contains `500`, second never called.
- all `ConnectFailed` → not Ok, reason contains `unreachable`.
**Step 2: Run → fails.**
**Step 3: Implement** `Notifier.RunAsync(string[] baseUrls, RecipeDownload payload, IRecipeSender sender, CancellationToken ct)` returning `NotifyResult(bool Ok, string Reason)`.
**Step 4: Run → passes.**
**Step 5: Commit**`feat(delmia-notifier): connect-failure-only failover loop`
---
### Task 6: Real `HttpClient` sender (outcome classification)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs Task 2, Task 5)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs`
**Behavior:** implements `IRecipeSender` over an injected `HttpClient`. `POST {baseUrl.TrimEnd('/')}/api/DelmiaRecipeDownload`, header `X-API-Key: <key>`, body = `RecipeDownload` JSON via `NotifierJsonContext`. Classification:
- `HttpRequestException` (incl. inner `SocketException`) or `TaskCanceledException`/`OperationCanceledException` from timeout → `AttemptOutcome(ConnectFailed, 0, null, ex.Message)`.
- Got a response → `Connected`, with `StatusCode`; on 2xx parse body into `RecipeDownloadResult` (tolerate parse failure → `Body=null`, treated as failure by the loop).
**Step 1: Failing tests** with a fake `HttpMessageHandler` (`Func<HttpRequestMessage, HttpResponseMessage>`):
- handler throws `HttpRequestException``ConnectFailed`.
- handler returns 200 `{"Result":true,"ResultText":""}``Connected`, 200, `Body.Result == true`; assert request had `X-API-Key` header and path `/api/DelmiaRecipeDownload`.
- handler returns 500 → `Connected`, 500.
**Step 2: Run → fails.**
**Step 3: Implement** `HttpRecipeSender`.
**Step 4: Run → passes.**
**Step 5: Commit**`feat(delmia-notifier): HttpClient recipe sender with connect-failure classification`
---
### Task 7: `Program.Main` wiring + result reporter + logger
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs Tasks 26)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs` (maps outcome → stdout + exit code)
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs` (stderr + optional file)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs`
**Reporter contract (drop-in parity):** success → write `YES` to a `TextWriter`, return `0`. Failure → write `NO` then the reason line, return `-1`. (Inject the `TextWriter` so tests assert the exact lines.)
**Step 1: Failing tests** for `Reporter.Report(bool ok, string reason, TextWriter stdout)`:
- ok → stdout is `"YES"` (+ newline), returns `0`.
- not ok with reason `"boom"` → stdout is `"NO\nboom"`, returns `-1`.
**Step 2: Run → fails.**
**Step 3: Implement** `Reporter`, `DiagLog` (writes timestamped lines to `Console.Error` and, if `LogPath` set, appends to the file — create parent dir; never throw), and wire `Program.Main`:
1. `ArgParser.Parse(args)` → on error: `DiagLog`, `Reporter.Report(false, error)`.
2. Load config; if no `BaseUrls` → report false "no BaseUrls configured".
3. `ResolveApiKey`; if null → report false "API key not configured (SCADABRIDGE_API_KEY)".
4. Build `HttpClient { Timeout = TimeSpan.FromSeconds(cfg.TimeoutSeconds) }` + `HttpRecipeSender`.
5. `await Notifier.RunAsync(...)`; log per-attempt diagnostics; `Reporter.Report(result.Ok, result.Reason)`.
6. Wrap in try/catch → on unexpected error: `DiagLog` + report false with the message. **stdout only ever gets `YES`/`NO`+reason.**
**Step 4: Run → passes** (reporter tests; full-build the two projects).
**Step 5: Commit**`feat(delmia-notifier): Program wiring, YES/NO reporter, diagnostics log`
---
### Task 8: README, publish/AOT instructions, final verification
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 27 docs portion (verification depends on all)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`
- Test: (none new) — run the full suite.
**Step 1: Write `README.md`** — purpose (Delmia → ScadaBridge `DelmiaRecipeDownload`), CLI flags table, `appsettings.json` schema, the `SCADABRIDGE_API_KEY` env var, the `YES`/`NO` + exit-code contract, and the **publish** command:
```
# On Windows (Native AOT can't cross-compile from macOS/Linux):
dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64
# → WWNotifier.exe (self-contained, single native file) + appsettings.json
```
Plus the manual smoke test (`curl`/run against `wonder-app-vd03` `/api/DelmiaRecipeDownload`).
**Step 2: Final verification**`dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: build clean (no warnings, since warnings-as-errors), all tests pass.
**Step 3: Commit**`docs(delmia-notifier): README + publish/AOT instructions`
---
## Notes for the executor
- Do **not** add NuGet packages to the src project — BCL only (AOT-clean). The test project uses only the standard xUnit set already in `Directory.Packages.props`.
- If any task needs a file not listed in its `Files:` block, that's a plan defect — surface it.
- The AOT native publish + live smoke against `wonder-app-vd03` happen on a Windows host; the managed build/test in every task is the cross-platform gate here.
@@ -0,0 +1,14 @@
{
"planPath": "docs/plans/2026-06-26-delmia-recipe-notifier.md",
"tasks": [
{"id": 11, "subject": "Task 1: Scaffold src + test projects", "status": "pending"},
{"id": 12, "subject": "Task 2: DTOs + JSON source-gen context", "status": "pending", "blockedBy": [11]},
{"id": 13, "subject": "Task 3: CLI arg parser", "status": "pending", "blockedBy": [11]},
{"id": 14, "subject": "Task 4: Config loader + API key", "status": "pending", "blockedBy": [11]},
{"id": 15, "subject": "Task 5: Failover loop (IRecipeSender seam)", "status": "pending", "blockedBy": [12]},
{"id": 16, "subject": "Task 6: HttpClient recipe sender", "status": "pending", "blockedBy": [12, 15]},
{"id": 17, "subject": "Task 7: Program wiring + reporter + logger", "status": "pending", "blockedBy": [13, 14, 15, 16]},
{"id": 18, "subject": "Task 8: README + publish/AOT + final verify", "status": "pending", "blockedBy": [17]}
],
"lastUpdated": "2026-06-26"
}