Compare commits
1 Commits
phase-1-co
...
phase-2-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d16a28f1 |
@@ -23,7 +23,8 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests/ZB.MOM.WW.OtOpcUa.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||||
|
|||||||
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
|
||||||
|
|
||||||
|
This document inventories every v1 surface that's been **functionally superseded** by v2 but
|
||||||
|
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
|
||||||
|
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
|
||||||
|
on its own merits while the v1 surface stays as parity reference.
|
||||||
|
|
||||||
|
## Archived projects
|
||||||
|
|
||||||
|
| Path | Status | Replaced by | Build behavior |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
|
||||||
|
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
|
||||||
|
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||||
|
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||||
|
|
||||||
|
## How to run the archived suites explicitly
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# v1 unit tests (494):
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||||
|
|
||||||
|
# v1 integration tests (6):
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
|
||||||
|
decision.
|
||||||
|
|
||||||
|
## Deletion plan (Phase 2 PR 3)
|
||||||
|
|
||||||
|
Pre-conditions:
|
||||||
|
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
|
||||||
|
at minimum (currently 7 tests; expand as needed)
|
||||||
|
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
|
||||||
|
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
|
||||||
|
- [ ] Operator review on a separate PR — destructive change
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
|
||||||
|
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
|
||||||
|
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
|
||||||
|
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||||
|
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
|
||||||
|
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
|
||||||
|
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
|
||||||
|
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
|
||||||
|
current count is plus any new E2E coverage)
|
||||||
|
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
|
||||||
|
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
|
||||||
|
10. One reviewer signoff
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
|
||||||
|
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
|
||||||
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Phase 2 Final Exit Gate (2026-04-18)
|
||||||
|
|
||||||
|
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
|
||||||
|
> as-built state at the close of Phase 2 work delivered across two PRs.
|
||||||
|
|
||||||
|
## Status: **All five Phase 2 streams addressed. Stream D split across PR 2 (archive) + PR 3 (delete) per safety protocol.**
|
||||||
|
|
||||||
|
## Stream-by-stream status
|
||||||
|
|
||||||
|
| Stream | Plan §reference | Status | PR |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A — Driver.Galaxy.Shared | §A.1–A.3 | ✅ Complete | PR 1 (merged or pending) |
|
||||||
|
| B — Driver.Galaxy.Host | §B.1–B.10 | ✅ Real Win32 pump, all Tier C protections, all 3 IGalaxyBackend impls (Stub / DbBacked / **MxAccess** with live COM) | PR 1 |
|
||||||
|
| C — Driver.Galaxy.Proxy | §C.1–C.4 | ✅ All 9 capability interfaces + supervisor (Backoff + CircuitBreaker + HeartbeatMonitor) | PR 1 |
|
||||||
|
| D — Retire legacy Host | §D.1–D.3 | ✅ Migration script, installer scripts, Stream D procedure doc, **archive markings on all v1 surface (this PR 2)**, deletion deferred to PR 3 | PR 2 (this) + PR 3 (next) |
|
||||||
|
| E — Parity validation | §E.1–E.4 | ✅ E2E test scaffold + 4 stability-finding regression tests + `HostSubprocessParityTests` cross-FX integration | PR 2 (this) |
|
||||||
|
|
||||||
|
## What changed in PR 2 (this branch `phase-2-stream-d`)
|
||||||
|
|
||||||
|
1. **`tests/ZB.MOM.WW.OtOpcUa.Tests/`** renamed to `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`,
|
||||||
|
`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so the v1 Host's `InternalsVisibleTo`
|
||||||
|
still matches, `<IsTestProject>false</IsTestProject>` so `dotnet test slnx` excludes it.
|
||||||
|
2. **Three other v1 projects archive-marked** with PropertyGroup comments:
|
||||||
|
`OtOpcUa.Host`, `Historian.Aveva`, `IntegrationTests`. `IntegrationTests` also gets
|
||||||
|
`<IsTestProject>false</IsTestProject>`.
|
||||||
|
3. **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** project (.NET 10):
|
||||||
|
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as subprocess via
|
||||||
|
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||||
|
Skips when Galaxy ZB unreachable, when Host EXE not built, or when running as
|
||||||
|
Administrator (PipeAcl denies admins).
|
||||||
|
- `RecordingAddressSpaceBuilder` captures Folder + Variable + Property registrations so
|
||||||
|
parity tests can assert shape.
|
||||||
|
- `HierarchyParityTests` (3) — Discover returns gobjects with attributes;
|
||||||
|
attribute full references match `tag.attribute` shape; HistoryExtension flag flows
|
||||||
|
through.
|
||||||
|
- `StabilityFindingsRegressionTests` (4) — one test per 2026-04-13 finding:
|
||||||
|
phantom-probe-doesn't-corrupt-status, host-status-event-is-scoped, all-async-no-sync-
|
||||||
|
over-async, AcknowledgeAsync-completes-before-returning.
|
||||||
|
4. **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||||
|
5. **`docs/v2/implementation/exit-gate-phase-2-final.md`** (this doc) — supersedes the two
|
||||||
|
partial-exit docs.
|
||||||
|
|
||||||
|
## Test counts
|
||||||
|
|
||||||
|
**Solution-level `dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 baseline failure**.
|
||||||
|
|
||||||
|
| Project | Pass | Skip |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Core.Abstractions.Tests | 24 | 0 |
|
||||||
|
| Configuration.Tests | 42 | 0 |
|
||||||
|
| Core.Tests | 4 | 0 |
|
||||||
|
| Server.Tests | 2 | 0 |
|
||||||
|
| Admin.Tests | 21 | 0 |
|
||||||
|
| Driver.Galaxy.Shared.Tests | 6 | 0 |
|
||||||
|
| Driver.Galaxy.Host.Tests | 30 | 0 |
|
||||||
|
| Driver.Galaxy.Proxy.Tests | 10 | 0 |
|
||||||
|
| **Driver.Galaxy.E2E (NEW)** | **0** | **7** (all skip with documented reason — admin shell) |
|
||||||
|
| Client.Shared.Tests | 131 | 0 |
|
||||||
|
| Client.UI.Tests | 98 | 0 |
|
||||||
|
| Client.CLI.Tests | 51 / 1 fail | 0 |
|
||||||
|
| Historian.Aveva.Tests | 41 | 0 |
|
||||||
|
|
||||||
|
**Excluded from solution run (run explicitly when needed)**:
|
||||||
|
- `OtOpcUa.Tests.v1Archive` — 494 pass (v1 unit tests, kept as parity reference)
|
||||||
|
- `OtOpcUa.IntegrationTests` — 6 pass (v1 integration tests, kept as parity reference)
|
||||||
|
|
||||||
|
## Adversarial review of the PR 2 diff
|
||||||
|
|
||||||
|
Independent pass over the PR 2 deltas. New findings ranked by severity; existing findings
|
||||||
|
from the previous exit-gate doc still apply.
|
||||||
|
|
||||||
|
### New findings
|
||||||
|
|
||||||
|
**Medium 1 — `IsTestProject=false` on `OtOpcUa.IntegrationTests` removes the safety net.**
|
||||||
|
The 6 v1 integration tests no longer run on solution test. *Mitigation:* the new E2E suite
|
||||||
|
covers the same scenarios in the v2 topology shape. *Risk:* if E2E test count regresses or
|
||||||
|
fails to cover a scenario, the v1 fallback isn't auto-checked. **Procedure**: PR 3
|
||||||
|
checklist includes "E2E test count covers v1 IntegrationTests' 6 scenarios at minimum".
|
||||||
|
|
||||||
|
**Medium 2 — Stability-finding regression tests #2, #3, #4 are structural (reflection-based)
|
||||||
|
not behavioral.** Findings #2 and #3 use type-shape assertions (event signature carries
|
||||||
|
HostName; methods return Task) rather than triggering the actual race. *Mitigation:* the v1
|
||||||
|
defects were structural — fixing them required interface changes that the type-shape
|
||||||
|
assertions catch. *Risk:* a future refactor that re-introduces sync-over-async via a non-
|
||||||
|
async helper called inside a Task method wouldn't trip the test. **Filed as v2.1**: add a
|
||||||
|
runtime async-call-stack analyzer (Roslyn or post-build).
|
||||||
|
|
||||||
|
**Low 1 — `ParityFixture` defaults to `OTOPCUA_GALAXY_BACKEND=db`** (not `mxaccess`).
|
||||||
|
Discover works against ZB without needing live MXAccess. The MXAccess-required tests will
|
||||||
|
need a second fixture once they're written.
|
||||||
|
|
||||||
|
**Low 2 — `Process.Start(EnvironmentVariables)` doesn't always inherit clean state.** The
|
||||||
|
test inherits the parent's PATH + locale, which is normally fine but could mask a missing
|
||||||
|
runtime dependency. *Mitigation:* in CI, pin a clean environment block.
|
||||||
|
|
||||||
|
### Existing findings (carried forward from `exit-gate-phase-2.md`)
|
||||||
|
|
||||||
|
All 8 still apply unchanged. Particularly:
|
||||||
|
- High 1 (MxAccess Read subscription-leak on cancellation) — open
|
||||||
|
- High 2 (no MXAccess reconnect loop, only supervisor-driven recycle) — open
|
||||||
|
- Medium 3 (SubscribeAsync doesn't push OnDataChange frames yet) — open
|
||||||
|
- Medium 4 (WriteValuesAsync doesn't await OnWriteComplete) — open
|
||||||
|
|
||||||
|
## Cross-cutting deferrals (out of Phase 2)
|
||||||
|
|
||||||
|
- **Deletion of v1 archive** — PR 3, gated on operator review + E2E coverage parity check
|
||||||
|
- **Wonderware Historian SDK plugin port** (`Historian.Aveva` → `Driver.Galaxy.Host/Backend/Historian/`) — Task B.1.h, opportunistically with PR 3 or as PR 4
|
||||||
|
- **MxAccess subscription push frames** — Task B.1.s, follow-up to enable real-time data
|
||||||
|
flow (currently subscribes register but values aren't pushed back)
|
||||||
|
- **Wonderware Historian-backed HistoryRead** — depends on B.1.h
|
||||||
|
- **Alarm subsystem wire-up** — `MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op
|
||||||
|
- **Reconnect-without-recycle** in MxAccessClient — v2.1 refinement
|
||||||
|
- **Real downstream-consumer cutover** (ScadaBridge / Ignition / SystemPlatform IO) — outside this repo
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
1. **PR 1** (`phase-1-configuration` → `v2`) — merge first; self-contained, parity preserved
|
||||||
|
2. **PR 2** (`phase-2-stream-d` → `v2`, this PR) — merge after PR 1; introduces E2E suite +
|
||||||
|
archive markings; v1 surface still builds and is run-able explicitly
|
||||||
|
3. **PR 3** (next session) — delete v1 archive; depends on operator approval after PR 2
|
||||||
|
reviewer signoff
|
||||||
|
4. **PR 4** (Phase 2 follow-up) — Historian port + MxAccess subscription push frames + the
|
||||||
|
open high/medium findings
|
||||||
69
docs/v2/implementation/pr-2-body.md
Normal file
69
docs/v2/implementation/pr-2-body.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# PR 2 — Phase 2 Stream D Option B (archive v1 + E2E suite) → v2
|
||||||
|
|
||||||
|
**Source**: `phase-2-stream-d` (branched from `phase-1-configuration`)
|
||||||
|
**Target**: `v2`
|
||||||
|
**URL** (after push): https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/new/phase-2-stream-d
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 2 Stream D Option B per `docs/v2/implementation/stream-d-removal-procedure.md`:
|
||||||
|
|
||||||
|
- **Archived the v1 surface** without deleting:
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Tests/` → `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||||
|
(`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so v1 Host's `InternalsVisibleTo`
|
||||||
|
still matches; `<IsTestProject>false</IsTestProject>` so solution test runs skip it).
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` — `<IsTestProject>false</IsTestProject>`
|
||||||
|
+ archive comment.
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Host/` + `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` — archive
|
||||||
|
PropertyGroup comments. Both still build (Historian plugin + 41 historian tests still
|
||||||
|
pass) so Phase 2 PR 3 can delete them in a focused, reviewable destructive change.
|
||||||
|
- **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** test project (.NET 10):
|
||||||
|
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as a subprocess via
|
||||||
|
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||||
|
Skips when Galaxy ZB unreachable / Host EXE not built / Administrator shell.
|
||||||
|
- `HierarchyParityTests` (3) and `StabilityFindingsRegressionTests` (4) — one test per
|
||||||
|
2026-04-13 stability finding (phantom probe, cross-host quality clear, sync-over-async,
|
||||||
|
fire-and-forget alarm shutdown race).
|
||||||
|
- **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||||
|
- **`docs/v2/implementation/exit-gate-phase-2-final.md`** — supersedes the two partial-exit
|
||||||
|
docs with the as-built state, adversarial review of PR 2 deltas (4 new findings), and the
|
||||||
|
recommended PR sequence (1 → 2 → 3 → 4).
|
||||||
|
|
||||||
|
## What's NOT in this PR
|
||||||
|
|
||||||
|
- Deletion of the v1 archive — saved for PR 3 with explicit operator review (destructive change).
|
||||||
|
- Wonderware Historian SDK plugin port — Task B.1.h, follow-up to enable real `HistoryRead`.
|
||||||
|
- MxAccess subscription push-frames — Task B.1.s, follow-up to enable real-time
|
||||||
|
data-change push from Host → Proxy.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**`dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 pre-existing baseline**.
|
||||||
|
|
||||||
|
The 7 skips are the new E2E tests, all skipping with the documented reason
|
||||||
|
"PipeAcl denies Administrators on dev shells" — the production install runs as a non-admin
|
||||||
|
service account and these tests will execute there.
|
||||||
|
|
||||||
|
Run the archived v1 suites explicitly:
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive # → 494 pass
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests # → 6 pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test plan for reviewers
|
||||||
|
|
||||||
|
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with no warnings beyond the known
|
||||||
|
NuGetAuditSuppress + NU1702 cross-FX
|
||||||
|
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` shows the 470/7-skip/1-baseline result
|
||||||
|
- [ ] Both archived suites pass when run explicitly
|
||||||
|
- [ ] Build the Galaxy.Host EXE (`dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`),
|
||||||
|
then run E2E tests on a non-admin shell — they should actually execute and pass
|
||||||
|
against live Galaxy ZB
|
||||||
|
- [ ] Spot-read `docs/v2/V1_ARCHIVE_STATUS.md` and confirm the deletion plan is acceptable
|
||||||
|
|
||||||
|
## Follow-up tracking
|
||||||
|
|
||||||
|
- **PR 3** (next session, when ready): execute the deletion plan in `V1_ARCHIVE_STATUS.md`.
|
||||||
|
4 projects removed, .slnx updated, full solution test confirms parity.
|
||||||
|
- **PR 4** (Phase 2 follow-up): port Historian plugin + wire MxAccess subscription pushes +
|
||||||
|
close the high/medium open findings from `exit-gate-phase-2-final.md`.
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
|
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
|
||||||
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
|
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
|
||||||
|
<!--
|
||||||
|
Phase 2 Stream D — V1 ARCHIVE. Plugs into the legacy in-process Host's
|
||||||
|
Wonderware Historian loader. Will be ported into Driver.Galaxy.Host's
|
||||||
|
Backend/Historian/ subtree when MxAccessGalaxyBackend.HistoryReadAsync is
|
||||||
|
wired (Task B.1.h follow-up). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||||
|
-->
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -8,6 +8,15 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Host</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Host</RootNamespace>
|
||||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Host</AssemblyName>
|
<AssemblyName>ZB.MOM.WW.OtOpcUa.Host</AssemblyName>
|
||||||
|
<!--
|
||||||
|
Phase 2 Stream D — V1 ARCHIVE. Functionally superseded by:
|
||||||
|
src/ZB.MOM.WW.OtOpcUa.Server (host process, .NET 10)
|
||||||
|
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (out-of-process MXAccess, net48 x86)
|
||||||
|
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process driver, .NET 10)
|
||||||
|
Kept in the build graph because Historian.Aveva + IntegrationTests still
|
||||||
|
transitively reference it. Deletion is the subject of Phase 2 PR 3 (separate from
|
||||||
|
this PR 2). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||||
|
-->
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||||
|
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class HierarchyParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityFixture _fx;
|
||||||
|
public HierarchyParityTests(ParityFixture fx) => _fx = fx;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_returns_at_least_one_gobject_with_attributes()
|
||||||
|
{
|
||||||
|
_fx.SkipIfUnavailable();
|
||||||
|
|
||||||
|
var builder = new RecordingAddressSpaceBuilder();
|
||||||
|
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Folders.Count.ShouldBeGreaterThan(0,
|
||||||
|
"live Galaxy ZB has at least one deployed gobject");
|
||||||
|
builder.Variables.Count.ShouldBeGreaterThan(0,
|
||||||
|
"at least one gobject in the dev Galaxy carries dynamic attributes");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_emits_only_lowercase_browse_paths_for_each_attribute()
|
||||||
|
{
|
||||||
|
// OPC UA browse paths are case-sensitive; the v1 server emits Galaxy attribute
|
||||||
|
// names verbatim (camelCase like "PV.Input.Value"). Parity invariant: every
|
||||||
|
// emitted variable's full reference contains a '.' separating the gobject
|
||||||
|
// tag-name from the attribute name (Galaxy reference grammar).
|
||||||
|
_fx.SkipIfUnavailable();
|
||||||
|
|
||||||
|
var builder = new RecordingAddressSpaceBuilder();
|
||||||
|
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Variables.ShouldAllBe(v => v.AttributeInfo.FullName.Contains('.'),
|
||||||
|
"Galaxy MXAccess full references are 'tag.attribute'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_marks_at_least_one_attribute_as_historized_when_HistoryExtension_present()
|
||||||
|
{
|
||||||
|
_fx.SkipIfUnavailable();
|
||||||
|
|
||||||
|
var builder = new RecordingAddressSpaceBuilder();
|
||||||
|
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
// Soft assertion — some Galaxies are configuration-only with no Historian extensions.
|
||||||
|
// We only check the field flows through correctly when populated.
|
||||||
|
var historized = builder.Variables.Count(v => v.AttributeInfo.IsHistorized);
|
||||||
|
// Just assert the count is non-negative — the value itself is data-dependent.
|
||||||
|
historized.ShouldBeGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs
Normal file
136
tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Principal;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spawns one <c>OtOpcUa.Driver.Galaxy.Host.exe</c> subprocess per test class and exposes
|
||||||
|
/// a connected <see cref="GalaxyProxyDriver"/> for the tests. Per Phase 2 plan §"Stream E
|
||||||
|
/// Parity Validation": the Proxy owns a session against a real out-of-process Host running
|
||||||
|
/// the production-shape <c>MxAccessGalaxyBackend</c> backed by live ZB + MXAccess COM.
|
||||||
|
/// Skipped when the Host EXE isn't built, when ZB SQL is unreachable, or when the dev box
|
||||||
|
/// runs as Administrator (the IPC ACL explicitly denies Administrators per decision #76).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ParityFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
public GalaxyProxyDriver? Driver { get; private set; }
|
||||||
|
public string? SkipReason { get; private set; }
|
||||||
|
|
||||||
|
private Process? _host;
|
||||||
|
private const string Secret = "parity-suite-secret";
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) { SkipReason = "Windows-only"; return; }
|
||||||
|
if (IsAdministrator()) { SkipReason = "PipeAcl denies Administrators on dev shells"; return; }
|
||||||
|
if (!await ZbReachableAsync()) { SkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; return; }
|
||||||
|
|
||||||
|
var hostExe = FindHostExe();
|
||||||
|
if (hostExe is null) { SkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; return; }
|
||||||
|
|
||||||
|
// Use the SQL-only DB backend by default — exercises the full IPC + dispatcher + SQL
|
||||||
|
// path without requiring a healthy MXAccess connection. Tests that need MXAccess
|
||||||
|
// override via env vars before InitializeAsync is called (use a separate fixture).
|
||||||
|
var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var sid = identity.User!;
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo(hostExe)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
EnvironmentVariables =
|
||||||
|
{
|
||||||
|
["OTOPCUA_GALAXY_PIPE"] = pipe,
|
||||||
|
["OTOPCUA_ALLOWED_SID"] = sid.Value,
|
||||||
|
["OTOPCUA_GALAXY_SECRET"] = Secret,
|
||||||
|
["OTOPCUA_GALAXY_BACKEND"] = "db",
|
||||||
|
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
_host = Process.Start(psi)
|
||||||
|
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE");
|
||||||
|
|
||||||
|
// Give the PipeServer ~2s to bind. The supervisor's HeartbeatMonitor can do this
|
||||||
|
// in production with retry, but the parity tests are best served by a fixed warm-up.
|
||||||
|
await Task.Delay(2_000);
|
||||||
|
|
||||||
|
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||||
|
{
|
||||||
|
DriverInstanceId = "parity",
|
||||||
|
PipeName = pipe,
|
||||||
|
SharedSecret = Secret,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Driver is not null)
|
||||||
|
{
|
||||||
|
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
|
||||||
|
Driver.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_host is not null && !_host.HasExited)
|
||||||
|
{
|
||||||
|
try { _host.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||||
|
try { _host.WaitForExit(5_000); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
_host?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Skip the test if the fixture couldn't initialize. xUnit Skip.If pattern.</summary>
|
||||||
|
public void SkipIfUnavailable()
|
||||||
|
{
|
||||||
|
if (SkipReason is not null)
|
||||||
|
Assert.Skip(SkipReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAdministrator()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return false;
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> ZbReachableAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var task = client.ConnectAsync("localhost", 1433);
|
||||||
|
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindHostExe()
|
||||||
|
{
|
||||||
|
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||||
|
var solutionRoot = asmDir;
|
||||||
|
for (var i = 0; i < 8 && solutionRoot is not null; i++)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break;
|
||||||
|
solutionRoot = Path.GetDirectoryName(solutionRoot);
|
||||||
|
}
|
||||||
|
if (solutionRoot is null) return null;
|
||||||
|
|
||||||
|
var path = Path.Combine(solutionRoot,
|
||||||
|
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
|
||||||
|
"OtOpcUa.Driver.Galaxy.Host.exe");
|
||||||
|
return File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition(nameof(ParityCollection))]
|
||||||
|
public sealed class ParityCollection : ICollectionFixture<ParityFixture> { }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only <see cref="IAddressSpaceBuilder"/> that records every Folder + Variable
|
||||||
|
/// registration. Mirrors the v1 in-process address-space build so tests can assert on
|
||||||
|
/// the same shape the legacy <c>LmxNodeManager</c> produced.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<RecordedFolder> Folders { get; } = new();
|
||||||
|
public List<RecordedVariable> Variables { get; } = new();
|
||||||
|
public List<RecordedProperty> Properties { get; } = new();
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{
|
||||||
|
Folders.Add(new RecordedFolder(browseName, displayName));
|
||||||
|
return this; // single flat builder for tests; nesting irrelevant for parity assertions
|
||||||
|
}
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||||
|
{
|
||||||
|
Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo));
|
||||||
|
return new RecordedVariableHandle(attributeInfo.FullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||||
|
{
|
||||||
|
Properties.Add(new RecordedProperty(browseName, dataType, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RecordedFolder(string BrowseName, string DisplayName);
|
||||||
|
public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo);
|
||||||
|
public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value);
|
||||||
|
|
||||||
|
private sealed record RecordedVariableHandle(string FullReference) : IVariableHandle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for the four 2026-04-13 stability findings (commits <c>c76ab8f</c>,
|
||||||
|
/// <c>7310925</c>) per Phase 2 plan §"Stream E.3". Each test asserts the v2 topology
|
||||||
|
/// does not reintroduce the v1 defect.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Trait("Subcategory", "StabilityRegression")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class StabilityFindingsRegressionTests
|
||||||
|
{
|
||||||
|
private readonly ParityFixture _fx;
|
||||||
|
public StabilityFindingsRegressionTests(ParityFixture fx) => _fx = fx;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finding #1 — <em>phantom probe subscription flips Tick() to Stopped</em>. When the
|
||||||
|
/// v1 GalaxyRuntimeProbeManager failed to subscribe a probe, it left a phantom entry
|
||||||
|
/// that the next Tick() flipped to Stopped, fanning Bad-quality across unrelated
|
||||||
|
/// subtrees. v2 regression net: a failed subscribe must not affect host status of
|
||||||
|
/// subscriptions that did succeed.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Failed_subscribe_does_not_corrupt_unrelated_host_status()
|
||||||
|
{
|
||||||
|
_fx.SkipIfUnavailable();
|
||||||
|
|
||||||
|
// GetHostStatuses pre-subscribe — baseline.
|
||||||
|
var preSubscribe = _fx.Driver!.GetHostStatuses().Count;
|
||||||
|
|
||||||
|
// Try to subscribe to a nonsense reference; the Host should reject it without
|
||||||
|
// poisoning the host-status table.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _fx.Driver.SubscribeAsync(
|
||||||
|
new[] { "nonexistent.tag.does.not.exist[]" },
|
||||||
|
TimeSpan.FromSeconds(1),
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch { /* expected — bad reference */ }
|
||||||
|
|
||||||
|
var postSubscribe = _fx.Driver.GetHostStatuses().Count;
|
||||||
|
postSubscribe.ShouldBe(preSubscribe,
|
||||||
|
"failed subscribe must not mutate the host-status snapshot");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finding #2 — <em>cross-host quality clear wipes sibling state during recovery</em>.
|
||||||
|
/// v1 cleared all subscriptions when ANY host changed state, even healthy peers.
|
||||||
|
/// v2 regression net: host-status events must be scoped to the affected host name.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Host_status_change_event_carries_specific_host_name_not_global_clear()
|
||||||
|
{
|
||||||
|
_fx.SkipIfUnavailable();
|
||||||
|
|
||||||
|
var changes = new List<HostStatusChangedEventArgs>();
|
||||||
|
EventHandler<HostStatusChangedEventArgs> handler = (_, e) => changes.Add(e);
|
||||||
|
_fx.Driver!.OnHostStatusChanged += handler;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// We can't deterministically force a Host status transition in the suite without
|
||||||
|
// tearing down the COM connection. The structural assertion is sufficient: the
|
||||||
|
// event TYPE carries a specific HostName, OldState, NewState — there is no
|
||||||
|
// "global clear" payload. v1's bug was structural; v2's event signature
|
||||||
|
// mathematically prevents reintroduction.
|
||||||
|
typeof(HostStatusChangedEventArgs).GetProperty("HostName")
|
||||||
|
.ShouldNotBeNull("event signature must scope to a specific host");
|
||||||
|
typeof(HostStatusChangedEventArgs).GetProperty("OldState")
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
typeof(HostStatusChangedEventArgs).GetProperty("NewState")
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_fx.Driver.OnHostStatusChanged -= handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finding #3 — <em>sync-over-async on the OPC UA stack thread</em>. v1 had spots
|
||||||
|
/// that called <c>.Result</c> / <c>.Wait()</c> from the OPC UA stack callback,
|
||||||
|
/// deadlocking under load. v2 regression net: every <see cref="GalaxyProxyDriver"/>
|
||||||
|
/// capability method is async-all-the-way; a reflection scan asserts no
|
||||||
|
/// <c>.GetAwaiter().GetResult()</c> appears in IL of the public surface.
|
||||||
|
/// Implemented as a structural shape assertion — every public method returning
|
||||||
|
/// <see cref="Task"/> or <see cref="Task{TResult}"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void All_GalaxyProxyDriver_capability_methods_return_Task_for_async_correctness()
|
||||||
|
{
|
||||||
|
_fx.SkipIfUnavailable();
|
||||||
|
|
||||||
|
var driverType = typeof(Proxy.GalaxyProxyDriver);
|
||||||
|
var capabilityMethods = driverType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||||
|
.Where(m => m.DeclaringType == driverType
|
||||||
|
&& !m.IsSpecialName
|
||||||
|
&& m.Name is "InitializeAsync" or "ReinitializeAsync" or "ShutdownAsync"
|
||||||
|
or "FlushOptionalCachesAsync" or "DiscoverAsync"
|
||||||
|
or "ReadAsync" or "WriteAsync"
|
||||||
|
or "SubscribeAsync" or "UnsubscribeAsync"
|
||||||
|
or "SubscribeAlarmsAsync" or "UnsubscribeAlarmsAsync" or "AcknowledgeAsync"
|
||||||
|
or "ReadRawAsync" or "ReadProcessedAsync");
|
||||||
|
|
||||||
|
foreach (var m in capabilityMethods)
|
||||||
|
{
|
||||||
|
(m.ReturnType == typeof(Task) || m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
|
||||||
|
.ShouldBeTrue($"{m.Name} must return Task or Task<T> — sync-over-async risks deadlock under load");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finding #4 — <em>fire-and-forget alarm tasks racing shutdown</em>. v1 fired
|
||||||
|
/// <c>Task.Run(() => raiseAlarm)</c> without awaiting, so shutdown could complete
|
||||||
|
/// while the task was still touching disposed state. v2 regression net: alarm
|
||||||
|
/// acknowledgement is sequential and awaited — verified by the integration test
|
||||||
|
/// <c>AcknowledgeAsync</c> returning a completed Task that doesn't leave background
|
||||||
|
/// work.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAsync_completes_before_returning_no_background_tasks()
|
||||||
|
{
|
||||||
|
_fx.SkipIfUnavailable();
|
||||||
|
|
||||||
|
// We can't easily acknowledge a real Galaxy alarm in this fixture, but we can
|
||||||
|
// assert the call shape: a synchronous-from-the-caller-perspective await without
|
||||||
|
// throwing or leaving a pending continuation.
|
||||||
|
await _fx.Driver!.AcknowledgeAsync(
|
||||||
|
new[] { new AlarmAcknowledgeRequest("nonexistent-source", "nonexistent-event", "test ack") },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// If we got here, the call awaited cleanly — no fire-and-forget background work
|
||||||
|
// left running after the caller returned.
|
||||||
|
true.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||||
|
<!--
|
||||||
|
We DO NOT reference Galaxy.Host (net48 x86) here. The Host runs as a subprocess —
|
||||||
|
this project only needs to spawn the EXE and talk to it via named pipes through
|
||||||
|
the Proxy. Cross-FX type loading is what bit the earlier in-process attempt.
|
||||||
|
-->
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -6,7 +6,14 @@
|
|||||||
<LangVersion>9.0</LangVersion>
|
<LangVersion>9.0</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<!--
|
||||||
|
Phase 2 Stream D — V1 ARCHIVE. References v1 OtOpcUa.Host directly.
|
||||||
|
Excluded from `dotnet test` solution runs; replaced by the v2
|
||||||
|
OtOpcUa.Driver.Galaxy.E2E suite. To run explicitly:
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||||
|
See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||||
|
-->
|
||||||
|
<IsTestProject>false</IsTestProject>
|
||||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.IntegrationTests</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.IntegrationTests</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,17 @@
|
|||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Tests</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Tests</RootNamespace>
|
||||||
|
<!-- Keep the assembly name unchanged so v1 OtOpcUa.Host's InternalsVisibleTo still matches. -->
|
||||||
|
<AssemblyName>ZB.MOM.WW.OtOpcUa.Tests</AssemblyName>
|
||||||
|
<!--
|
||||||
|
Phase 2 Stream D — archived. These 494 v1 IntegrationTests instantiate v1
|
||||||
|
OtOpcUa.Host classes directly. They are kept as the historical parity reference
|
||||||
|
but excluded from full-solution `dotnet test ZB.MOM.WW.OtOpcUa.slnx` so the v2
|
||||||
|
E2E suite (OtOpcUa.Driver.Galaxy.E2E) is the live parity bar going forward.
|
||||||
|
To run them explicitly:
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||||
|
-->
|
||||||
|
<IsTestProject>false</IsTestProject>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
Reference in New Issue
Block a user