Compare commits

..

11 Commits

Author SHA1 Message Date
Joseph Doherty b58f3aeb61 docs: list DelmiaNotifier (#27) in component tables (standalone external client tool) 2026-06-26 05:25:09 -04:00
Joseph Doherty 99ceb6677f docs(delmia-notifier): README + publish/AOT instructions 2026-06-26 05:19:24 -04:00
Joseph Doherty 3931fa2101 feat(delmia-notifier): Program wiring, YES/NO reporter, diagnostics log 2026-06-26 05:18:19 -04:00
Joseph Doherty 71f680d542 feat(delmia-notifier): HttpClient recipe sender with connect-failure classification 2026-06-26 05:15:17 -04:00
Joseph Doherty d26462ed8d feat(delmia-notifier): connect-failure-only failover loop 2026-06-26 05:13:58 -04:00
Joseph Doherty 991c263c3e feat(delmia-notifier): config loader + SCADABRIDGE_API_KEY resolution 2026-06-26 05:12:27 -04:00
Joseph Doherty 3e964acff6 feat(delmia-notifier): CLI arg parser with required/optional validation 2026-06-26 05:11:19 -04:00
Joseph Doherty 82bde9693e feat(delmia-notifier): recipe DTOs + JSON source-gen context 2026-06-26 05:10:23 -04:00
Joseph Doherty 069881ac9b feat(delmia-notifier): scaffold DelmiaNotifier src + test projects 2026-06-26 05:08:54 -04:00
Joseph Doherty 9ce6783139 docs(delmia-notifier): implementation plan + task persistence (8 TDD tasks) 2026-06-26 05:01:16 -04:00
Joseph Doherty 0008ca891c docs(delmia-notifier): design for DelmiaNotifier console app (WWNotifier modern replacement)
Compact Native-AOT win-x64 console app DELMIA invokes to notify ScadaBridge of a
recipe download via POST /api/DelmiaRecipeDownload (X-API-Key). Drop-in CLI/output
parity with legacy WWNotifier; appsettings.json + SCADABRIDGE_API_KEY env var;
comma-list base URLs with connect-failure-only failover.
2026-06-26 04:55:40 -04:00
28 changed files with 1475 additions and 1 deletions
+2 -1
View File
@@ -63,7 +63,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
- Commit related changes together with a descriptive message summarizing the design decision and the implementation slice.
- After non-trivial code changes, build (`dotnet build ZB.MOM.WW.ScadaBridge.slnx`) and run relevant tests before declaring done; for cluster-runtime changes, rebuild the image with `bash docker/deploy.sh`.
## Current Component List (26 components)
## Current Component List (27 components)
1. Template Engine — Template modeling, inheritance, composition, validation, flattening, diffs.
2. Deployment Manager — Central-side deployment pipeline, system-wide artifact deployment, instance lifecycle.
@@ -91,6 +91,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates, system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
27. DelmiaNotifier — Standalone external client tool (NOT a cluster component, NOT in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`), with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract — a drop-in replacement for the legacy `WWNotifier` (see `docs/former-api-specs/dnc/`). Zero-dependency BCL-only, `System.Text.Json` source-gen. Project README: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`; design: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md`.
## Key Design Decisions (for context across sessions)
+1
View File
@@ -102,6 +102,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| 24 | Transport | [docs/requirements/Component-Transport.md](docs/requirements/Component-Transport.md) | Bundle export/import for templates, shared scripts, external systems, central-only artifacts, plus site-scoped config — `Site`s, site `DataConnection`s, and `Instance`s — reconciled across environments by a `BundleNameMap` name-mapping subsystem (auto-match + Map wizard step / CLI flags). AES-256-GCM encryption (carry-full-config with encrypted connection secrets); per-conflict resolution with a per-line Myers diff for code fields; real stale-instance enumeration; correlated audit trail. Never touches site runtime nodes. |
| 25 | Script Analysis | [docs/requirements/Component-ScriptAnalysis.md](docs/requirements/Component-ScriptAnalysis.md) | Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI. |
| 26 | KPI History | [docs/requirements/Component-KpiHistory.md](docs/requirements/Component-KpiHistory.md) | Reusable central KPI-history backbone: tall/EAV `KpiSample` store (central MS SQL), `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s each minute, bucketed `GetRawSeriesAsync` + `KpiSeriesBucketer` query, and a reusable custom-SVG `KpiTrendChart`. Ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health. |
| 27 | DelmiaNotifier | [src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md](src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md) | **Standalone external client tool** (not deployed to the cluster, not in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`) with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract. Drop-in replacement for the legacy `WWNotifier` ([docs/former-api-specs/dnc/](docs/former-api-specs/dnc/Delmia-Integration-API.md)). Design: [docs/plans/2026-06-26-delmia-recipe-notifier-design.md](docs/plans/2026-06-26-delmia-recipe-notifier-design.md). |
**Shared UI sub-component** (not a top-level component): [TreeView](docs/requirements/Component-TreeView.md) — reusable hierarchical tree/grid Blazor component used by the Central UI (#9) for the templates folder hierarchy, data-connection browse, and tag pickers.
+2
View File
@@ -25,6 +25,7 @@
<Project Path="src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj" />
@@ -55,5 +56,6 @@
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,158 @@
# Design — Delmia Recipe-Download Notifier (`ZB.MOM.WW.ScadaBridge.DelmiaNotifier`)
**Date:** 2026-06-26 · **Status:** Approved (brainstorming) · **Next:** implementation plan (writing-plans)
## Purpose
A compact Windows console application that **DELMIA Apriso shells out to** on each recipe/NC-program
download to notify the plant system that a recipe was placed at a path for a machine. It is the
modern replacement for the legacy `WWNotifier` (see
[`../former-api-specs/dnc/Delmia-Integration-API.md`](../former-api-specs/dnc/Delmia-Integration-API.md),
Surface B), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API**
method `DelmiaRecipeDownload`.
It is a strict **drop-in** for Delmia's existing call site: same command-line flags, same `YES`/`NO`
stdout contract, same exit-code semantics — so Delmia's invocation and output parsing are unchanged.
## Key decisions (from brainstorming)
| Decision | Choice |
|---|---|
| Target | ScadaBridge Inbound API `POST {baseUrl}/api/DelmiaRecipeDownload` |
| Auth | `X-API-Key: <key>` header |
| CLI/output | Exact parity with legacy `WWNotifier` (flags + `YES`/`NO` + exit code) |
| Config | `appsettings.json` next to the exe (URLs, timeout, optional log path) |
| Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file |
| Packaging | Self-contained **Native AOT**, `win-x64` (fast startup for per-invocation use) |
| Failover | Comma-list of base URLs; advance **only on connect failure** |
| Location | New project `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier`, in `ZB.MOM.WW.ScadaBridge.slnx` |
| Implementation | Approach A — zero-dependency single-file; hand-rolled arg parser, `System.Text.Json` source-gen, raw `HttpClient` |
| Exe name | `AssemblyName = WWNotifier` (literal drop-in for Delmia's existing call path) |
## Architecture
Single-purpose console app, no DI/generic host. One `Program.cs` orchestrates four small pieces:
1. **Arg parser** — hand-rolled, the 6 legacy flags → an in-memory request model.
2. **Config loader** — reads `appsettings.json` (via `System.Text.Json` source generator) → POCO;
reads the API key from the environment.
3. **Notifier** — builds the JSON payload and runs the failover POST loop behind a small seam
(interface/delegate) so the loop + result mapping are unit-testable without real HTTP.
4. **Result reporter** — maps the outcome to the `YES`/`NO` + exit-code contract (stdout) and writes
diagnostics (stderr + optional log file).
Zero third-party NuGet dependencies (BCL only) to keep the Native-AOT surface minimal and trim-clean.
## CLI contract (drop-in parity)
| Short | Long | Required | → payload field |
|---|---|---|---|
| `-d` | `--downloadpath` | yes | `DownloadPath` |
| `-m` | `--machine` | yes | `MachineCode` |
| `-w` | `--workorder` | yes | `WorkOrderNumber` |
| `-p` | `--partnumber` | yes | `PartNumber` |
| `-s` | `--seqop` | no | `JobStepNumber` |
| `-u` | `--username` | no | `Username` |
- **stdout:** exactly `YES` on success, or `NO` followed by a reason line on failure. Nothing else is
written to stdout (Delmia parses it).
- **exit code:** `0` on success, `-1` on failure (matches the legacy `Environment.ExitCode`).
- A missing required flag → `NO` + reason, exit `-1`, **no** HTTP attempt.
## Configuration & secret
`appsettings.json` placed next to the exe, loaded directly into a small POCO with a
`JsonSerializerContext` source generator (no reflection-based binder):
```json
{
"ScadaBridge": {
"BaseUrls": "http://host-a:8085,http://host-b:8085",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
```
- `BaseUrls` — comma-separated failover list (legacy-style). The method path
`/api/DelmiaRecipeDownload` is appended by the app; each entry is a base URL only.
- `TimeoutSeconds` — per-attempt `HttpClient.Timeout` (default 30).
- `LogPath` — optional diagnostic log file (relative to the exe). Omit to log to stderr only.
- **API key** comes from `SCADABRIDGE_API_KEY`. If unset/empty → `NO` + "API key not configured",
exit `-1`, no attempt. The key is never read from or written to a file.
## Request / response
- Per attempt: `POST {baseUrl}/api/DelmiaRecipeDownload`, header `X-API-Key: <key>`,
`Content-Type: application/json`, body = the flat `RecipeDownload` JSON.
- DTOs (local, source-gen serializable):
- `RecipeDownload { MachineCode, DownloadPath, WorkOrderNumber, PartNumber, JobStepNumber, Username }`
- `RecipeDownloadResult { bool Result, string ResultText }`
## Failover (connect-failure only)
Try each base URL in order; advance to the next **only when the attempt fails to connect** (no HTTP
response came back). A node that responds at all is authoritative — its answer is final.
| Attempt outcome | Failover? | Final result |
|---|---|---|
| **No response** — connection refused/reset, DNS failure, TLS error, or timeout | **Yes** → next URL | only if *all* URLs fail to connect → `NO` + last connection error, exit `-1` |
| HTTP 2xx + `Result == true` | No — stop | `YES`, exit `0` |
| HTTP 2xx + `Result == false` | No — stop | `NO` + `ResultText`, exit `-1` |
| HTTP non-2xx (401/403/4xx/**5xx**) | No — stop | `NO` + status/error, exit `-1` |
> Deliberate consequence: a `5xx` from the first node is reported as a failure, **not** rolled over
> to the next node — failover is strictly for unreachable nodes. (Revisit only if operations want
> `5xx`/`503` to also fail over.)
## Error handling & logging
- **stdout is reserved** for the `YES`/`NO` contract. All diagnostics — per-URL attempt, status code,
exception detail, which URL answered — go to **stderr** and, if `LogPath` is set, an appended log
file. Hand-written; no logging dependency.
- Failure reasons surfaced on the `NO` reason line: missing required arg, missing API key, no
`BaseUrls` configured, or "all URLs unreachable: \<last error\>".
- The HTTP call sits behind a tiny seam so the failover/result logic is unit-tested without network.
## Project layout & packaging
```
src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/
ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj
Program.cs # entry, arg parse, orchestration, result reporting
Config.cs # POCO + loader
RecipeDownload.cs # request DTO
RecipeDownloadResult.cs # response DTO
NotifierJsonContext.cs # JsonSerializerContext (source gen)
appsettings.json # copied to output
```
- `.csproj`: `net10.0`, `OutputType=Exe`, `AssemblyName=WWNotifier`, `PublishAot=true`,
`RuntimeIdentifier=win-x64`, `InvariantGlobalization=true`, AOT/trim analyzer warnings treated as
errors. Added to `ZB.MOM.WW.ScadaBridge.slnx`.
- **Build note:** the AOT `win-x64` native exe must be published **on Windows**
(`dotnet publish -c Release -r win-x64`) with the MSVC build tools present — Native AOT does not
cross-compile from macOS/Linux. Managed `dotnet build` / `dotnet test` still run cross-platform for
development and CI of the logic.
## Testing
`tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests` (xUnit), all logic-level (no AOT needed, runs
cross-platform):
- Arg parsing: all-flags, required-missing → failure, optional omitted.
- Config: comma-split of `BaseUrls`, defaults (`TimeoutSeconds`), missing file/section.
- Payload mapping: flags → `RecipeDownload` JSON (field-for-field).
- Result mapping: `Result true/false` and non-2xx → correct stdout + exit code.
- Failover loop (via the HTTP seam / a fake handler): connect-failure advances; first responding node
is final; `Result==false` does **not** advance; all-unreachable → `NO` + last error.
- Missing/empty `SCADABRIDGE_API_KEY` → fail-fast, no attempt.
- Manual live smoke against `wonder-app-vd03` (`/api/DelmiaRecipeDownload`) from the Windows build,
mirroring the earlier `curl` verification.
## Out of scope (YAGNI)
- No retry/backoff beyond the connect-failover loop; no Polly.
- No DI/generic host, no `Microsoft.Extensions.*`.
- No support for other inbound methods — recipe download only.
- No DPAPI/secret-store integration (env var is the agreed mechanism); revisit if required.
@@ -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": "completed"},
{"id": 12, "subject": "Task 2: DTOs + JSON source-gen context", "status": "completed", "blockedBy": [11]},
{"id": 13, "subject": "Task 3: CLI arg parser", "status": "completed", "blockedBy": [11]},
{"id": 14, "subject": "Task 4: Config loader + API key", "status": "completed", "blockedBy": [11]},
{"id": 15, "subject": "Task 5: Failover loop (IRecipeSender seam)", "status": "completed", "blockedBy": [12]},
{"id": 16, "subject": "Task 6: HttpClient recipe sender", "status": "completed", "blockedBy": [12, 15]},
{"id": 17, "subject": "Task 7: Program wiring + reporter + logger", "status": "completed", "blockedBy": [13, 14, 15, 16]},
{"id": 18, "subject": "Task 8: README + publish/AOT + final verify", "status": "completed", "blockedBy": [17]}
],
"lastUpdated": "2026-06-26"
}
@@ -0,0 +1,73 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Outcome of parsing the command line: success carries the payload, failure carries a human reason.</summary>
internal sealed record ParseResult(bool Ok, RecipeDownload? Payload, string? Error)
{
public static ParseResult Success(RecipeDownload payload) => new(true, payload, null);
public static ParseResult Fail(string error) => new(false, null, error);
}
/// <summary>
/// Hand-rolled, reflection-free parser for the legacy WWNotifier flags. Each flag takes one value
/// (short or long form). The four required flags must be present; the two optional flags may be omitted.
/// </summary>
internal static class ArgParser
{
public static ParseResult Parse(string[] args)
{
var payload = new RecipeDownload();
for (var i = 0; i < args.Length; i++)
{
var flag = args[i];
if (i + 1 >= args.Length)
{
return ParseResult.Fail($"missing value for flag '{flag}'");
}
var value = args[++i];
switch (flag)
{
case "-m" or "--machine":
payload.MachineCode = value;
break;
case "-d" or "--downloadpath":
payload.DownloadPath = value;
break;
case "-w" or "--workorder":
payload.WorkOrderNumber = value;
break;
case "-p" or "--partnumber":
payload.PartNumber = value;
break;
case "-s" or "--seqop":
payload.JobStepNumber = value;
break;
case "-u" or "--username":
payload.Username = value;
break;
default:
return ParseResult.Fail($"unknown flag '{flag}'");
}
}
if (string.IsNullOrEmpty(payload.MachineCode))
{
return ParseResult.Fail("missing required flag -m/--machine");
}
if (string.IsNullOrEmpty(payload.DownloadPath))
{
return ParseResult.Fail("missing required flag -d/--downloadpath");
}
if (string.IsNullOrEmpty(payload.WorkOrderNumber))
{
return ParseResult.Fail("missing required flag -w/--workorder");
}
if (string.IsNullOrEmpty(payload.PartNumber))
{
return ParseResult.Fail("missing required flag -p/--partnumber");
}
return ParseResult.Success(payload);
}
}
@@ -0,0 +1,38 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Reads and interprets the notifier's configuration and secret. Parse logic is string-based so it is unit-testable without touching disk.</summary>
internal static class ConfigLoader
{
private const string ApiKeyEnvVar = "SCADABRIDGE_API_KEY";
/// <summary>Deserialize the <c>appsettings.json</c> text via the source-gen context.</summary>
public static NotifierConfig Load(string jsonText) =>
JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig) ?? new NotifierConfig();
/// <summary>Read and parse the <c>appsettings.json</c> sitting next to the executable.</summary>
public static NotifierConfig LoadFromDefaultFile()
{
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
return Load(File.ReadAllText(path));
}
/// <summary>Split a comma-separated base-URL list into trimmed, non-empty entries.</summary>
public static string[] SplitBaseUrls(string? baseUrls)
{
if (string.IsNullOrWhiteSpace(baseUrls))
{
return [];
}
return baseUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>Resolve the API key from <c>SCADABRIDGE_API_KEY</c>; null/whitespace → null. Env accessor is injected for testability.</summary>
public static string? ResolveApiKey(Func<string, string?> envGet)
{
var key = envGet(ApiKeyEnvVar);
return string.IsNullOrWhiteSpace(key) ? null : key;
}
}
@@ -0,0 +1,39 @@
using System.Globalization;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>
/// Best-effort diagnostics: writes UTC-timestamped lines to stderr and, when a log path is configured,
/// appends them to a file (relative to the exe). Never throws — diagnostics must not break the YES/NO path.
/// </summary>
internal sealed class DiagLog(string? logPath)
{
private readonly string? _logPath = string.IsNullOrWhiteSpace(logPath) ? null : logPath;
public void Write(string message)
{
var line = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} {message}");
Console.Error.WriteLine(line);
if (_logPath is null)
{
return;
}
try
{
var fullPath = Path.IsPathRooted(_logPath) ? _logPath : Path.Combine(AppContext.BaseDirectory, _logPath);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.AppendAllText(fullPath, line + Environment.NewLine);
}
catch
{
// diagnostics are best-effort; swallow file errors so the YES/NO contract is unaffected
}
}
}
@@ -0,0 +1,58 @@
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>
/// <see cref="IRecipeSender"/> over a real <see cref="HttpClient"/>: POSTs the recipe payload to
/// <c>{baseUrl}/api/DelmiaRecipeDownload</c> with the <c>X-API-Key</c> header. A reachable server
/// (any status) is <see cref="AttemptKind.Connected"/>; a connection/timeout failure is <see cref="AttemptKind.ConnectFailed"/>.
/// </summary>
internal sealed class HttpRecipeSender(HttpClient http, string apiKey) : IRecipeSender
{
private const string MethodPath = "/api/DelmiaRecipeDownload";
public async Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
{
var url = baseUrl.TrimEnd('/') + MethodPath;
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.TryAddWithoutValidation("X-API-Key", apiKey);
var json = JsonSerializer.Serialize(payload, NotifierJsonContext.Default.RecipeDownload);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await http.SendAsync(request, ct);
var status = (int)response.StatusCode;
RecipeDownloadResult? body = null;
if (response.IsSuccessStatusCode)
{
var responseText = await response.Content.ReadAsStringAsync(ct);
body = TryParse(responseText);
}
return new AttemptOutcome(AttemptKind.Connected, status, body, null);
}
catch (HttpRequestException ex)
{
return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, ex.Message);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
// No external cancellation requested → this is an HttpClient.Timeout, i.e. the node never answered.
return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "timeout: " + ex.Message);
}
}
private static RecipeDownloadResult? TryParse(string responseText)
{
try
{
return JsonSerializer.Deserialize(responseText, NotifierJsonContext.Default.RecipeDownloadResult);
}
catch (JsonException)
{
return null; // unparseable body on a 2xx → treated as failure by the loop
}
}
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Whether a single POST attempt reached the server (<see cref="Connected"/>) or never did (<see cref="ConnectFailed"/>).</summary>
internal enum AttemptKind
{
Connected,
ConnectFailed,
}
/// <summary>Result of one POST attempt against a single base URL.</summary>
/// <param name="Kind">Did the attempt reach the server?</param>
/// <param name="StatusCode">HTTP status when <see cref="AttemptKind.Connected"/>; 0 otherwise.</param>
/// <param name="Body">Parsed response body on a 2xx; null otherwise.</param>
/// <param name="Error">Connection/exception detail when <see cref="AttemptKind.ConnectFailed"/>; null otherwise.</param>
internal sealed record AttemptOutcome(AttemptKind Kind, int StatusCode, RecipeDownloadResult? Body, string? Error);
/// <summary>Seam over a single recipe-download POST attempt, so the failover loop is testable without real HTTP.</summary>
internal interface IRecipeSender
{
Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct);
}
@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Final outcome of the notify operation, mapped 1:1 to the YES/NO + exit-code contract.</summary>
internal sealed record NotifyResult(bool Ok, string Reason);
/// <summary>
/// Connect-failure-only failover loop: tries each base URL in order. A node that responds at all is
/// authoritative — its answer is final (success, business rejection, or HTTP error alike). Only a
/// failure to connect rolls over to the next URL; if every URL fails to connect the last error is reported.
/// </summary>
internal static class Notifier
{
public static async Task<NotifyResult> RunAsync(
string[] baseUrls, RecipeDownload payload, IRecipeSender sender, CancellationToken ct)
{
var lastError = "no base URLs configured";
foreach (var baseUrl in baseUrls)
{
var outcome = await sender.SendAsync(baseUrl, payload, ct);
if (outcome.Kind == AttemptKind.ConnectFailed)
{
lastError = outcome.Error ?? "connection failed";
continue; // unreachable node → try the next
}
// Connected — this node's answer is authoritative; never fail over past it.
if (IsSuccessStatus(outcome.StatusCode))
{
if (outcome.Body is { Result: true })
{
return new NotifyResult(true, outcome.Body.ResultText ?? string.Empty);
}
var reason = outcome.Body?.ResultText;
return new NotifyResult(false, string.IsNullOrEmpty(reason) ? "request rejected" : reason);
}
return new NotifyResult(false, $"HTTP {outcome.StatusCode}");
}
return new NotifyResult(false, $"all URLs unreachable: {lastError}");
}
private static bool IsSuccessStatus(int status) => status is >= 200 and < 300;
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Root of <c>appsettings.json</c>; mirrors the <c>ScadaBridge</c> section only.</summary>
internal sealed class NotifierConfig
{
public ScadaBridgeSection ScadaBridge { get; set; } = new();
}
internal sealed class ScadaBridgeSection
{
/// <summary>Comma-separated failover list of base URLs (the method path is appended by the app).</summary>
public string? BaseUrls { get; set; }
/// <summary>Per-attempt HTTP timeout in seconds.</summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>Optional diagnostic log file (relative to the exe); null/omitted → stderr only.</summary>
public string? LogPath { get; set; }
}
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
[JsonSerializable(typeof(RecipeDownload))]
[JsonSerializable(typeof(RecipeDownloadResult))]
[JsonSerializable(typeof(NotifierConfig))]
internal partial class NotifierJsonContext : JsonSerializerContext;
@@ -0,0 +1,73 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
var stdout = Console.Out;
// Config first, so the diagnostic log can honour the configured LogPath even for early failures.
NotifierConfig cfg;
try
{
cfg = ConfigLoader.LoadFromDefaultFile();
}
catch (Exception ex)
{
new DiagLog(null).Write("config load failed: " + ex.Message);
return Reporter.Report(false, "config load failed: " + ex.Message, stdout);
}
var log = new DiagLog(cfg.ScadaBridge.LogPath);
try
{
var parse = ArgParser.Parse(args);
if (!parse.Ok)
{
log.Write("arg error: " + parse.Error);
return Reporter.Report(false, parse.Error!, stdout);
}
var baseUrls = ConfigLoader.SplitBaseUrls(cfg.ScadaBridge.BaseUrls);
if (baseUrls.Length == 0)
{
log.Write("no BaseUrls configured");
return Reporter.Report(false, "no BaseUrls configured", stdout);
}
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable);
if (apiKey is null)
{
log.Write("API key not configured (SCADABRIDGE_API_KEY)");
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY)", stdout);
}
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) };
var sender = new LoggingRecipeSender(new HttpRecipeSender(http, apiKey), log);
log.Write($"notifying {baseUrls.Length} URL(s) for machine {parse.Payload!.MachineCode}");
var result = await Notifier.RunAsync(baseUrls, parse.Payload, sender, CancellationToken.None);
log.Write($"result: ok={result.Ok} reason={result.Reason}");
return Reporter.Report(result.Ok, result.Reason, stdout);
}
catch (Exception ex)
{
log.Write("unexpected error: " + ex);
return Reporter.Report(false, "unexpected error: " + ex.Message, stdout);
}
}
/// <summary>Decorates a sender to emit a per-attempt diagnostic line; keeps stdout reserved for the YES/NO contract.</summary>
private sealed class LoggingRecipeSender(IRecipeSender inner, DiagLog log) : IRecipeSender
{
public async Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
{
var outcome = await inner.SendAsync(baseUrl, payload, ct);
log.Write(outcome.Kind == AttemptKind.ConnectFailed
? $"attempt {baseUrl}: connect failed: {outcome.Error}"
: $"attempt {baseUrl}: HTTP {outcome.StatusCode}");
return outcome;
}
}
}
@@ -0,0 +1,114 @@
# DelmiaNotifier (`WWNotifier.exe`)
A compact, self-contained Windows console app that **DELMIA Apriso shells out to** on each
recipe / NC-program download to notify the plant that a recipe was placed at a path for a machine.
It is the modern, drop-in replacement for the legacy `WWNotifier` (see
[`../../docs/former-api-specs/dnc/Delmia-Integration-API.md`](../../docs/former-api-specs/dnc/Delmia-Integration-API.md),
*Surface B*), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API**
method `DelmiaRecipeDownload`. The command-line flags, the `YES`/`NO` stdout contract, and the
exit-code semantics are **unchanged**, so Delmia's call site and output parsing need no changes — only
the executable on disk and its `appsettings.json` are swapped.
> The published assembly name is **`WWNotifier`** precisely so it is a literal file-for-file drop-in
> for Delmia's existing call path.
## What it does
Per invocation it POSTs the recipe-download notification to the configured ScadaBridge node(s) and
maps the result back to the legacy contract:
```
DELMIA → WWNotifier.exe → POST {baseUrl}/api/DelmiaRecipeDownload (X-API-Key: <key>)
← { "Result": true/false, "ResultText": "..." }
← stdout: YES | NO\n<reason> · exit: 0 | -1
```
## Command-line flags
| Short | Long | Required | → payload field |
|---|---|---|---|
| `-d` | `--downloadpath` | yes | `DownloadPath` |
| `-m` | `--machine` | yes | `MachineCode` |
| `-w` | `--workorder` | yes | `WorkOrderNumber` |
| `-p` | `--partnumber` | yes | `PartNumber` |
| `-s` | `--seqop` | no | `JobStepNumber` |
| `-u` | `--username` | no | `Username` |
Each flag takes one value (`-m Z28061`). A missing required flag → `NO` + reason, exit `-1`, **no**
HTTP attempt.
```
WWNotifier.exe -m Z28061 -d "C:\recipes\job.nc" -w WO12345 -p PN-7788 -s 0100 -u operator1
```
## Output contract (drop-in parity)
- **stdout** is reserved for the contract Delmia parses:
- success → `YES` (exit `0`)
- failure → `NO` on the first line, then a human-readable reason on the next line (exit `-1`)
- **exit code**: `0` on success, `-1` on failure. (On POSIX shells `-1` surfaces as `255`; on Windows
`%ERRORLEVEL%` is `-1`.)
- All diagnostics (per-URL attempt, status code, which URL answered, exceptions) go to **stderr** and,
if `LogPath` is set, an appended log file — never to stdout.
## Configuration — `appsettings.json`
Placed next to the exe (copied to the output on build/publish):
```json
{
"ScadaBridge": {
"BaseUrls": "http://host-a:8085,http://host-b:8085",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
```
- **`BaseUrls`** — comma-separated failover list of **base URLs only**; the app appends
`/api/DelmiaRecipeDownload`. URLs are tried in order with **connect-failure-only failover**: a node
that responds at all (even `4xx`/`5xx`) is authoritative and its answer is final; only a node that
fails to connect (refused/reset, DNS, TLS, timeout) rolls over to the next. If every URL fails to
connect → `NO` + last connection error.
- **`TimeoutSeconds`** — per-attempt HTTP timeout (default `30`).
- **`LogPath`** — optional diagnostic log file, relative to the exe. Omit to log to stderr only.
## Secret — `SCADABRIDGE_API_KEY`
The inbound API key is read **only** from the environment variable `SCADABRIDGE_API_KEY` and is sent
as the `X-API-Key` header. It is never read from or written to a file. If unset/empty → `NO` +
`API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt.
Set it on the machine/account that DELMIA runs under (e.g. a system/user environment variable, or the
service account's environment).
## Building & publishing (Native AOT, `win-x64`)
The runtime artifact is a self-contained, single-file native exe. **Native AOT does not cross-compile
from macOS/Linux** — publish on Windows with the MSVC build tools (Desktop C++ workload) installed:
```powershell
# On Windows:
dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64
# → bin/Release/net10.0/win-x64/publish/WWNotifier.exe (+ appsettings.json)
```
Copy `WWNotifier.exe` **and** `appsettings.json` to the Delmia host, set `SCADABRIDGE_API_KEY`, and
point `BaseUrls` at the central node(s).
> Managed `dotnet build` / `dotnet test` run cross-platform and are the development/CI gate; only the
> final native publish requires Windows.
## Manual smoke test
After deploying (or against a reachable test cluster such as `wonder-app-vd03`):
```powershell
$env:SCADABRIDGE_API_KEY = "sbk_..."
.\WWNotifier.exe -m Z28061Sim -d "C:\recipes\test.nc" -w WO-TEST -p PN-TEST -s 0100 -u smoketest
# expect: YES (exit 0) — or NO + reason (exit -1) with details on stderr / in the log file
```
This mirrors the earlier `curl` verification of `POST /api/DelmiaRecipeDownload` with the
`X-API-Key` header.
@@ -0,0 +1,11 @@
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; }
}
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownloadResult
{
public bool Result { get; set; }
public string? ResultText { get; set; }
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>
/// Maps an outcome to the legacy WWNotifier stdout contract: <c>YES</c> + exit 0 on success,
/// <c>NO</c> followed by a reason line + exit -1 on failure. The writer is injected so stdout is the
/// only thing Delmia ever parses (LF-terminated, deterministic across platforms).
/// </summary>
internal static class Reporter
{
public static int Report(bool ok, string reason, TextWriter stdout)
{
if (ok)
{
stdout.Write("YES\n");
return 0;
}
stdout.Write("NO\n");
stdout.Write(reason);
return -1;
}
}
@@ -0,0 +1,19 @@
<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>
@@ -0,0 +1,7 @@
{
"ScadaBridge": {
"BaseUrls": "http://localhost:9000",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
@@ -0,0 +1,59 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
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(@"C:\r.nc", r.Payload.DownloadPath);
Assert.Equal("W1", r.Payload.WorkOrderNumber);
Assert.Equal("P1", r.Payload.PartNumber);
Assert.Equal("0100", r.Payload.JobStepNumber);
Assert.Equal("op", r.Payload.Username);
}
[Fact]
public void Parses_long_flags()
{
var r = ArgParser.Parse(new[] { "--machine", "Z", "--downloadpath", "x", "--workorder", "W", "--partnumber", "P" });
Assert.True(r.Ok);
Assert.Equal("Z", r.Payload!.MachineCode);
Assert.Equal("x", r.Payload.DownloadPath);
}
[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);
}
[Fact]
public void Flag_without_value_returns_error()
{
var r = ArgParser.Parse(new[] { "-m" });
Assert.False(r.Ok);
}
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class ConfigLoaderTests
{
[Fact]
public void SplitBaseUrls_trims_and_drops_empties()
{
var urls = ConfigLoader.SplitBaseUrls("a, b ,,c");
Assert.Equal(new[] { "a", "b", "c" }, urls);
}
[Fact]
public void SplitBaseUrls_null_or_whitespace_returns_empty()
{
Assert.Empty(ConfigLoader.SplitBaseUrls(null));
Assert.Empty(ConfigLoader.SplitBaseUrls(" "));
}
[Fact]
public void Load_defaults_timeout_to_30_when_omitted()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\"}}");
Assert.Equal(30, cfg.ScadaBridge.TimeoutSeconds);
Assert.Equal("http://x", cfg.ScadaBridge.BaseUrls);
}
[Fact]
public void Load_reads_all_fields()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"a,b\",\"TimeoutSeconds\":5,\"LogPath\":\"l.log\"}}");
Assert.Equal("a,b", cfg.ScadaBridge.BaseUrls);
Assert.Equal(5, cfg.ScadaBridge.TimeoutSeconds);
Assert.Equal("l.log", cfg.ScadaBridge.LogPath);
}
[Fact]
public void ResolveApiKey_returns_null_when_unset_or_whitespace()
{
Assert.Null(ConfigLoader.ResolveApiKey(_ => null));
Assert.Null(ConfigLoader.ResolveApiKey(_ => " "));
}
[Fact]
public void ResolveApiKey_returns_value_when_present()
{
Assert.Equal("sbk_x", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_x" : null));
}
}
@@ -0,0 +1,62 @@
using System.Net;
using System.Text;
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class HttpRecipeSenderTests
{
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(responder(request));
}
}
private static readonly RecipeDownload Payload =
new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" };
[Fact]
public async Task ConnectFailure_maps_to_ConnectFailed()
{
var handler = new StubHandler(_ => throw new HttpRequestException("refused"));
using var http = new HttpClient(handler);
var sender = new HttpRecipeSender(http, "sbk_x");
var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None);
Assert.Equal(AttemptKind.ConnectFailed, outcome.Kind);
Assert.Equal(0, outcome.StatusCode);
}
[Fact]
public async Task Success_200_parses_body_and_sends_header_and_path()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Result\":true,\"ResultText\":\"\"}", Encoding.UTF8, "application/json"),
});
using var http = new HttpClient(handler);
var sender = new HttpRecipeSender(http, "sbk_x");
var outcome = await sender.SendAsync("http://a/", Payload, CancellationToken.None);
Assert.Equal(AttemptKind.Connected, outcome.Kind);
Assert.Equal(200, outcome.StatusCode);
Assert.NotNull(outcome.Body);
Assert.True(outcome.Body!.Result);
Assert.Equal("sbk_x", handler.LastRequest!.Headers.GetValues("X-API-Key").Single());
Assert.Equal("http://a/api/DelmiaRecipeDownload", handler.LastRequest.RequestUri!.ToString());
}
[Fact]
public async Task Non2xx_is_Connected_with_status()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
using var http = new HttpClient(handler);
var sender = new HttpRecipeSender(http, "sbk_x");
var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None);
Assert.Equal(AttemptKind.Connected, outcome.Kind);
Assert.Equal(500, outcome.StatusCode);
}
}
@@ -0,0 +1,27 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
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);
}
}
@@ -0,0 +1,70 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class NotifierTests
{
private sealed class FakeSender(params AttemptOutcome[] outcomes) : IRecipeSender
{
private readonly Queue<AttemptOutcome> _outcomes = new(outcomes);
public int Calls { get; private set; }
public Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
{
Calls++;
return Task.FromResult(_outcomes.Dequeue());
}
}
private static readonly string[] TwoUrls = ["http://a", "http://b"];
private static readonly RecipeDownload Payload =
new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" };
[Fact]
public async Task ConnectFailure_advances_to_next_url()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "refused"),
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = true }, null));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal(2, sender.Calls);
}
[Fact]
public async Task Connected_result_false_is_final_no_failover()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = false, ResultText = "bad machine" }, null),
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = true }, null));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("bad machine", result.Reason);
Assert.Equal(1, sender.Calls);
}
[Fact]
public async Task Connected_5xx_is_final_no_failover()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.Connected, 500, null, null),
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = true }, null));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("500", result.Reason);
Assert.Equal(1, sender.Calls);
}
[Fact]
public async Task All_connect_failures_report_unreachable_with_last_error()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "refused-a"),
new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "refused-b"));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("unreachable", result.Reason);
Assert.Contains("refused-b", result.Reason);
Assert.Equal(2, sender.Calls);
}
}
@@ -0,0 +1,24 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class ReporterTests
{
[Fact]
public void Success_writes_YES_and_returns_0()
{
using var sw = new StringWriter();
var code = Reporter.Report(true, "", sw);
Assert.Equal("YES\n", sw.ToString());
Assert.Equal(0, code);
}
[Fact]
public void Failure_writes_NO_then_reason_and_returns_minus1()
{
using var sw = new StringWriter();
var code = Reporter.Report(false, "boom", sw);
Assert.Equal("NO\nboom", sw.ToString());
Assert.Equal(-1, code);
}
}
@@ -0,0 +1,21 @@
<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>