docs(delmia-notifier): implementation plan + task persistence (8 TDD tasks)
This commit is contained in:
@@ -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 2–6)
|
||||||
|
|
||||||
|
**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 2–7 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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user