Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b58f3aeb61 | |||
| 99ceb6677f | |||
| 3931fa2101 | |||
| 71f680d542 | |||
| d26462ed8d | |||
| 991c263c3e | |||
| 3e964acff6 | |||
| 82bde9693e | |||
| 069881ac9b | |||
| 9ce6783139 | |||
| 0008ca891c |
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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>
|
||||
Reference in New Issue
Block a user