Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty 468f13cb56 Merge remote-tracking branch 'origin/master' into fix/trx-skip-reporting
v2-ci / unit-tests (pull_request) Waiting to run
2026-10-13 11:55:13 -04:00
Joseph Doherty f71fe13826 docs: record successful bounded CI baseline 2026-10-13 11:55:13 -04:00
dohertj2 6f0674296b Merge pull request 'Clarify S7 counter evidence and fixture limits' (#715) from docs/s7-counter-evidence into master
v2-ci / unit-tests (push) Has been cancelled
2026-10-13 11:54:57 -04:00
dohertj2 19bc13bf6a Merge pull request 'Bound CI concurrency and stabilize test fixtures' (#711) from fix/bootstrap-and-boundary-test-fixtures into master
v2-ci / unit-tests (push) Waiting to run
2026-10-13 11:54:15 -04:00
Joseph Doherty cd50db136e docs: clarify S7 counter evidence and fixture limits 2026-10-13 11:34:59 -04:00
Joseph Doherty eecf301200 ci: count individual skipped TRX results 2026-10-13 11:05:49 -04:00
7 changed files with 231 additions and 26 deletions
+2
View File
@@ -41,6 +41,8 @@ jobs:
# #606: regenerated review summary must remain in sync with module findings.
- name: code-reviews README drift check
run: python3 code-reviews/regen-readme.py --check
- name: TRX skip guard fixtures
run: python3 scripts/ci/test-assert-not-all-skipped.py
# #707: bound project workers and avoid concurrent NuGet restore work.
- name: dotnet restore
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx --disable-parallel -m:2
+45 -7
View File
@@ -198,15 +198,53 @@ Timers are **read-only** this phase; a write attempt returns `BadNotWritable`.
### Counter read
**Address**: `C{n}` (e.g. `C0`, `C10`). **DataType must be `Int32`**. The OPC UA node is
a scalar `Int32` whose value is the current **count** (raw word from the PLC).
a scalar `Int32` containing the unsigned **raw big-endian word**, widened to `Int32`:
`(bytes[0] << 8) | bytes[1]`. `S7.Net`'s `Counter.FromByteArray` performs no BCD
reinterpretation; for example, bytes `01 23` surface as `291`. This describes the
current decoder, not verified agreement with a PLC's displayed count.
Counters are **read-only** this phase; a write attempt returns `BadNotWritable`.
> **Known limitation — Counter BCD encoding on S7-300/400**: `S7.Net`'s
> `Counter.FromByteArray` returns the raw big-endian word without BCD decode.
> On **classic S7-300/400** the C-area word is BCD-encoded (0–999), so the
> surfaced value can differ from the actual count on that hardware. S7-1200/1500
> use IEC/DB counters (plain integers) where the raw word is correct. BCD
> reinterpretation for legacy C-area is a live-hardware-gated follow-up.
The S7comm ReadVar **C-area response encoding remains unverified on hardware**.
PLC instruction representations do not by themselves establish network encoding:
Siemens documents a binary current value for [L: Load counter](https://docs.tia.siemens.cloud/r/en-us/v21/stl-s7-300-s7-400/stl-mnemonic-s7-300-s7-400/counter-operations-s7-300-s7-400/l-load-counter-s7-300-s7-400)
and separate `CV` and `CV_BCD` outputs for [S_CD](https://docs.tia.siemens.cloud/r/en-us/v21/lad-s7-300-s7-400/counter-operations-s7-300-s7-400/simatic-counters-s7-300-s7-400/s_cd-assign-parameters-and-count-down-s7-300-s7-400?contentId=reR_xapFX_Ike2opHIvfzg).
IEC counter values stored in DB memory use the **DB address and declared integer
type**, a different read path from `C{n}`. CPU family alone does not select the
counter kind or prove its wire representation. In particular, Siemens also lists
legacy SIMATIC counters for S7-1500 in its [instruction comparison (pages 23–24)](https://cache.industry.siemens.com/dl/files/777/109986777/att_1322640/v1/s7_1500_compare_table_en.pdf?download=true).
The driver has no counter encoding selector; hardware-faithful counter support
requires independent raw-response evidence before any interpretation change.
### Counter evidence follow-up (#616)
The existing `S7_1500SpanReadTests` exercises the python-snap7 profile's synthetic
raw words (`C0 = 4711`, `C7 = 31337`). These are outside the classic SIMATIC count
range `0..999` documented by S_CD. These expectations describe raw-word handling;
they do not establish classic counter count semantics. The recorded device-0 run cannot prove nonzero
numbering. The nonzero test is environment-gated, but its options remain
`CpuType = S71500`, rack `0`, slot `0`: setting `S7_TIMER_NUMBER_ADDRESSING=1`
does not configure a classic CPU or make the profile a classic counter gate.
Before claiming hardware validation or closing #616:
- Prepare a separate target-specific semantic gate with the actual CPU type,
rack, slot and TSAP/connection settings. Keep the synthetic raw-word profile
as its own evidence of the existing decoder contract.
- Record CPU model/order number, firmware, endpoint, connection settings and
driver/S7netplus plus simulator versions where applicable.
- Observe independent PLC-side current counts and binary/BCD reference outputs
for valid classic counts such as `123`, a second distinguishing count (`42`
or `999`), and zero. Record the instruction/counter kind and DB reference
addresses; do not derive reference values from the driver's output.
- Capture the actual two-byte S7comm C-area responses alongside those references.
Do not infer response bytes from a simulator seed or expected BCD value.
- Verify several distinct nonzero timer/counter numbers against their independent
PLC values and captured requests/responses, and verify the read-only contract.
The raw-byte/count and nonzero-numbering gates remain open. Password/protection
support inherited from #303/#632 is a separate follow-up, not evidence supplied
by these counter checks.
### Deferrals (not yet implemented)
+23
View File
@@ -28,3 +28,26 @@ AKKA_TEST_TIMEFACTOR=3 dotnet test ZB.MOM.WW.OtOpcUa.slnx \
--logger trx --results-directory artifacts/trx
MIN_EXECUTED=12000 bash scripts/ci/assert-not-all-skipped.sh artifacts/trx/*.trx
```
The guard uses the existing TRX `Counters/@executed` value for the aggregate
floor. Its `skipped` diagnostic counts individual `Results/UnitTestResult`
records whose outcome is `NotExecuted`, using the document's XML namespace.
Some VSTest reports set summary `notExecuted` to zero even when these records
are present. Skip records are counted per file, including repeated runs; they
are not a count of unique test methods and do not affect the executed floor.
For malformed XML, the diagnostic retains the old summary-counter fallback;
this guard does not add XML validation to its existing gate policy.
Run the deterministic script fixtures without building or running the solution:
```bash
python3 scripts/ci/test-assert-not-all-skipped.py
```
Verified hosted baseline: [Gitea run 975](https://gitea.dohertylan.com/dohertj2/lmxopcua/actions/runs/975)
completed successfully on 2026-10-13 for commit
`76bdca90e94e814e06ef10b78271e2b492b2ba32` (merged by PR #711).
Restore, Release build, README drift check, the serialized whole-solution test
step, the 12,000-test floor and TRX upload all passed. The floor counted
14,630 executed tests across 62 TRX files. This baseline predates the individual
skip-reporting correction; its printed zero skip diagnostic is not skip evidence.
+20 -1
View File
@@ -33,12 +33,31 @@ for f in "$@"; do
exit 2
fi
seen=$((seen + 1))
# The trx <Counters .../> element in <ResultSummary> carries executed + notExecuted counts.
# Keep the executed-counter floor unchanged; notExecuted is only a fallback
# for malformed XML that this guard historically accepted.
counters="$(grep -oE '<Counters[^>]*/?>' "$f" | head -1)"
ex="$(printf '%s' "$counters" | grep -oE 'executed="[0-9]+"' | grep -oE '[0-9]+' || true)"
ne="$(printf '%s' "$counters" | grep -oE 'notExecuted="[0-9]+"' | grep -oE '[0-9]+' || true)"
ex="${ex:-0}"
ne="${ne:-0}"
# VSTest can emit notExecuted="0" despite individual NotExecuted results.
# Count direct Results/UnitTestResult records in the document's namespace,
# excluding foreign elements, nested records and escaped diagnostic text.
ne="$(python3 - "$f" "$ne" <<'PY'
import sys
import xml.etree.ElementTree as ET
try:
root = ET.parse(sys.argv[1]).getroot()
except ET.ParseError:
# Skip reporting must not introduce a new malformed-XML gate failure.
print(sys.argv[2])
else:
namespace = root.tag.split("}", 1)[0] + "}" if root.tag.startswith("{") else ""
results = root.findall(f"{namespace}Results/{namespace}UnitTestResult")
print(sum(result.get("outcome") == "NotExecuted" for result in results))
PY
)"
total_executed=$((total_executed + ex))
total_skipped=$((total_skipped + ne))
echo " $(basename "$f"): executed=${ex} skipped=${ne}"
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Deterministic subprocess checks for the TRX executed-floor guard."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).with_name("assert-not-all-skipped.sh")
NS = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"
def trx(executed, outcomes, summary_skipped=0, namespace=NS, extra=""):
declaration = f' xmlns="{namespace}"' if namespace else ""
results = "".join(f'<UnitTestResult outcome="{outcome}"/>' for outcome in outcomes)
return (f'<TestRun{declaration}><Results>{results}{extra}</Results>'
f'<ResultSummary><Counters total="{len(outcomes)}" executed="{executed}" '
f'notExecuted="{summary_skipped}"/></ResultSummary></TestRun>')
class SkipGuardTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="trx-skip-fixtures-")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
def write(self, name, content):
path = self.root / name
path.write_text(content, encoding="utf-8")
return path
def run_guard(self, *paths, minimum=None):
env = os.environ.copy()
env.pop("MIN_EXECUTED", None)
if minimum is not None:
env["MIN_EXECUTED"] = str(minimum)
return subprocess.run(["bash", str(SCRIPT), *map(str, paths)], env=env,
text=True, capture_output=True, check=False)
def assert_report(self, result, status, executed, skipped, files=1):
self.assertEqual(status, result.returncode, result.stdout + result.stderr)
self.assertIn(f"files={files} executed={executed} skipped={skipped} ", result.stdout)
def test_mixed_results_ignore_zero_summary_skip_counter(self):
# A summary-only skip count misses the three actual NotExecuted records.
path = self.write("mixed.trx", trx(827, ["Passed"] * 827 + ["NotExecuted"] * 3))
result = self.run_guard(path)
self.assert_report(result, 0, 827, 3)
self.assertIn("mixed.trx: executed=827 skipped=3", result.stdout)
def test_all_skipped_fails_default_floor_but_report_only_passes(self):
path = self.write("all-skipped.trx", trx(0, ["NotExecuted"] * 188))
self.assert_report(self.run_guard(path), 1, 0, 188)
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 188)
def test_zero_tests_fails_default_floor_but_report_only_passes(self):
path = self.write("zero.trx", trx(0, []))
self.assert_report(self.run_guard(path), 1, 0, 0)
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 0)
def test_agreeing_summary_and_outcomes_are_not_double_counted(self):
path = self.write("agreeing.trx", trx(1, ["Passed", "NotExecuted"], 1))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_outcomes_are_authoritative_when_summary_overstates_skips(self):
path = self.write("overstated.trx", trx(1, ["Passed", "NotExecuted"], 9))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_namespace_path_and_exact_outcome_exclude_decoys(self):
extra = ('<Other><UnitTestResult outcome="NotExecuted"/></Other>'
'<foreign:UnitTestResult xmlns:foreign="urn:other" outcome="NotExecuted"/>'
'<Output><Message>&lt;UnitTestResult outcome="NotExecuted"/&gt;</Message></Output>')
content = trx(2, ["Passed", "Failed", "NotExecuted", "notexecuted"], extra=extra)
# Prefixes on Results/UnitTestResult must work just like the default namespace.
content = content.replace('<TestRun ', f'<TestRun xmlns:t="{NS}" ')
content = content.replace('<Results>', '<t:Results>').replace('</Results>', '</t:Results>')
content = content.replace('<UnitTestResult ', '<t:UnitTestResult ')
path = self.write("namespaced.trx", content)
self.assert_report(self.run_guard(path), 0, 2, 1)
def test_unnamespaced_results_remain_supported(self):
path = self.write("legacy.trx", trx(1, ["Passed", "NotExecuted"], namespace=""))
self.assert_report(self.run_guard(path), 0, 1, 1)
def test_executed_counter_controls_aggregate_floor_at_boundary(self):
# Counters retain authority even when result records disagree with executed.
mixed = self.write("floor.trx", trx(12000, ["Passed", "NotExecuted"]))
skipped = self.write("skipped.trx", trx(0, ["NotExecuted"]))
self.assert_report(self.run_guard(mixed, skipped, minimum=12000), 0, 12000, 2, 2)
self.assert_report(self.run_guard(mixed, skipped, minimum=12001), 1, 12000, 2, 2)
def test_missing_arguments_and_file_keep_exit_two(self):
self.assertEqual(2, self.run_guard().returncode)
self.assertEqual(2, self.run_guard(self.root / "missing.trx").returncode)
def test_missing_counters_keeps_existing_exit_one(self):
path = self.write("no-counters.trx", '<TestRun><Results/></TestRun>')
self.assertEqual(1, self.run_guard(path, minimum=0).returncode)
def test_malformed_xml_keeps_summary_fallback_and_gate_status(self):
path = self.write("malformed.trx", '<TestRun><Counters executed="2" notExecuted="3"/>')
self.assert_report(self.run_guard(path, minimum=2), 0, 2, 3)
self.assert_report(self.run_guard(path, minimum=3), 1, 2, 3)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -1465,7 +1465,7 @@ public sealed class S7Driver
{
// AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte
// S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double);
// a Counter (C{n}) decodes its 2-byte word to a COUNT via Counter.FromByteArray (returns
// a Counter (C{n}) exposes its 2-byte RAW WORD via Counter.FromByteArray (returns
// ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area
// here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm
// below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so
@@ -1474,11 +1474,12 @@ public sealed class S7Driver
return (object)global::S7.Net.Types.Timer.FromByteArray(block);
if (addr.Area is S7Area.Counter)
{
// NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]),
// NOT a BCD decode. On classic S7-300/400 the C-area word is BCD (0-999), so on that hardware
// this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain
// ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD
// reinterpretation for legacy C-area counters is a live-hardware-gated follow-up.
// S7.Net Counter.FromByteArray returns the raw big-endian word ((bytes[0]<<8)|bytes[1]),
// without BCD reinterpretation. This preserves the raw value, not a verified PLC count:
// S7comm C-area response encoding still needs independent raw bytes and PLC-side
// count/binary/BCD references (#616). CPU family alone does not prove that encoding.
// IEC counter integers in DB memory take the DB type codec, not this C-area path;
// S7-1500 also has legacy SIMATIC counters. See docs/drivers/S7.md for the evidence gate.
return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block);
}
@@ -27,7 +27,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// areas: <b>PASSED</b> — 4 passed, 0 failed, 1 skipped (the device number-addressing test,
/// see below). The Timer/Counter batch was served by 4 requests for 7 tags: one DB1 span,
/// one 3-item multi-var carrying only the scalars, and one 2-byte area read apiece for
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (decoded count 4711).
/// <c>T0</c> (decoded 456.0 s from S5TIME <c>0x2456</c>) and <c>C0</c> (surfaced raw word 4711).
/// </para>
/// <para>
/// <b>Counter evidence limit.</b> The profile's C0=4711 and C7=31337 are synthetic raw
/// words, outside classic SIMATIC counts 0..999. The device-0 record proves the fixture's
/// raw-word decode and batching exclusion, not classic count semantics or nonzero
/// addressing. BuildOptions retains S71500/rack 0/slot 0; a target-specific semantic gate
/// still needs independent PLC counts and binary/BCD references paired with captured
/// C-area bytes, CPU model/firmware and connection settings (docs/drivers/S7.md, #616).
/// </para>
/// <para>
/// <b>What the layout buys.</b> The ten tags are positioned so the planner's rules produce a
@@ -73,11 +81,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// returns TM bytes 0..1, and it takes <c>start 80</c> (= 10 × 8) to reach the timer seeded
/// at TM byte 10. Device <b>0</b> is the single fixed point of the two conventions
/// (<c>0 &gt;&gt; 3 == 2 × 0 == 0</c>), so it is the only device this fixture can pin a
/// VALUE for, and that is what the mixed batch uses. The spec-correct <c>2 × N</c> cells
/// VALUE for, and that is what the mixed batch uses. The profile's <c>2 × N</c> cells
/// are seeded too and
/// <see cref="Timer_and_counter_decode_the_seeded_values_at_their_device_numbers"/> asserts
/// them — skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a real
/// CPU. The device-number offset arithmetic itself stays unit-covered
/// CPU. This flag is an operator declaration, not hardware evidence.
/// The device-number offset arithmetic itself stays unit-covered
/// (<c>S7ScalarBlockTests</c>, <c>S7SpanReadParityTests</c>).
/// </para>
/// <para>
@@ -240,7 +249,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
snapshot.StatusCode.ShouldBe(0u, $"'{name}' must be Good");
// (a) Correct decode off real wire. A Timer surfaces as the S5TIME word's duration in
// seconds (double) and a Counter as the raw count (int) — two different areas, two
// seconds (double) and a Counter as the raw word (int) — two different areas, two
// different values, two different CLR types, so a read that reached the wrong area or
// decoded through the wrong arm cannot pass.
byName["TC_Timer"].Value.ShouldBe(TimerCounterLayout.Device0TimerSeconds);
@@ -280,15 +289,19 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>
/// Verifies a Timer and a Counter at NON-zero device numbers decode their seeded values —
/// the assertion the shared fixture cannot serve.
/// the raw-word assertion the shared fixture cannot serve.
/// </summary>
/// <remarks>
/// Skipped unless <c>S7_TIMER_NUMBER_ADDRESSING=1</c> declares the endpoint a device
/// number-addressing CPU, because python-snap7's server is not one: it resolves the TM/CT
/// request address as a bit address, so <c>T5</c> reads TM byte 0 rather than TM byte 10
/// (measured 2026-08-17 — see the type remarks). Set the variable alongside
/// <c>S7_SIM_ENDPOINT</c> when pointing this suite at a real S7 CPU seeded to match
/// <c>Docker/profiles/s7_1500.json</c>'s TM/CT areas.
/// (measured 2026-08-17 — see the type remarks). The flag only enables this test;
/// BuildOptions still selects S71500, rack 0, slot 0 and the profile's raw C7 word 31337.
/// It does not configure a classic CPU or prove classic 0..999 count semantics. A separate
/// target-specific gate needs valid counts, independent PLC binary/BCD references and
/// captured C-area bytes, plus CPU model/firmware and connection settings (#616).
/// See docs/drivers/S7.md for the evidence checklist. The skip message below retains the
/// historical fixture instructions; enabling the flag alone does not satisfy this gate.
/// </remarks>
[Fact]
public async Task Timer_and_counter_decode_the_seeded_values_at_their_device_numbers()
@@ -408,12 +421,13 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>
/// The Timer/Counter layouts. Two sets: a mixed batch that provokes all three read shapes at
/// once (block span, multi-var, per-tag Timer/Counter), and a device-numbered pair only a
/// real CPU can serve. Addresses map 1:1 to the TM0 / CT0 seeds in
/// device number-addressing endpoint can serve. These synthetic raw-word expectations
/// do not prove classic counter semantics. Addresses map 1:1 to the TM0 / CT0 seeds in
/// <c>Docker/profiles/s7_1500.json</c>.
/// </summary>
private static class TimerCounterLayout
{
/// <summary>Declares the endpoint a real S7 CPU, which addresses TM/CT by device number.</summary>
/// <summary>Operator opt-in for nonzero TM/CT addressing; does not change CPU options or prove encoding.</summary>
public const string RealCpuEnvVar = "S7_TIMER_NUMBER_ADDRESSING";
/// <summary>
@@ -425,7 +439,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
/// <summary>T0's seed 0x2456 — S5TIME base 2 (×1 s) over BCD 456.</summary>
public const double Device0TimerSeconds = 456.0d;
/// <summary>C0's seed — the raw big-endian count word.</summary>
/// <summary>C0's synthetic raw big-endian word 4711; not a classic 0..999 count reference.</summary>
public const int Device0CounterCount = 4711;
/// <summary>
@@ -441,7 +455,7 @@ public sealed class S7_1500SpanReadTests(Snap7ServerFixture sim)
new("TC_Lone_I16", "DB1.DBW600", S7DataType.Int16),
new("TC_Lone_Mk", "MW10", S7DataType.UInt16),
// Timer tags are typed Float64 (decoded to seconds) and Counter tags Int32 (decoded to
// a count) — the init guard rejects any other typing. Writable:false is mandatory, not
// a raw word) — the init guard rejects any other typing. Writable:false is mandatory, not
// stylistic: S7TagDefinition defaults Writable to TRUE and the same guard rejects a
// writable Timer/Counter outright (they are read-only this phase).
new("TC_Timer", "T0", S7DataType.Float64, Writable: false),