Merge pull request 'S7 integration fixture via python-snap7 (#216) + per-driver test-fixture coverage docs' (#160) from s7-integration-fixture into v2

This commit was merged in pull request #160.
This commit is contained in:
2026-04-20 11:31:14 -04:00
19 changed files with 1605 additions and 1 deletions

View File

@@ -34,6 +34,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>

View File

@@ -0,0 +1,97 @@
# AB Legacy test fixture
Coverage map + gap inventory for the AB Legacy (PCCC) driver — SLC 500 /
MicroLogix / PLC-5 / LogixPccc-mode.
**TL;DR: there is no integration fixture.** Everything runs through a
`FakeAbLegacyTag` injected via `IAbLegacyTagFactory`. libplctag powers the
real wire path but ships no in-process fake, and `ab_server` has no PCCC
emulation either — so PCCC behavior against real hardware is trusted from
field deployments, not from CI.
## What the fixture is
Nothing at the integration layer.
`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` is unit-only, all tests
tagged `[Trait("Category", "Unit")]`. The driver accepts
`IAbLegacyTagFactory` via ctor DI; every test supplies a `FakeAbLegacyTag`.
## What it actually covers (unit only)
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
per-parent `SemaphoreSlim` (mirrors the AB CIP + FOCAS PMC-bit pattern from #181)
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
fake-returned statuses
- `AbLegacyDriverTests``IDriver` lifecycle
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
`IPerCallHostResolver`.
## What it does NOT cover
### 1. Wire-level PCCC
No PCCC frame is sent by the test suite. libplctag's PCCC subset (DF1,
ControlNet-over-EtherNet, PLC-5 native EtherNet) is untested here;
driver-side correctness depends on libplctag being correct.
### 2. Family-specific behavior
- SLC 500 timeout + retry thresholds (SLC's comm module has known slow-response
edges) — unit fakes don't simulate timing.
- MicroLogix 1100 / 1400 max-connection-count limits — not stressed.
- PLC-5 native EtherNet connection setup (PCCC-encapsulated-in-CIP vs raw
CSPv4) — routing covered at parse level only.
### 3. Multi-device routing
`IPerCallHostResolver` contract is verified; real PCCC wire routing across
multiple gateways is not.
### 4. Alarms / history
PCCC has no alarm object + no history object. Driver doesn't implement
`IAlarmSource` or `IHistoryProvider` — no test coverage is the correct shape.
### 5. File-type coverage
PCCC has many file types (N, F, B, T, C, R, S, ST, A) — the parser tests
cover the common ones but uncommon ones (`R` counters, `S` status files,
`A` ASCII strings) have thin coverage.
## When to trust AB Legacy tests, when to reach for a rig
| Question | Unit tests | Real PLC |
| --- | --- | --- |
| "Does `N7:0/5` parse correctly?" | yes | - |
| "Does bit-in-word RMW serialize concurrent writers?" | yes | yes |
| "Does the driver lifecycle hang / crash?" | yes | yes |
| "Does a real read against an SLC 500 return correct bytes?" | no | yes (required) |
| "Does MicroLogix 1100 respect its connection-count cap?" | no | yes (required) |
| "Do PLC-5 ST-files round-trip correctly?" | no | yes (required) |
## Follow-up candidates
1. **Nothing open-source** — libplctag's test suite runs against real
hardware; there is no public PCCC simulator comparable to `pymodbus` or
`ab_server`.
2. **Lab rig** — cheapest path is a used SLC 5/05 or MicroLogix 1100 on a
dedicated network; the parts are end-of-life but still available. PLC-5
and LogixPccc-mode behavior require those specific controllers.
3. **libplctag upstream test harness** — the project's own `tests/` folder
has PCCC cases we could try to adapt, but they assume specific hardware.
AB Legacy is inherently a trust-the-library driver until someone stands up
a rig.
## Key fixture / config files
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs`
in-process fake + factory
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
at the top of the file

View File

@@ -0,0 +1,147 @@
# ab_server test fixture
Coverage map + gap inventory for the AB CIP integration fixture backed by
libplctag's `ab_server` simulator.
**TL;DR:** `ab_server` is a connectivity + atomic-read smoke harness for the AB
CIP driver. It does **not** benchmark UDTs, alarms, or any family-specific
quirk. UDT / alarm / quirk behavior is verified only by unit tests with
`FakeAbCipTagRuntime`.
## What the fixture is
- **Binary**: `ab_server` / `ab_server.exe` from libplctag
([libplctag/libplctag](https://github.com/libplctag/libplctag) +
[kyle-github/ab_server](https://github.com/kyle-github/ab_server), MIT).
Resolved off `PATH` by `AbServerFixture.LocateBinary`; tests skip via
`[AbServerFact]` / `[AbServerTheory]` when missing.
- **Lifecycle**: `AbServerFixture` (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs`)
starts the simulator with a profile-specific `--plc` arg + `--tag` seeds,
waits ~500 ms, kills on `DisposeAsync`.
- **Profiles**: `KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`
in `AbServerProfile.cs`. Each composes a CLI arg list + seed-tag set; their
own `Notes` fields document the quirks called out below.
- **Tests**: one smoke, `AbCipReadSmokeTests.Driver_reads_seeded_DInt_from_ab_server`,
parametrized over all four profiles.
## What it actually covers
- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
- Atomic Logix types per seed: `DINT`, `REAL`, `BOOL`, `SINT`, `STRING`.
- One `DINT[16]` array tag (ControlLogix profile only).
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
clone without the simulator stays green.
## What it does NOT cover
Each gap below is either stated explicitly in the profile's `Notes` field or
inferable from the seed-tag set + smoke-test surface.
### 1. UDTs / CIP Template Object (class 0x6C)
ControlLogix profile `Notes`: *"ab_server lacks full UDT emulation."*
Unverified against `ab_server`:
- PR 6 structured read/write (`AbCipStructureMember` fan-out)
- #179 Template Object shape reader (`CipTemplateObjectDecoder` + `FetchUdtShapeAsync`)
- #194 whole-UDT read optimization (`AbCipUdtReadPlanner` +
`AbCipUdtMemberLayout` + the `ReadGroupAsync` path in `AbCipDriver`)
Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
`AbCipUdtMemberLayoutTests`, `AbCipUdtReadPlannerTests`,
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
+ offset-keyed `FakeAbCipTag` values.
### 2. ALMD / ALMA alarm projection (#177)
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
`OnAlarmEvent` raise/clear path + ack-write semantics are not exercised
end-to-end.
Unit coverage: `AbCipAlarmProjectionTests` — fakes feed `InFaulted` /
`Severity` via `ValuesByOffset` + assert the emitted `AlarmEventArgs`.
### 3. Micro800 unconnected-only path
Micro800 profile `Notes`: *"ab_server has no --plc micro800 — falls back to
controllogix emulation."*
The empty routing path + unconnected-session requirement (PR 11) is unit-tested
but never challenged at the CIP wire level. Real Micro800 (2080-series) on a
lab rig would be the authoritative benchmark.
### 4. GuardLogix safety subsystem
GuardLogix profile `Notes`: *"ab_server doesn't emulate the safety
subsystem."*
Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
forced on safety tags) runs. Actual safety-partition write rejection — what
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
### 5. CompactLogix narrow ConnectionSize cap
CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
value per PR 10, but `ab_server` accepts whatever the client asks for — the
cap's correctness is trusted from its unit test, never stressed against a
simulator that rejects oversized requests.
### 6. BOOL-within-DINT read-modify-write (#181)
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
serialization is unit-tested only (`AbCipBoolInDIntRmwTests`). `ab_server`
seeds a plain `TestBOOL` tag; the `.N` bit-within-DINT syntax that triggers
the RMW path is not exercised end-to-end.
### 7. Capability surfaces beyond read
No smoke test for:
- `IWritable.WriteAsync`
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
- `ISubscribable.SubscribeAsync` (poll-group engine)
- `IHostConnectivityProbe` state transitions under wire failure
- `IPerCallHostResolver` multi-device routing
The driver implements all of these + they have unit coverage, but the only
end-to-end path `ab_server` validates today is atomic `ReadAsync`.
## When to trust ab_server, when to reach for a rig
| Question | ab_server | Unit tests | Lab rig |
| --- | --- | --- | --- |
| "Does the driver talk CIP at all?" | yes | - | - |
| "Is my atomic read path wired correctly?" | yes | yes | yes |
| "Does whole-UDT grouping work?" | no | yes | yes (required) |
| "Do ALMD alarms raise + clear?" | no | yes | yes (required) |
| "Is Micro800 unconnected-only enforced wire-side?" | no (emulated as CLX) | partial | yes (required) |
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (required) |
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (required) |
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | yes (stress) |
## Follow-up candidates
If integration-level UDT / alarm / quirk proof becomes a shipping gate, the
options are roughly:
1. **Extend `ab_server`** upstream — the project accepts PRs + already carries
a CIP framing layer that UDT emulation could plug into.
2. **Swap in a richer simulator** — e.g.
[OpenPLC](https://www.openplcproject.com/) or pycomm3's test harness, if
either exposes UDTs over EtherNet/IP in a way libplctag can consume.
3. **Stand up a lab rig** — physical `1756-L7x` / `5069-L3x` / `2080-LC30` /
`1756-L8xS` controllers running Rockwell Studio 5000 projects; wire into
CI via a self-hosted runner. The only path that covers safety + narrow
ConnectionSize + real ALMD execution.
See also:
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs`
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs`
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs`
- `docs/v2/test-data-sources.md` §2 — the broader test-data-source picking
rationale this fixture slots into

View File

@@ -0,0 +1,95 @@
# FOCAS test fixture
Coverage map + gap inventory for the FANUC FOCAS2 CNC driver.
**TL;DR: there is no integration fixture.** Every test uses a
`FakeFocasClient` injected via `IFocasClientFactory`. Fanuc's FOCAS library
(`Fwlib32.dll`) is closed-source proprietary with no public simulator;
CNC-side behavior is trusted from field deployments.
## What the fixture is
Nothing at the integration layer.
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` is unit-only. The driver ships
as Tier C (process-isolated) per `docs/v2/driver-stability.md` because the
FANUC DLL has known crash modes; tests can't replicate those in-process.
## What it actually covers (unit only)
- `FocasCapabilityTests` — data-type mapping (PMC bit / word / float,
macro variable types, parameter types)
- `FocasReadWriteTests` — read + write against the fake, FOCAS native status
→ OPC UA StatusCode mapping
- `FocasScaffoldingTests``IDriver` lifecycle + multi-device routing
- `FocasPmcBitRmwTests` — PMC bit read-modify-write synchronization (per-byte
`SemaphoreSlim`, mirrors the AB / Modbus pattern from #181)
- `FwlibNativeHelperTests``Focas32.dll``Fwlib32.dll` bridge validation
+ P/Invoke signature validation
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
`IPerCallHostResolver`.
## What it does NOT cover
### 1. FOCAS wire traffic
No FOCAS TCP frame is sent. `Fwlib32.dll`'s TCP-to-FANUC-gateway exchange is
closed-source; the driver trusts the P/Invoke layer per #193. Real CNC
correctness is trusted from field deployments.
### 2. Alarm / parameter-change callbacks
FOCAS has no push model — the driver polls via the shared `PollGroupEngine`.
There are no CNC-initiated callbacks to test; the absence is by design.
### 3. Macro / ladder variable types
FANUC has CNC-specific extensions (macro variables `#100-#999`, system
variables `#1000-#5000`, PMC timers / counters / keep-relays) whose
per-address semantics differ across 0i-F / 30i / 31i / 32i Series. Driver
covers the common address shapes; per-model quirks are not stressed.
### 4. Model-specific behavior
- Alarm retention across power cycles (model-specific CNC behavior)
- Parameter range enforcement (CNC rejects out-of-range writes)
- MTB (machine tool builder) custom screens that expose non-standard data
### 5. Tier-C process isolation behavior
Per driver-stability.md, FOCAS should run process-isolated because
`Fwlib32.dll` has documented crash modes. The test suite runs in-process +
only exercises the happy path + mapped error codes — a native access
violation from the DLL would take the test host down. The process-isolation
path (similar to Galaxy's out-of-process Host) has been scoped but not
implemented.
## When to trust FOCAS tests, when to reach for a rig
| Question | Unit tests | Real CNC |
| --- | --- | --- |
| "Does PMC address `R100.3` route to the right bit?" | yes | yes |
| "Does the FANUC status → OPC UA StatusCode map cover every documented code?" | yes (contract) | yes |
| "Does a real read against a 30i Series return correct bytes?" | no | yes (required) |
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
## Follow-up candidates
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
but it's under NDA + tied to licensed dev-kit installations; can't
redistribute for CI.
2. **Lab rig** — used FANUC 0i-F simulator controller (or a retired machine
tool) on a dedicated network; only path that covers real CNC behavior.
3. **Process isolation first** — before trusting FOCAS in production at
scale, shipping the Tier-C out-of-process Host architecture (similar to
Galaxy) is higher value than a CI simulator.
## Key fixture / config files
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs`
in-process fake implementing `IFocasClient`
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` — ctor takes
`IFocasClientFactory`
- `docs/v2/driver-stability.md` — Tier C scope + process-isolation rationale

View File

@@ -0,0 +1,164 @@
# Galaxy test fixture
Coverage map + gap inventory for the Galaxy driver — out-of-process Host
(net48 x86 MXAccess COM) + Proxy (net10) + Shared protocol.
**TL;DR: Galaxy has the richest test harness in the fleet** — real Host
subprocess spawn, real ZB SQL queries, IPC parity checks against the v1
LmxProxy reference, + live-smoke tests when MXAccess runtime is actually
installed. Gaps are live-plant + failover-shaped: the E2E suite covers the
representative ~50-tag deployment but not large-site discovery stress, real
Rockwell/Siemens PLC enumeration through MXAccess, or ZB SQL Always-On
replica failover.
## What the fixture is
Multi-project test topology:
- **E2E parity** —
`tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs` spawns the
production `OtOpcUa.Driver.Galaxy.Host.exe` as a subprocess, opens the
named-pipe IPC, connects `GalaxyProxyDriver` + runs hierarchy / stability
parity tests against both.
- **Host.Tests** —
`tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/` — direct Host process
testing (18+ test classes covering alarm discovery, AVEVA prerequisite
checks, IPC dispatcher, alarm tracker, probe manager, historian
cluster/quality/wiring, history read, OPC UA attribute mapping,
subscription lifecycle, reconnect, multi-host proxy, ADS address routing,
expression evaluation) + `GalaxyRepositoryLiveSmokeTests` that hit real
ZB SQL.
- **Proxy.Tests** — `GalaxyProxyDriver` client contract tests.
- **Shared.Tests** — shared protocol + address model.
- **TestSupport** — test helpers reused across the above.
## How tests skip
- **E2E parity**: `ParityFixture.SkipIfUnavailable()` runs at class init and
checks Windows-only, non-admin user, ZB SQL reachable on
`localhost:1433`, Host EXE built in the expected `bin/` folder. Any miss
→ tests skip.
- **Live-smoke** (`GalaxyRepositoryLiveSmokeTests`): `Assert.Skip` when ZB
unreachable. A `per project_galaxy_host_installed` memory on this repo's
dev box notes the MXAccess runtime is installed + pipe ACL denies Admins,
so live tests must run from a non-elevated shell.
- **Unit** tests (Shared, Proxy contract, most Host.Tests) have no skip —
they run anywhere.
## What it actually covers
### E2E parity suite
- `HierarchyParityTests` — Host address-space hierarchy vs v1 LmxProxy
reference (same ZB, same Galaxy, same shape)
- `StabilityFindingsRegressionTests` — probe subscription failure
handling + host-status mutation guard from the v1 stability findings
backlog
### Host.Tests (representative)
- Alarm discovery → subsystem setup
- AVEVA prerequisite checks (runtime installed, platform deployed, etc.)
- IPC dispatcher — request/response routing over the named pipe
- Alarm tracker state machine
- Probe manager — per-runtime probe subscription + reconnect
- Historian cluster / quality / wiring — Aveva Historian integration
- OPC UA attribute mapping
- Subscription lifecycle + reconnect
- Multi-host proxy routing
- ADS address routing + expression evaluation (Galaxy's legacy expression
language)
### Live-smoke
- `GalaxyRepositoryLiveSmokeTests` — real SQL against ZB database, verifies
the ZB schema + `LocalPlatform` scope filter + change-detection query
shape match production.
### Capability surfaces hit
All of them: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`,
`ISubscribable`, `IHostConnectivityProbe`, `IPerCallHostResolver`,
`IAlarmSource`, `IHistoryProvider`. Galaxy is the only driver where every
interface sees both contract + real-integration coverage.
## What it does NOT cover
### 1. MXAccess COM by default
The E2E parity suite backs subscriptions via the DB-only path; MXAccess COM
integration opts in via a separate live-smoke. So "does the MXAccess STA
pump correctly handle real Wonderware runtime events" is exercised only
when the operator runs live smoke on a machine with MXAccess installed.
### 2. Real Rockwell / Siemens PLC enumeration
Galaxy runtime talks to PLCs through MXAccess (Device Integration Objects).
The CI parity suite uses a representative ~50-tag deployment; large sites
(1000+ tag hierarchies, multi-Galaxy replication, deeply-nested templates)
are not stressed.
### 3. ZB SQL Always-On failover
Live-smoke hits a single SQL instance. Real production ZB often runs on
Always-On availability groups; replica failover behavior is not tested.
### 4. Galaxy replication / backup-restore
Galaxy supports backup + partial replication across platforms — these
rewrite the ZB schema in ways that change the contained_name vs tag_name
mapping. Not exercised.
### 5. Historian failover
Aveva Historian can be clustered. `historian cluster / quality` tests
verify the cluster-config query; they don't exercise actual failover
(primary dies → secondary takes over mid-HistoryRead).
### 6. AVEVA runtime version matrix
MXAccess COM contract varies subtly across System Platform 2017 / 2020 /
2023. The live-smoke runs against whatever version is installed on the dev
box; CI has no AVEVA installed at all (licensing + footprint).
## When to trust the Galaxy suite, when to reach for a live plant
| Question | E2E parity | Live-smoke | Real plant |
| --- | --- | --- | --- |
| "Does Host spawn + IPC round-trip work?" | yes | yes | yes |
| "Does the ZB schema query match production shape?" | partial | yes | yes |
| "Does MXAccess COM handle runtime reconnect correctly?" | no | yes | yes |
| "Does the driver scale to 1000+ tags on one Galaxy?" | no | partial | yes (required) |
| "Does historian failover mid-read return a clean error?" | no | no | yes (required) |
| "Does System Platform 2023's MXAccess differ from 2020?" | no | partial | yes (required) |
| "Does ZB Always-On replica failover preserve generation?" | no | no | yes (required) |
## Follow-up candidates
1. **System Platform 2023 live-smoke matrix** — set up a second dev box
running SP2023; run the same live-smoke against both to catch COM-contract
drift early.
2. **Synthetic large-site fixture** — script a ZB populator that creates a
1000-Equipment / 20000-tag hierarchy, run the parity suite against it.
Catches O(N) → O(N²) discovery regressions.
3. **Historian failover scripted test** — with a two-node AVEVA Historian
cluster, tear down primary mid-HistoryRead + verify the driver's failover
behavior + error surface.
4. **ZB Always-On CI** — SQL Server 2022 on Linux supports Always-On;
could stand up a two-replica group for replica-failover coverage.
This is already the best-tested driver; the remaining work is site-scale
+ production-topology coverage, not capability coverage.
## Key fixture / config files
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs` — E2E fixture
that spawns Host + connects Proxy
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs`
— live ZB smoke with `Assert.Skip` gate
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/` — shared helpers
- `docs/drivers/Galaxy.md` — COM bridge + STA pump + IPC architecture
- `docs/drivers/Galaxy-Repository.md` — ZB SQL reader + `LocalPlatform`
scope filter + change detection
- `docs/v2/aveva-system-platform-io-research.md` — MXAccess + Wonderware
background

View File

@@ -0,0 +1,113 @@
# Modbus test fixture
Coverage map + gap inventory for the Modbus TCP driver's integration-test
harness backed by `pymodbus` simulator profiles per PLC family.
**TL;DR:** Modbus is the best-covered driver — a real `pymodbus` server on
localhost with per-family seed-register profiles, plus a skip-gate when the
simulator port isn't reachable. Covers DL205 / Mitsubishi MELSEC / Siemens
S7-1500 family quirks end-to-end. Gaps are mostly error-path + alarm/history
shaped (neither is a Modbus-side concept).
## What the fixture is
- **Simulator**: `pymodbus` (Python, BSD) driven from PowerShell + per-family
JSON profiles under
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/`.
- **Lifecycle**: `ModbusSimulatorFixture` (collection-scoped) TCP-probes
`localhost:5020` on first use. `MODBUS_SIM_ENDPOINT` env var overrides the
endpoint so the same suite can target a real PLC.
- **Profiles**: `DL205Profile`, `MitsubishiProfile`, `S7_1500Profile`
each composes device-specific register-format + quirk-seed JSON for pymodbus.
- **Tests skip** via `Assert.Skip(sim.SkipReason)` when the probe fails; no
custom FactAttribute needed because `ModbusSimulatorCollection` carries the
skip reason.
## What it actually covers
### DL205 (Automation Direct)
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
(octal → Modbus offset)
- `DL205ExceptionCodeTests` — Modbus exception → OPC UA StatusCode mapping
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
- `DL205StringQuirkTests` — packed-string V-memory layout
- `DL205VMemoryQuirkTests` — V-memory octal addressing
- `DL205XInputTests` — X-register read-only enforcement
### Mitsubishi MELSEC
- `MitsubishiSmokeTests` — read + write round-trip
- `MitsubishiQuirkTests` — word-order, device-code mapping (D/M/X/Y ranges)
### Siemens S7-1500 (Modbus gateway flavor)
- `S7_1500SmokeTests` — read + write round-trip
- `S7_ByteOrderTests` — ABCD/DCBA/BADC/CDAB byte-order matrix
### Capability surfaces hit
- `IReadable` + `IWritable` — full round-trip
- `ISubscribable` — via the shared `PollGroupEngine` (polled subscription)
- `IHostConnectivityProbe` — TCP-reach transitions
## What it does NOT cover
### 1. No `ITagDiscovery`
Modbus has no symbol table — the driver requires a static tag map from
`DriverConfig`. There is no discovery path to test + none in the fixture.
### 2. Error-path fuzzing
`pymodbus` serves the seeded values happily; the fixture can't easily inject
exception responses (code 0x010x0B) or malformed PDUs. The
`AbCipStatusMapper`-equivalent for exception codes is unit-tested via
`DL205ExceptionCodeTests` but the simulator itself never refuses a read.
### 3. Variant-specific quirks beyond the three profiles
- FX5U / QJ71MT91 Mitsubishi variants — profile scaffolds exist, no tests yet
- Non-S7-1500 Siemens (S7-1200 / ET200SP) — byte-order covered but
connection-pool + fragmentation quirks untested
- DL205-family cousins (DL06, DL260) — no dedicated profile
### 4. Subscription stress
`PollGroupEngine` is unit-tested standalone but the simulator doesn't exercise
it under multi-register packing stress (FC03 with 125-register batches,
boundary splits, etc.).
### 5. Alarms / history
Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
`IHistoryProvider`; no test coverage is the correct shape.
## When to trust the Modbus fixture, when to reach for a rig
| Question | Fixture | Unit tests | Real PLC |
| --- | --- | --- | --- |
| "Does FC03/FC06/FC16 work end-to-end?" | yes | - | yes |
| "Does DL205 octal addressing map correctly?" | yes | yes | yes |
| "Does float CDAB word-swap round-trip?" | yes | yes | yes |
| "Does the driver handle exception responses?" | no | yes | yes (required) |
| "Does packing 125 regs into one FC03 work?" | no | no | yes (required) |
| "Does FX5U behave like Q-series?" | no | no | yes (required) |
## Follow-up candidates
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
2. Extend `pymodbus` profiles to inject exception responses — a JSON flag per
register saying "next read returns exception 0x04."
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
## Key fixture / config files
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs`
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs`
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiProfile.cs`
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500Profile.cs`
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/` — simulator
driver script + per-family JSON profiles

View File

@@ -0,0 +1,139 @@
# OPC UA Client test fixture
Coverage map + gap inventory for the OPC UA Client (gateway / aggregation)
driver.
**TL;DR: there is no integration fixture.** Tests mock the OPC UA SDK's
`Session` + `Subscription` types directly; there is no upstream OPC UA
server standup in CI. The irony is not lost — this repo *is* an OPC UA
server, and the integration fixtures for `OpcUaApplicationHost`
(`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` +
`OpcUaEquipmentWalkerIntegrationTests.cs`) stand up the server-side stack
end-to-end. The client-side driver could in principle wire against one of
those, but doesn't today.
## What the fixture is
Nothing at the integration layer.
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is unit-only. Tests
inject fakes through the driver's construction path; the
OPCFoundation.NetStandard `Session` surface is wrapped behind an interface
the tests mock.
## What it actually covers (unit only)
The surface is broad because `OpcUaClientDriver` is the richest-capability
driver in the fleet (it's a gateway for another OPC UA server, so it
mirrors the full capability matrix):
- `OpcUaClientDriverScaffoldTests``IDriver` lifecycle
- `OpcUaClientReadWriteTests` — read + write lifecycle
- `OpcUaClientSubscribeAndProbeTests` — monitored-item subscription + probe
state transitions
- `OpcUaClientDiscoveryTests``GetEndpoints` + endpoint selection
- `OpcUaClientAttributeMappingTests` — OPC UA node attribute → driver value
mapping
- `OpcUaClientSecurityPolicyTests``SignAndEncrypt` / `Sign` / `None`
policy negotiation contract
- `OpcUaClientCertAuthTests` — cert store paths, revocation-list config
- `OpcUaClientReconnectTests` — SDK reconnect hook + `TransferSubscriptions`
across the disconnect boundary
- `OpcUaClientFailoverTests` — primary → secondary session fallback per
driver config
- `OpcUaClientAlarmTests` — A&E severity bucket (11000 → Low / Medium /
High / Critical), subscribe / unsubscribe / ack contract
- `OpcUaClientHistoryTests` — historical data read + interpolation contract
Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`,
`IAlarmSource`, `IHistoryProvider`.
## What it does NOT cover
### 1. Real stack exchange
No UA Secure Channel is ever opened. Every test mocks `Session.ReadAsync`,
`Session.CreateSubscription`, `Session.AddItem`, etc. — the SDK itself is
trusted. Certificate validation, signing, nonce handling, chunk assembly,
keep-alive cadence — all SDK-internal and untested here.
### 2. Subscription transfer across reconnect
Contract test: "after a simulated reconnect, `TransferSubscriptions` is
called with the right handles." Real behavior: SDK re-publishes against the
new channel and some events can be lost depending on publish-queue state.
The lossy window is not characterized.
### 3. Large-scale subscription stress
100+ monitored items with heterogeneous publish intervals under a single
session — the shape that breaks publish-queue-size tuning in the wild — is
not exercised.
### 4. Real historian mappings
`IHistoryProvider.ReadRawAsync` + `ReadProcessedAsync` +
`ReadAtTimeAsync` + `ReadEventsAsync` are contract-mocked. Against a real
historian (AVEVA Historian, Prosys historian, Kepware LocalHistorian) each
has specific interpolation + bad-quality-handling quirks the contract test
doesn't see.
### 5. Real A&E events
Alarm subscription is mocked via filtered monitored items; the actual
`EventFilter` select-clause behavior against a server that exposes typed
ConditionType events (non-base `BaseEventType`) is not verified.
### 6. Authentication variants
- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested
but not exchanged against a server that actually enforces each.
- LDAP-backed `UserName` (matching this repo's server-side
`LdapUserAuthenticator`) requires a live LDAP round-trip; not tested.
## When to trust OpcUaClient tests, when to reach for a server
| Question | Unit tests | Real upstream server |
| --- | --- | --- |
| "Does severity 750 bucket as High?" | yes | yes |
| "Does the driver call `TransferSubscriptions` after reconnect?" | yes | yes |
| "Does a real OPC UA read/write round-trip work?" | no | yes (required) |
| "Does event-filter-based alarm subscription return ConditionType events?" | no | yes (required) |
| "Does history read from AVEVA Historian return correct aggregates?" | no | yes (required) |
| "Does the SDK's publish queue lose notifications under load?" | no | yes (stress) |
## Follow-up candidates
The easiest win here is to **wire the client driver tests against this
repo's own server**. The integration test project
`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs`
already stands up a real OPC UA server on a non-default port with a seeded
FakeDriver. An `OpcUaClientLiveLoopbackTests` that connects the client
driver to that server would give:
- Real Secure Channel negotiation
- Real Session / Subscription / MonitoredItem exchange
- Real read/write round-trip
- Real certificate validation (the integration test already sets up PKI)
It wouldn't cover upstream-server-specific quirks (AVEVA Historian, Kepware,
Prosys), but it would cover 80% of the SDK surface the driver sits on top
of.
Beyond that:
1. **Prosys OPC UA Simulation Server** — free, Windows-available, scriptable.
2. **UaExpert Server-Side Simulator** — Unified Automation's sample server;
good coverage of typed ConditionType events.
3. **Dedicated historian integration lab** — only path for
historian-specific coverage.
## Key fixture / config files
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with
mocked `Session`
- `src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` — ctor +
session-factory seam tests mock through
- `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs`
the server-side integration harness a future loopback client test could
piggyback on

View File

@@ -37,6 +37,19 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
- **All other drivers** share a single per-driver specification in [docs/v2/driver-specs.md](../v2/driver-specs.md) — addressing, data-type maps, connection settings, and quirks live there. That file is the authoritative per-driver reference; this index points at it rather than duplicating.
## Test-fixture coverage maps
Each driver has a dedicated fixture doc that lays out what the integration / unit harness actually covers vs. what's trusted from field deployments. Read the relevant one before claiming "green suite = production-ready" for a driver.
- [AB CIP](AbServer-Test-Fixture.md) — `ab_server` simulator, atomic-read smoke only; UDT / ALMD / family quirks are unit-only
- [Modbus](Modbus-Test-Fixture.md) — `pymodbus` + per-family profiles; best-covered driver, gaps are error-path-shaped
- [Siemens S7](S7-Test-Fixture.md) — no integration fixture, unit-only via fake `IS7Client`
- [AB Legacy](AbLegacy-Test-Fixture.md) — no integration fixture, unit-only via `FakeAbLegacyTag` (libplctag PCCC)
- [TwinCAT](TwinCAT-Test-Fixture.md) — no integration fixture, unit-only via `FakeTwinCATClient` with native-notification harness
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
- [Galaxy](Galaxy-Test-Fixture.md) — richest harness: E2E Host subprocess + ZB SQL live-smoke + MXAccess opt-in
## Related cross-driver docs
- [HistoricalDataAccess.md](../HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The Galaxy driver's Aveva Historian implementation is the first; OPC UA Client forwards to the upstream server; other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.

View File

@@ -0,0 +1,119 @@
# Siemens S7 test fixture
Coverage map + gap inventory for the S7 driver.
**TL;DR:** S7 now has a wire-level integration fixture backed by
[python-snap7](https://github.com/gijzelaerr/python-snap7)'s `Server` class
(task #216). Atomic reads (u16 / i16 / i32 / f32 / bool-with-bit) + DB
write-then-read round-trip are exercised end-to-end through S7netplus +
real ISO-on-TCP on `localhost:1102`. Unit tests still carry everything
else (address parsing, error-branch handling, probe-loop contract). Gaps
remaining are variant-quirk-shaped: Optimized-DB symbolic access, PG/OP
session types, PUT/GET-disabled enforcement — all need real hardware.
## What the fixture is
**Integration layer** (task #216):
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/` stands up a
python-snap7 `Server` via `PythonSnap7/serve.ps1 -Profile s7_1500` on
`localhost:1102`. `Snap7ServerFixture` probes the port at collection init
+ skips with a clear message when unreachable (matches the pymodbus
pattern). `server.py` reads a JSON profile + seeds DB/MB bytes at declared
offsets; seeds are typed (`u16` / `i16` / `i32` / `f32` / `bool` / `ascii`
for S7 STRING).
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` covers
everything the wire-level suite doesn't — address parsing, error
branches, probe-loop contract. All tests tagged
`[Trait("Category", "Unit")]`.
The driver ctor change that made this possible:
`Plc(CpuType, host, port, rack, slot)` — S7netplus 0.20's 5-arg overload
— wires `S7DriverOptions.Port` through so the simulator can bind 1102
(non-privileged) instead of 102 (root / Firewall-prompt territory).
## What it actually covers
### Integration (python-snap7, task #216)
- `S7_1500SmokeTests.Driver_reads_seeded_u16_through_real_S7comm` — DB1.DBW0
read via real S7netplus over TCP + simulator; proves handshake + read path
- `S7_1500SmokeTests.Driver_reads_seeded_typed_batch` — i16, i32, f32,
bool-with-bit in one batch call; proves typed decode per S7DataType
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word`
`DB1.DBW100` write → read-back; proves write path + buffer visibility
### Unit
- `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.)
- `S7DriverScaffoldTests``IDriver` lifecycle (init / reinit / shutdown / health)
- `S7DriverReadWriteTests` — error paths (uninitialized read/write, bad
addresses, transport exceptions)
- `S7DiscoveryAndSubscribeTests``ITagDiscovery.DiscoverAsync` + polled
`ISubscribable` contract with the shared `PollGroupEngine`
Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`.
Wire-level surfaces verified: `IReadable`, `IWritable`.
## What it does NOT cover
### 1. Wire-level anything
No ISO-on-TCP frame is ever sent during the test suite. S7netplus is the only
wire-path abstraction and it has no in-process fake mode; the shipping choice
was to contract-test via `IS7Client` rather than patch into S7netplus
internals.
### 2. Read/write happy path
Every `S7DriverReadWriteTests` case exercises error branches. A successful
read returning real PLC data is not tested end-to-end — the return value is
whatever the fake says it is.
### 3. Mailbox serialization under concurrent reads
The driver's `SemaphoreSlim` serializes S7netplus calls because the S7 CPU's
comm mailbox is scanned at most once per cycle. Contention behavior under
real PLC latency is not exercised.
### 4. Variant quirks
S7-1200 vs S7-1500 vs S7-300/400 connection semantics (PG vs OP vs S7-Basic)
not differentiated at test time.
### 5. Data types beyond the scalars
UDT fan-out, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`,
arrays of structs — not covered.
## When to trust the S7 tests, when to reach for a rig
| Question | Unit tests | Real PLC |
| --- | --- | --- |
| "Does the address parser accept X syntax?" | yes | - |
| "Does the driver lifecycle hang / crash?" | yes | yes |
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
| "Does a UDT fan-out produce usable member variables?" | no | yes (required) |
## Follow-up candidates
1. **Snap7 server** — [Snap7](https://snap7.sourceforge.net/) ships a
C-library-based S7 server that could run in-CI on Linux. A pinned build +
a fixture shape similar to `ab_server` would give S7 parity with Modbus /
AB CIP coverage.
2. **Plcsim Advanced** — Siemens' paid emulator. Licensed per-seat; fits a
lab rig but not CI.
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
network port, wired via self-hosted runner.
Without any of these, S7 driver correctness against real hardware is trusted
from field deployments, not from the test suite.
## Key fixture / config files
- `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` — unit tests only, no harness
- `src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` — ctor takes
`IS7ClientFactory` which tests fake; docstring lines 8-20 note the deferred
integration fixture

View File

@@ -0,0 +1,111 @@
# TwinCAT test fixture
Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver.
**TL;DR: there is no integration fixture.** Every test uses a
`FakeTwinCATClient` injected via `ITwinCATClientFactory`. Beckhoff's ADS
library has no open-source simulator; ADS traffic against real TwinCAT
runtimes is trusted from field deployments.
The silver lining: TwinCAT is the only driver outside Galaxy that uses
**native notifications** (no polling) for `ISubscribable`, and the fake
exposes a fire-event harness so notification routing is contract-tested
rigorously — just not on the wire.
## What the fixture is
Nothing at the integration layer.
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is unit-only.
`FakeTwinCATClient` also fakes the `AddDeviceNotification` flow so tests can
trigger callbacks without a running runtime.
## What it actually covers (unit only)
- `TwinCATAmsAddressTests``ads://<netId>:<port>` parsing + routing
- `TwinCATCapabilityTests` — data-type mapping (primitives + declared UDTs),
read-only classification
- `TwinCATReadWriteTests` — read + write through the fake, status mapping
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
- `TwinCATSymbolBrowserTests``ITagDiscovery.DiscoverAsync` via
`ReadSymbolsAsync` (#188) + system-symbol filtering
- `TwinCATNativeNotificationTests``AddDeviceNotification` (#189)
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
unsubscribe
- `TwinCATDriverTests``IDriver` lifecycle
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
`IPerCallHostResolver`.
## What it does NOT cover
### 1. AMS / ADS wire traffic
No real AMS router frame is exchanged. Beckhoff's `TwinCAT.Ads` NuGet (their
own .NET SDK, not libplctag-style OSS) has no in-process fake; tests stub
the `ITwinCATClient` abstraction above it.
### 2. Multi-route AMS
ADS supports chained routes (`<localNetId> → <routerNetId> → <targetNetId>`)
for PLCs behind an EC master / IPC gateway. Parse coverage exists; wire-path
coverage doesn't.
### 3. Notification reliability under jitter
`AddDeviceNotification` delivers at the runtime's cycle boundary; under high
CPU load or network jitter real notifications can coalesce. The fake fires
one callback per test invocation — real callback-coalescing behavior is
untested.
### 4. TC2 vs TC3 variant handling
TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
`GetSymbolInfoByName` semantics + symbol-table layouts. Driver targets TC3;
TC2 compatibility is not exercised.
### 5. Cycle-time alignment for `ISubscribable`
Native ADS notifications fire on the PLC cycle boundary. The fake test
harness assumes notifications fire on a timer the test controls;
cycle-aligned firing under real PLC control is not verified.
### 6. Alarms / history
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in
scope for this driver family. TwinCAT 3's TcEventLogger could theoretically
back an `IAlarmSource`, but shipping that is a separate feature.
## When to trust TwinCAT tests, when to reach for a rig
| Question | Unit tests | Real TwinCAT runtime |
| --- | --- | --- |
| "Does the AMS address parser accept X?" | yes | - |
| "Does notification → `OnDataChange` wire correctly?" | yes (contract) | yes |
| "Does symbol browsing filter TwinCAT internals?" | yes | yes |
| "Does a real ADS read return correct bytes?" | no | yes (required) |
| "Do notifications coalesce under load?" | no | yes (required) |
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
## Follow-up candidates
1. **TwinCAT 3 runtime on CI** — Beckhoff ships a free developer runtime
(7-day trial, restartable). Could run on a Windows CI runner with a
helper that auto-restarts the runtime every 6 days. Works but operational
overhead.
2. **AdsSimulator** — Beckhoff has a closed-source "ADS simulator" library
used internally; not publicly available.
3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network; the
only route that covers TC2 + real notification behavior + EtherCAT I/O
effects.
Without a rig, TwinCAT correctness is trusted from the fake matching
reality, which has held across field deployments so far.
## Key fixture / config files
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs`
in-process fake with the notification-fire harness used by
`TwinCATNativeNotificationTests`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — ctor takes
`ITwinCATClientFactory`

View File

@@ -84,7 +84,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
var plc = new Plc(_options.CpuType, _options.Host, _options.Rack, _options.Slot);
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
// honours the bound.

View File

@@ -0,0 +1,110 @@
# python-snap7 server profiles
JSON-driven seed profiles for `snap7.server.Server` from
[python-snap7](https://github.com/gijzelaerr/python-snap7) (MIT). Shape
mirrors the pymodbus profiles under
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/` — a
PowerShell launcher + per-family JSON + a Python shim that the launcher
exec's.
| File | What it seeds | Test category |
|---|---|---|
| [`s7_1500.json`](s7_1500.json) | DB1 (1024 bytes) with smoke values at known offsets (i16 @ DBW10, i32 @ DBD20, f32 @ DBD30, bool @ DBX50.3, scratch word @ DBW100, STRING "Hello" @ 200) + MB (256 bytes) with probe marker at MW0. | `Trait=Integration, Device=S7_1500` |
Default port **1102** (non-privileged; sidesteps Windows Firewall prompt +
Linux's root-required bind on port 102). The fixture
(`Snap7ServerFixture`) defaults to `localhost:1102`. Override via
`S7_SIM_ENDPOINT` to point at a real S7 CPU on port 102. The S7 driver
threads `_options.Port` through to S7netplus's 5-arg `Plc` ctor, so the
non-standard port works end-to-end.
## Install
```powershell
pip install "python-snap7>=2.0"
```
`python-snap7` wraps the upstream `snap7` C library; the install pulls
platform-specific binaries automatically. Requires Python ≥ 3.10.
Windows Firewall will prompt on first bind; allow Private network.
## Run
Foreground (Ctrl+C to stop):
```powershell
.\serve.ps1 -Profile s7_1500
```
Non-default port:
```powershell
.\serve.ps1 -Profile s7_1500 -Port 102
```
Or invoke the Python shim directly:
```powershell
python .\server.py .\s7_1500.json --port 1102
```
## Run the integration tests
In a separate shell with the simulator running:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
```
Tests auto-skip with a clear `SkipReason` when `localhost:1102` isn't
reachable within 2 seconds.
## What's encoded in `s7_1500.json`
| Address | Type | Seed | Purpose |
|---|---|---|---|
| `DB1.DBW0` | u16 | `4242` | read-back probe |
| `DB1.DBW10` | i16 | `-12345` | smoke i16 read |
| `DB1.DBD20` | i32 | `1234567890` | smoke i32 read |
| `DB1.DBD30` | f32 | `3.14159` | smoke f32 read (big-endian) |
| `DB1.DBX50.3` | bool | `true` | smoke bool read at bit 3 |
| `DB1.DBW100` | u16 | `0` | scratch for write-then-read |
| `DB1.STRING[200]` | S7 STRING | `"Hello"` (max 32, cur 5) | smoke string read |
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
Seed types supported by `server.py`: `u8`, `i8`, `u16`, `i16`, `u32`,
`i32`, `f32`, `bool` (with `"bit": 0..7`), `ascii` (S7 STRING type with
configurable `max_len`).
## Known limitations (python-snap7 upstream)
The `snap7.server.Server` docstring admits:
> "Legacy S7 server implementation. Emulates a Siemens S7 PLC for testing
> and development purposes. [...] pure Python emulator implementation that
> simulates PLC behaviour for protocol compliance testing rather than
> full industrial-grade functionality."
What that means in practice — things this fixture does NOT cover:
- **S7-1500 Optimized-DB symbolic access** — the real S7-1500 with TIA Portal
optimization enabled uses symbolic addressing that's wire-incompatible with
absolute DB addressing. Our driver targets non-optimized DBs; so does
snap7's server. Rig test required to verify against an Optimized CPU.
- **PG / OP / S7-Basic session types** — S7netplus uses OP session; the
simulator accepts whatever session type is requested, unlike real CPUs
that allocate session slots differently.
- **SCL variant-specific behaviour** — e.g. S7-1200 missing certain PDU
types, S7-300's older handshake, S7-400 multi-CPU racks with non-zero
slot. Simulator collapses all into one generic CPU emulation.
- **PUT/GET-disabled-by-default** — real S7-1200/1500 CPUs refuse reads
when PUT/GET is off in TIA Portal hardware config; the driver maps that
to `BadDeviceFailure`. Simulator has no such toggle + always accepts.
## References
- [python-snap7 GitHub](https://github.com/gijzelaerr/python-snap7) — source + install
- [snap7.server API](https://python-snap7.readthedocs.io/en/latest/API/server.html) — `Server` class reference
- [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md) — coverage map + gap inventory
- [`docs/v2/s7.md`](../../../docs/v2/s7.md) — driver-side addressing + family notes

View File

@@ -0,0 +1,35 @@
{
"_description": "S7-1500 profile — single DB1 (1024 bytes) + MB (256 bytes) with well-known seeds at named offsets for the smoke + byte-order + string tests. Big-endian Siemens wire order throughout.",
"areas": [
{
"area": "DB",
"index": 1,
"size": 1024,
"seeds": [
{ "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
"offset": 0, "type": "u16", "value": 4242 },
{ "_desc": "DB1.DBW10 — i16 smoke value for SmokeI16 read path",
"offset": 10, "type": "i16", "value": -12345 },
{ "_desc": "DB1.DBD20 — i32 smoke value for SmokeI32 read path",
"offset": 20, "type": "i32", "value": 1234567890 },
{ "_desc": "DB1.DBD30 — f32 smoke value for SmokeF32 read path (IEEE-754 big-endian)",
"offset": 30, "type": "f32", "value": 3.14159 },
{ "_desc": "DB1.DBX50.3 — bool bit at byte-50 bit-3 for SmokeBool read path",
"offset": 50, "type": "bool", "value": true, "bit": 3 },
{ "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0",
"offset": 100, "type": "u16", "value": 0 },
{ "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)",
"offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 }
]
},
{
"area": "MK",
"index": 0,
"size": 256,
"seeds": [
{ "_desc": "MW0 — probe target for S7ProbeOptions.ProbeAddress default",
"offset": 0, "type": "u16", "value": 1 }
]
}
]
}

View File

@@ -0,0 +1,56 @@
<#
.SYNOPSIS
Launches the python-snap7 S7 server with one of the integration-test
profiles. Foreground process — Ctrl+C to stop. Mirrors the pymodbus
`serve.ps1` wrapper in tests\...\Modbus.IntegrationTests\Pymodbus\.
.PARAMETER Profile
Which profile JSON to load: currently only 's7_1500' ships. Additional
families (S7-1200, S7-300) can drop in as new JSON files alongside.
.PARAMETER Port
TCP port to bind. Default 1102 (non-privileged; matches
Snap7ServerFixture default endpoint). Pass 102 to match S7 standard —
requires root on Linux + triggers Windows Firewall prompt.
.EXAMPLE
.\serve.ps1 -Profile s7_1500
.EXAMPLE
.\serve.ps1 -Profile s7_1500 -Port 102
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [ValidateSet('s7_1500')] [string]$Profile,
[int]$Port = 1102
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
# python-snap7 installs the `snap7` Python package; we call via `python -m`
# or via the server.py shim in this folder. Shim path is simpler to diagnose.
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue }
if (-not $python) {
Write-Error "python not found on PATH. Install Python 3.10+ and 'pip install python-snap7'."
exit 1
}
# Verify python-snap7 is installed so failures surface here, not in a
# confusing ImportError from server.py.
& $python.Source -c "import snap7.server" 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Error "python-snap7 not importable. Install with: pip install 'python-snap7>=2.0'"
exit 1
}
$jsonFile = Join-Path $here "$Profile.json"
if (-not (Test-Path $jsonFile)) {
Write-Error "Profile config not found: $jsonFile"
exit 1
}
Write-Host "Starting python-snap7 server: profile=$Profile TCP=localhost:$Port"
Write-Host "Ctrl+C to stop."
& $python.Source (Join-Path $here "server.py") $jsonFile --port $Port

View File

@@ -0,0 +1,150 @@
"""python-snap7 S7 server for integration tests.
Reads a JSON profile from argv[1], allocates bytearrays for each declared area
(DB / MB / EB / AB), poke-seeds values at declared offsets, then starts the
snap7 Server on the configured port + blocks until Ctrl+C. Shape intentionally
mirrors the pymodbus `serve.ps1 + *.json` pattern one directory over so
someone familiar with the Modbus fixture can read this without re-learning.
The snap7.server.Server class is the MIT-licensed S7 PLC emulator wrapped by
python-snap7 (https://github.com/gijzelaerr/python-snap7). Its own docstring
admits "protocol compliance testing rather than full industrial-grade
functionality" — good enough for ISO-on-TCP wire-level round-trip but NOT
for S7-1500 Optimized-DB symbolic access, SCL variant-specific behaviour, or
PG/OP/S7-Basic session differentiation.
"""
from __future__ import annotations
import argparse
import ctypes
import json
import signal
import sys
import time
from pathlib import Path
# python-snap7 installs as `snap7` package; Server class lives under `snap7.server`.
import snap7
from snap7.type import SrvArea
# Map JSON area names → SrvArea enum values. PE = inputs (I/E), PA = outputs
# (Q/A), MK = memory (M), DB = data blocks, TM = timers, CT = counters.
AREA_MAP: dict[str, int] = {
"PE": SrvArea.PE,
"PA": SrvArea.PA,
"MK": SrvArea.MK,
"DB": SrvArea.DB,
"TM": SrvArea.TM,
"CT": SrvArea.CT,
}
def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
"""Poke seed values into the area buffer at declared byte offsets.
Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is
big-endian (Siemens wire format).
"""
for seed in seeds:
off = int(seed["offset"])
t = seed["type"]
v = seed["value"]
if t == "u8":
buf[off] = int(v) & 0xFF
elif t == "i8":
buf[off] = int(v) & 0xFF
elif t == "u16":
buf[off:off + 2] = int(v).to_bytes(2, "big", signed=False)
elif t == "i16":
buf[off:off + 2] = int(v).to_bytes(2, "big", signed=True)
elif t == "u32":
buf[off:off + 4] = int(v).to_bytes(4, "big", signed=False)
elif t == "i32":
buf[off:off + 4] = int(v).to_bytes(4, "big", signed=True)
elif t == "f32":
import struct
buf[off:off + 4] = struct.pack(">f", float(v))
elif t == "bool":
bit = int(seed.get("bit", 0))
if bool(v):
buf[off] |= (1 << bit)
else:
buf[off] &= ~(1 << bit) & 0xFF
elif t == "ascii":
# Siemens STRING type: byte 0 = max length, byte 1 = current length,
# bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.
payload = str(v).encode("ascii")
max_len = int(seed.get("max_len", 254))
buf[off] = max_len
buf[off + 1] = len(payload)
buf[off + 2:off + 2 + len(payload)] = payload
else:
raise ValueError(f"Unknown seed type '{t}'")
def main() -> int:
parser = argparse.ArgumentParser(description="python-snap7 S7 server for integration tests")
parser.add_argument("profile", help="Path to profile JSON")
parser.add_argument("--port", type=int, default=1102, help="TCP port (default 1102 non-privileged)")
args = parser.parse_args()
profile_path = Path(args.profile)
if not profile_path.is_file():
print(f"profile not found: {profile_path}", file=sys.stderr)
return 1
with profile_path.open() as f:
profile = json.load(f)
server = snap7.server.Server()
# Keep bytearray refs alive for the server's lifetime — snap7 doesn't copy
# the buffer, it takes a pointer. Letting GC collect would corrupt reads.
buffers: list[bytearray] = []
for area_decl in profile.get("areas", []):
area_name = area_decl["area"]
if area_name not in AREA_MAP:
print(f"unknown area '{area_name}' (expected one of {list(AREA_MAP)})", file=sys.stderr)
return 1
index = int(area_decl.get("index", 0)) # DB number for DB area, 0 for MK/PE/PA
size = int(area_decl["size"])
buf = bytearray(size)
seed_buffer(buf, area_decl.get("seeds", []))
buffers.append(buf)
# register_area takes (area, index, c-array); we wrap the bytearray
# into a ctypes char array so the native lib can take &buf[0].
arr_type = ctypes.c_char * size
arr = arr_type.from_buffer(buf)
server.register_area(AREA_MAP[area_name], index, arr)
print(f" seeded {area_name}{index} size={size} seeds={len(area_decl.get('seeds', []))}")
port = int(args.port)
print(f"Starting python-snap7 server on TCP {port} (Ctrl+C to stop)")
server.start(tcp_port=port)
stop = {"sig": False}
def _handle(*_a):
stop["sig"] = True
signal.signal(signal.SIGINT, _handle)
try:
signal.signal(signal.SIGTERM, _handle)
except Exception:
pass # SIGTERM not on all platforms
try:
while not stop["sig"]:
time.sleep(0.25)
finally:
print("stopping python-snap7 server")
try:
server.stop()
except Exception:
pass
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,53 @@
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Driver-side configuration matching what <c>PythonSnap7/s7_1500.json</c> seeds
/// into the simulator's DB1 + MB areas. Tag names here become the full references
/// the smoke tests read/write against; addresses map 1:1 to the JSON profile's
/// seed offsets so a seed drift in the JSON surfaces as a driver-side read
/// mismatch, not a mystery test failure.
/// </summary>
public static class S7_1500Profile
{
public const string ProbeTag = "ProbeProbeWord";
public const int ProbeSeedValue = 4242;
public const string SmokeI16Tag = "SmokeI16";
public const short SmokeI16SeedValue = -12345;
public const string SmokeI32Tag = "SmokeI32";
public const int SmokeI32SeedValue = 1234567890;
public const string SmokeF32Tag = "SmokeF32";
public const float SmokeF32SeedValue = 3.14159f;
public const string SmokeBoolTag = "SmokeBool";
public const string WriteScratchTag = "WriteScratch";
public static S7DriverOptions BuildOptions(string host, int port) => new()
{
Host = host,
Port = port,
CpuType = S7NetCpuType.S71500,
Rack = 0,
Slot = 0,
Timeout = TimeSpan.FromSeconds(5),
// Disable the probe loop — the integration tests run their own reads +
// a background probe would race with them for the S7netplus mailbox
// gate, injecting flakiness that has nothing to do with the code
// under test.
Probe = new S7ProbeOptions { Enabled = false },
Tags =
[
new S7TagDefinition(ProbeTag, "DB1.DBW0", S7DataType.UInt16),
new S7TagDefinition(SmokeI16Tag, "DB1.DBW10", S7DataType.Int16),
new S7TagDefinition(SmokeI32Tag, "DB1.DBD20", S7DataType.Int32),
new S7TagDefinition(SmokeF32Tag, "DB1.DBD30", S7DataType.Float32),
new S7TagDefinition(SmokeBoolTag, "DB1.DBX50.3", S7DataType.Bool),
new S7TagDefinition(WriteScratchTag, "DB1.DBW100", S7DataType.UInt16),
],
};
}

View File

@@ -0,0 +1,83 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// End-to-end smoke against the python-snap7 S7-1500 profile. Drives the real
/// <see cref="S7Driver"/> + real S7netplus ISO-on-TCP stack + real CIP-free
/// S7comm exchange against <c>localhost:1102</c>. Success proves initialisation,
/// typed reads (u16 / i16 / i32 / f32 / bool-with-bit), and a write-then-read
/// round-trip all work against a real S7 server — the baseline everything
/// S7-specific (byte-order, optimized-DB differences, probe behaviour) layers on.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500SmokeTests(Snap7ServerFixture sim)
{
[Fact]
public async Task Driver_reads_seeded_u16_through_real_S7comm()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-u16");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
[S7_1500Profile.ProbeTag], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(1);
snapshots[0].StatusCode.ShouldBe(0u, "seeded u16 read must succeed end-to-end");
Convert.ToInt32(snapshots[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
}
[Fact]
public async Task Driver_reads_seeded_typed_batch()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-batch");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
[S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeI32Tag,
S7_1500Profile.SmokeF32Tag, S7_1500Profile.SmokeBoolTag],
TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(4);
foreach (var s in snapshots) s.StatusCode.ShouldBe(0u);
Convert.ToInt32(snapshots[0].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
Convert.ToInt32(snapshots[1].Value).ShouldBe(S7_1500Profile.SmokeI32SeedValue);
Convert.ToSingle(snapshots[2].Value).ShouldBe(S7_1500Profile.SmokeF32SeedValue, tolerance: 0.0001f);
Convert.ToBoolean(snapshots[3].Value).ShouldBeTrue();
}
[Fact]
public async Task Driver_write_then_read_round_trip_on_scratch_word()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-write");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
const ushort probe = 0xBEEF;
var writeResults = await drv.WriteAsync(
[new WriteRequest(S7_1500Profile.WriteScratchTag, probe)],
TestContext.Current.CancellationToken);
writeResults.Count.ShouldBe(1);
writeResults[0].StatusCode.ShouldBe(0u,
"write must succeed against snap7's DB1.DBW100 scratch register");
var readResults = await drv.ReadAsync(
[S7_1500Profile.WriteScratchTag], TestContext.Current.CancellationToken);
readResults.Count.ShouldBe(1);
readResults[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(readResults[0].Value).ShouldBe(probe);
}
}

View File

@@ -0,0 +1,83 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
/// <summary>
/// Reachability probe for a python-snap7 simulator (see
/// <c>PythonSnap7/serve.ps1</c>) or a real S7 PLC. Parses <c>S7_SIM_ENDPOINT</c>
/// (default <c>localhost:1102</c>) + TCP-connects once at fixture construction.
/// Tests check <see cref="SkipReason"/> + call <c>Assert.Skip</c> when unreachable, so
/// `dotnet test` stays green on a fresh box without the simulator installed —
/// mirrors the <c>ModbusSimulatorFixture</c> pattern.
/// </summary>
/// <remarks>
/// <para>
/// Default port is <b>1102</b>, not the S7-standard 102. 102 is a privileged port
/// on Linux (needs root) + triggers the Windows Firewall prompt on first bind;
/// 1102 sidesteps both. S7netplus 0.20 supports the 5-arg <c>Plc</c> ctor that
/// takes an explicit port (verified + wired through <c>S7DriverOptions.Port</c>),
/// so the driver can reach the simulator on its non-standard port without
/// hacks.
/// </para>
/// <para>
/// The probe is a one-shot liveness check; tests open their own S7netplus
/// sessions against the same endpoint. Don't share a socket — S7 CPUs serialise
/// concurrent connections against the same mailbox anyway, but sharing would
/// couple test ordering to socket reuse in ways this harness shouldn't care
/// about.
/// </para>
/// <para>
/// Fixture is a collection fixture so the probe runs once per test session, not
/// per test.
/// </para>
/// </remarks>
public sealed class Snap7ServerFixture : IAsyncDisposable
{
// Default 1102 (non-privileged) matches PythonSnap7/server.py. Override with
// S7_SIM_ENDPOINT to point at a real PLC on its native 102.
private const string DefaultEndpoint = "localhost:1102";
private const string EndpointEnvVar = "S7_SIM_ENDPOINT";
public string Host { get; }
public int Port { get; }
public string? SkipReason { get; }
public Snap7ServerFixture()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
var parts = raw.Split(':', 2);
Host = parts[0];
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 102;
try
{
// Force IPv4 — python-snap7 binds 0.0.0.0 (IPv4) and .NET's default
// dual-stack "localhost" resolves IPv6 ::1 first then times out before
// falling back. Same story the Modbus fixture hits.
using var client = new TcpClient(AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(Host)
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"python-snap7 simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start it (PythonSnap7\\serve.ps1 -Profile s7_1500) or override {EndpointEnvVar}.";
}
}
catch (Exception ex)
{
SkipReason = $"python-snap7 simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start it (PythonSnap7\\serve.ps1 -Profile s7_1500) or override {EndpointEnvVar}.";
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class Snap7ServerCollection : Xunit.ICollectionFixture<Snap7ServerFixture>
{
public const string Name = "Snap7Server";
}

View File

@@ -0,0 +1,35 @@
<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.S7.IntegrationTests</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.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="PythonSnap7\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>