docs(plan): OpcUaClient ReadEventsAsync implementation plan + tasks
3 serial tasks (T1 pure cores → T2 wire-in → T3 smoke+docs+finish), all driver-internal. No interface/Commons/proto/EF change; no bUnit.
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
# OpcUaClient `ReadEventsAsync` event-history passthrough — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Implement the optional `IHistoryProvider.ReadEventsAsync` on `OpcUaClientDriver` so it forwards OPC UA HistoryReadEvents to its upstream server, completing the driver's history-provider surface.
|
||||
|
||||
**Architecture:** Driver-internal only. The driver builds a fixed canonical 6-clause `EventFilter` and maps the upstream `HistoryEvent` onto the existing `HistoricalEvent` record (mirrors `WonderwareHistorianClient.ReadEventsAsync`). The pure logic (`BuildBaseEventFilter`, `MapHistoryEvents`) is `internal static` and unit-tested offline; the thin `Session.HistoryReadAsync` wire glue is covered by a without-init unit test + a skip-gated opc-plc integration smoke. NO `IHistoryProvider`/Core.Abstractions/Commons/proto/EF change. NO bUnit.
|
||||
|
||||
**Tech Stack:** C#/.NET 10, OPC Foundation UA-.NETStandard SDK, xUnit + Shouldly.
|
||||
|
||||
**Design doc:** `docs/plans/2026-06-18-opcuaclient-read-events-design.md` (committed `400bef47`).
|
||||
|
||||
**Branch:** `feat/opcuaclient-read-events` (off master `bd791e79`).
|
||||
|
||||
**Hard rules:** stage by explicit path (never `git add .`); never stage `sql_login.txt`, `src/Server/.../pki/`, `pending.md`, `stillpending.md`, `docker-dev/docker-compose.yml`; no `--no-verify`; no force-push; NO EF migration / Commons / proto / interface change; NO bUnit. Run all build/test/rig commands with `dangerouslyDisableSandbox: true`.
|
||||
|
||||
---
|
||||
|
||||
## Reference: exact existing code
|
||||
|
||||
**`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs`**
|
||||
- Class decl `:31`: `public sealed class OpcUaClientDriver : … IHistoryProvider, …` (already implements the interface).
|
||||
- Raw/Processed/AtTime passthrough `:1546-1664`, shared wire path `ExecuteHistoryReadAsync` `:1615-1664` — the pattern to mirror (`RequireSession()`, `TryParseNodeId(session, ref, out nodeId)`, `_gate.WaitAsync`, `session.HistoryReadAsync(requestHeader: null, historyReadDetails: …, timestampsToReturn: TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead: …, ct: …)`, unwrap `r.HistoryData?.Body`, `r.ContinuationPoint is { Length: > 0 }`).
|
||||
- `:1668-1676`: `internal static NodeId MapAggregateToNodeId(...)` — the `internal static` pure-helper precedent the new helpers follow.
|
||||
- `:1678-1682`: the stale "ReadEventsAsync stays at the interface default … out of scope for this PR" comment — **delete in T2.**
|
||||
- Existing methods return `Core.Abstractions.HistoryReadResult` (fully qualified) — mirror with `Core.Abstractions.HistoricalEventsResult` / `Core.Abstractions.HistoricalEvent`.
|
||||
|
||||
**`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs`** — `ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken)` returns `HistoricalEventsResult`; `HistoricalEvent(string EventId, string? SourceName, DateTime EventTimeUtc, DateTime ReceivedTimeUtc, string? Message, ushort Severity)`. `maxEvents <= 0` = "backend default cap" sentinel.
|
||||
|
||||
**`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs`** — `using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;`. Has the current `ReadEventsAsync_throws_NotSupportedException_as_documented` test (`:83-98`) — **replace in T2.** Direct driver-capability calls (e.g. `drv.ReadRawAsync`) already build, so the project already tolerates the `UnwrappedCapabilityCallAnalyzer` rule — follow that (no new suppression).
|
||||
|
||||
**`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs`** — `[Collection(OpcPlcCollection.Name)]`, ctor `(OpcPlcFixture sim)`, `if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);`, `OpcPlcProfile.BuildOptions(sim.EndpointUrl)`, `await drv.InitializeAsync("{}", ct)`. opc-plc runs with `--alm` (live alarm conditions, not a historian).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Pure cores — `BuildBaseEventFilter` + `MapHistoryEvents` + offline unit tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (T2 edits the same driver file)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` (add the two `internal static` helpers + private coercion helpers near the existing `MapAggregateToNodeId` at `:1666`; do NOT add `ReadEventsAsync` yet)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs` (add new tests; leave the existing NotSupported test untouched in this task)
|
||||
|
||||
**Step 1: Write the failing tests** (append to `OpcUaClientHistoryTests.cs`)
|
||||
|
||||
```csharp
|
||||
/// <summary>BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order.</summary>
|
||||
[Fact]
|
||||
public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses()
|
||||
{
|
||||
var filter = OpcUaClientDriver.BuildBaseEventFilter();
|
||||
|
||||
filter.SelectClauses.Count.ShouldBe(6);
|
||||
string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"];
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
{
|
||||
var clause = filter.SelectClauses[i];
|
||||
clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType);
|
||||
clause.AttributeId.ShouldBe(Attributes.Value);
|
||||
clause.BrowsePath.Count.ShouldBe(1);
|
||||
clause.BrowsePath[0].Name.ShouldBe(expected[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>MapHistoryEvents maps every field by its canonical select-clause index.</summary>
|
||||
[Fact]
|
||||
public void MapHistoryEvents_maps_all_six_fields_by_canonical_index()
|
||||
{
|
||||
var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var recvTime = eventTime.AddSeconds(1);
|
||||
var he = new HistoryEvent();
|
||||
var fields = new HistoryEventFieldList();
|
||||
fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 })); // 0 EventId
|
||||
fields.EventFields.Add(new Variant("Pump17")); // 1 SourceName
|
||||
fields.EventFields.Add(new Variant(eventTime)); // 2 Time
|
||||
fields.EventFields.Add(new Variant(recvTime)); // 3 ReceiveTime
|
||||
fields.EventFields.Add(new Variant(new LocalizedText("High temp"))); // 4 Message
|
||||
fields.EventFields.Add(new Variant((ushort)700)); // 5 Severity
|
||||
he.Events.Add(fields);
|
||||
|
||||
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
|
||||
|
||||
mapped.Count.ShouldBe(1);
|
||||
var e = mapped[0];
|
||||
e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 }));
|
||||
e.SourceName.ShouldBe("Pump17");
|
||||
e.EventTimeUtc.ShouldBe(eventTime);
|
||||
e.ReceivedTimeUtc.ShouldBe(recvTime);
|
||||
e.Message.ShouldBe("High temp");
|
||||
e.Severity.ShouldBe((ushort)700);
|
||||
}
|
||||
|
||||
/// <summary>MapHistoryEvents returns empty for a HistoryEvent with no rows.</summary>
|
||||
[Fact]
|
||||
public void MapHistoryEvents_with_no_events_returns_empty()
|
||||
{
|
||||
OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>MapHistoryEvents tolerates a field list shorter than six without throwing.</summary>
|
||||
[Fact]
|
||||
public void MapHistoryEvents_tolerates_short_field_list_without_throwing()
|
||||
{
|
||||
var he = new HistoryEvent();
|
||||
var fields = new HistoryEventFieldList();
|
||||
fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present
|
||||
he.Events.Add(fields);
|
||||
|
||||
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
|
||||
|
||||
mapped.Count.ShouldBe(1);
|
||||
mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 }));
|
||||
mapped[0].SourceName.ShouldBeNull();
|
||||
mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue);
|
||||
mapped[0].Severity.ShouldBe((ushort)0);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run to verify they fail to compile** (`BuildBaseEventFilter`/`MapHistoryEvents` don't exist yet)
|
||||
|
||||
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientHistoryTests"` (`dangerouslyDisableSandbox: true`)
|
||||
Expected: BUILD FAILS — `OpcUaClientDriver` has no `BuildBaseEventFilter` / `MapHistoryEvents`.
|
||||
|
||||
**Step 3: Implement the helpers** in `OpcUaClientDriver.cs`, immediately after `MapAggregateToNodeId` (around `:1676`, before the `:1678` comment):
|
||||
|
||||
```csharp
|
||||
// Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices.
|
||||
private static readonly string[] EventFieldBrowseNames =
|
||||
[
|
||||
BrowseNames.EventId, // 0
|
||||
BrowseNames.SourceName, // 1
|
||||
BrowseNames.Time, // 2
|
||||
BrowseNames.ReceiveTime, // 3
|
||||
BrowseNames.Message, // 4
|
||||
BrowseNames.Severity, // 5
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents —
|
||||
/// the six BaseEventType fields the OtOpcUa server projects (<see cref="HistoricalEvent"/>).
|
||||
/// The clause order is load-bearing: <see cref="MapHistoryEvents"/> reads results by index.
|
||||
/// </summary>
|
||||
/// <returns>An EventFilter with six SimpleAttributeOperand value clauses.</returns>
|
||||
internal static EventFilter BuildBaseEventFilter()
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
foreach (var browseName in EventFieldBrowseNames)
|
||||
{
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = ObjectTypeIds.BaseEventType,
|
||||
BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) },
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an upstream <see cref="HistoryEvent"/> (field arrays ordered to match
|
||||
/// <see cref="BuildBaseEventFilter"/>) onto <see cref="HistoricalEvent"/> records. Defensive:
|
||||
/// short / null / wrong-typed fields degrade to null/default rather than throwing.
|
||||
/// </summary>
|
||||
/// <param name="historyEvent">The upstream history-event payload.</param>
|
||||
/// <returns>The mapped historical events in upstream order.</returns>
|
||||
internal static IReadOnlyList<Core.Abstractions.HistoricalEvent> MapHistoryEvents(HistoryEvent historyEvent)
|
||||
{
|
||||
if (historyEvent?.Events is not { Count: > 0 } rows) return [];
|
||||
var result = new List<Core.Abstractions.HistoricalEvent>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var fields = row?.EventFields;
|
||||
result.Add(new Core.Abstractions.HistoricalEvent(
|
||||
EventId: CoerceEventId(FieldAt(fields, 0)),
|
||||
SourceName: CoerceString(FieldAt(fields, 1)),
|
||||
EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)),
|
||||
ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)),
|
||||
Message: CoerceString(FieldAt(fields, 4)),
|
||||
Severity: CoerceSeverity(FieldAt(fields, 5))));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object? FieldAt(VariantCollection? fields, int index)
|
||||
=> fields is not null && index < fields.Count ? fields[index].Value : null;
|
||||
|
||||
private static string CoerceEventId(object? value) => value switch
|
||||
{
|
||||
byte[] bytes => Convert.ToBase64String(bytes),
|
||||
string s => s,
|
||||
null => string.Empty,
|
||||
_ => value.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
private static string? CoerceString(object? value) => value switch
|
||||
{
|
||||
LocalizedText lt => lt.Text,
|
||||
string s => s,
|
||||
null => null,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
private static DateTime CoerceDateTime(object? value)
|
||||
=> value is DateTime dt ? dt : DateTime.MinValue;
|
||||
|
||||
private static ushort CoerceSeverity(object? value)
|
||||
{
|
||||
try { return value is null ? (ushort)0 : Convert.ToUInt16(value); }
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; }
|
||||
}
|
||||
```
|
||||
|
||||
Notes for the implementer:
|
||||
- These types are all in `Opc.Ua` (already `using`d by the driver): `EventFilter`, `SimpleAttributeOperand`, `ObjectTypeIds`, `Attributes`, `BrowseNames`, `QualifiedName`, `QualifiedNameCollection`, `HistoryEvent`, `HistoryEventFieldList`, `VariantCollection`, `Variant`, `LocalizedText`.
|
||||
- Fully-qualify `Core.Abstractions.HistoricalEvent` to match the existing `Core.Abstractions.HistoryReadResult` style and avoid any SDK name collision.
|
||||
|
||||
**Step 4: Run the tests** — Run the same `dotnet test --filter` command. Expected: the 4 new tests PASS (the existing `ReadEventsAsync_throws_NotSupportedException_as_documented` still passes too — untouched).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs \
|
||||
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
|
||||
git commit -m "feat(opcuaclient): add BuildBaseEventFilter + MapHistoryEvents pure cores"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `ReadEventsAsync` wire-in + remove stale comment + flip contract test
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (depends on T1; edits the same driver + test files)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` (add the public `ReadEventsAsync`; delete the `:1678-1682` stale comment)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs` (replace the NotSupported test)
|
||||
|
||||
**Step 1: Replace the contract test.** Delete `ReadEventsAsync_throws_NotSupportedException_as_documented` (`:83-98`) and add in its place:
|
||||
|
||||
```csharp
|
||||
/// <summary>ReadEventsAsync requires an initialized session like the sibling history reads.</summary>
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadEventsAsync(
|
||||
sourceName: null,
|
||||
startUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
endUtc: DateTime.UtcNow,
|
||||
maxEvents: 100,
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run to verify it fails** — Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~ReadEventsAsync_without_initialize"` (`dangerouslyDisableSandbox: true`). Expected: FAIL — `ReadEventsAsync` still inherits the throwing default, so it throws `NotSupportedException`, not `InvalidOperationException` (RequireSession runs first only once the override exists).
|
||||
|
||||
**Step 3: Implement `ReadEventsAsync`.** Delete the stale comment block at `:1678-1682` and add the method in its place (after the helpers from T1):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
|
||||
/// <see cref="BuildBaseEventFilter"/> and maps the result onto <see cref="HistoricalEvent"/>
|
||||
/// (the OtOpcUa server projects only those six BaseEventType fields, so a richer client
|
||||
/// filter would be discarded server-side — the driver supplies the canonical set itself).
|
||||
/// </summary>
|
||||
/// <param name="sourceName">
|
||||
/// Upstream event-notifier NodeId to read from (mirrors how <c>fullReference</c> is the
|
||||
/// upstream NodeId for raw reads). Null/empty → the upstream Server object (<c>i=2253</c>),
|
||||
/// the standard server-wide event notifier. An unparseable id short-circuits to an empty
|
||||
/// result, matching the raw path's malformed-NodeId behavior.
|
||||
/// </param>
|
||||
/// <param name="startUtc">Inclusive lower bound on event time (UTC).</param>
|
||||
/// <param name="endUtc">Exclusive upper bound on event time (UTC).</param>
|
||||
/// <param name="maxEvents">Upper cap; <c><= 0</c> means "no cap" (NumValuesPerNode = 0).</param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <returns>The historical events plus the upstream continuation point (null when complete).</returns>
|
||||
public async Task<Core.Abstractions.HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
|
||||
NodeId notifierNodeId;
|
||||
if (string.IsNullOrEmpty(sourceName))
|
||||
{
|
||||
notifierNodeId = ObjectIds.Server;
|
||||
}
|
||||
else if (!TryParseNodeId(session, sourceName, out var parsed))
|
||||
{
|
||||
return new Core.Abstractions.HistoricalEventsResult([], null);
|
||||
}
|
||||
else
|
||||
{
|
||||
notifierNodeId = parsed;
|
||||
}
|
||||
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = startUtc,
|
||||
EndTime = endUtc,
|
||||
NumValuesPerNode = maxEvents <= 0 ? 0u : (uint)maxEvents,
|
||||
Filter = BuildBaseEventFilter(),
|
||||
};
|
||||
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = notifierNodeId },
|
||||
};
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var resp = await session.HistoryReadAsync(
|
||||
requestHeader: null,
|
||||
historyReadDetails: new ExtensionObject(details),
|
||||
timestampsToReturn: TimestampsToReturn.Both,
|
||||
releaseContinuationPoints: false,
|
||||
nodesToRead: nodesToRead,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (resp.Results.Count == 0) return new Core.Abstractions.HistoricalEventsResult([], null);
|
||||
var r = resp.Results[0];
|
||||
|
||||
var events = r.HistoryData?.Body is HistoryEvent he
|
||||
? MapHistoryEvents(he)
|
||||
: [];
|
||||
|
||||
var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null;
|
||||
return new Core.Abstractions.HistoricalEventsResult(events, contPt);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `ReadEventDetails`, `HistoryReadValueIdCollection`, `HistoryReadValueId`, `ObjectIds`, `ExtensionObject`, `TimestampsToReturn` are all `Opc.Ua` (already in scope, used by the raw path).
|
||||
- `TryParseNodeId(ISession, string, out NodeId)` and `RequireSession()` are the existing private members (see `ExecuteHistoryReadAsync`); `_gate` is the existing `SemaphoreSlim`.
|
||||
- Verify `notifierNodeId` definite-assignment compiles (assigned in both reachable branches; the `else if` early-returns).
|
||||
|
||||
**Step 4: Run the tests**
|
||||
|
||||
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests --filter "FullyQualifiedName~OpcUaClientHistoryTests"` (`dangerouslyDisableSandbox: true`)
|
||||
Expected: ALL pass — including the new `ReadEventsAsync_without_initialize_throws_InvalidOperationException`.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs \
|
||||
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
|
||||
git commit -m "feat(opcuaclient): implement IHistoryProvider.ReadEventsAsync passthrough"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Integration smoke + docs + build + live `/run` + finish
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min (implement); live-verify + finish run in the controller
|
||||
**Parallelizable with:** none (depends on T2)
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs` (add one skip-gated smoke)
|
||||
- Modify: `docs/Historian.md` (add a short driver-passthrough note)
|
||||
|
||||
**Step 1: Add the integration smoke** to `OpcUaClientSmokeTests.cs`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Verifies HistoryReadEvents passthrough issues a well-formed request and returns a
|
||||
/// result without throwing. opc-plc exposes live alarm conditions (--alm) but is NOT a
|
||||
/// historian, so the upstream may return zero historical events or reject the service —
|
||||
/// either way the driver must produce a HistoricalEventsResult, never throw. This proves
|
||||
/// the wire request + unwrap path; a non-empty event list is infra-gated on an upstream
|
||||
/// that historizes events.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_reads_events_returns_result_without_throwing()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-events");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await drv.ReadEventsAsync(
|
||||
sourceName: null, // null → upstream Server object (i=2253)
|
||||
startUtc: DateTime.UtcNow.AddHours(-1),
|
||||
endUtc: DateTime.UtcNow,
|
||||
maxEvents: 100,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Events.ShouldNotBeNull();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build the integration test project**
|
||||
|
||||
Run: `dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests` (`dangerouslyDisableSandbox: true`)
|
||||
Expected: 0 errors. (The test is skip-gated; running it without the fixture skips.)
|
||||
|
||||
**Step 3: Doc note.** In `docs/Historian.md`, find the section describing `IHistorianDataSource` vs the driver `IHistoryProvider` (or the HistoryRead overview) and add a short paragraph: the OpcUaClient driver's `IHistoryProvider` now forwards **all four** history reads (Raw / Processed / AtTime / **Events**) to its upstream server; for events it sends a fixed canonical BaseEventType `EventFilter` and maps the upstream `HistoryEvent` onto `HistoricalEvent` (the same six fields the server projects). Keep it to ~3 sentences. Distinguish it from the server-side single `IHistorianDataSource` backend. If `docs/Historian.md` has no natural spot, add the note where the OpcUaClient driver history passthrough is first mentioned; if it isn't mentioned at all, add a one-line bullet under the HistoryRead section.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs \
|
||||
docs/Historian.md
|
||||
git commit -m "test(opcuaclient): event-history smoke + docs(historian): driver event passthrough"
|
||||
```
|
||||
|
||||
**Step 5: Full driver-test build + run (controller).**
|
||||
|
||||
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests` (`dangerouslyDisableSandbox: true`)
|
||||
Expected: green (existing + 5 new unit tests).
|
||||
|
||||
**Step 6: Live `/run` (controller).** Bring up opc-plc and run the skip-gated integration smoke against it:
|
||||
|
||||
```bash
|
||||
# opc-plc reference sim — either the local integration-test compose or the shared 10.100.0.35:50000.
|
||||
docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml up -d
|
||||
# Point the fixture at the running sim; run the event smoke (and the existing smokes as a sanity check).
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests \
|
||||
--filter "FullyQualifiedName~Client_reads_events_returns_result_without_throwing" \
|
||||
# dangerouslyDisableSandbox: true (the rig is outside the Bash sandbox)
|
||||
```
|
||||
Expected: the event smoke PASSES (returns a non-null result without throwing) — proving the wire path. Honest caveat to record: a non-empty event list is infra-gated (opc-plc is not a historian). If the fixture self-skips because the sim env var isn't wired, fall back to the shared `OPCUA_SIM_ENDPOINT=opc.tcp://10.100.0.35:50000` and document the outcome.
|
||||
|
||||
**Step 7: Finish — REQUIRED SUB-SKILL `superpowers-extended-cc:finishing-a-development-branch`.** Verify tests green, then **merge to master + push** (the standing finish choice). Stage only by path. After merge, update memory + the never-staged `stillpending.md` §A backlog line (item 2 struck through SHIPPED) — these are local bookkeeping, NOT committed.
|
||||
|
||||
---
|
||||
|
||||
## Execution notes
|
||||
|
||||
- T1 → T2 → T3 strictly serial (T1/T2 edit the same driver file; T2 depends on T1; T3 depends on T2). No parallel dispatch.
|
||||
- After all three, a final integration code-review over `git diff 400bef47..HEAD` before finishing.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-18-opcuaclient-read-events.md",
|
||||
"designPath": "docs/plans/2026-06-18-opcuaclient-read-events-design.md",
|
||||
"branch": "feat/opcuaclient-read-events",
|
||||
"baseSha": "bd791e79",
|
||||
"designCommit": "400bef47",
|
||||
"tasks": [
|
||||
{"id": 534, "subject": "Task 1: Pure cores — BuildBaseEventFilter + MapHistoryEvents + unit tests", "classification": "standard", "status": "pending"},
|
||||
{"id": 535, "subject": "Task 2: ReadEventsAsync wire-in + remove stale comment + flip contract test", "classification": "standard", "status": "pending", "blockedBy": [534]},
|
||||
{"id": 536, "subject": "Task 3: Integration smoke + docs + build + live /run + finish", "classification": "small", "status": "pending", "blockedBy": [535]}
|
||||
],
|
||||
"lastUpdated": "2026-06-18"
|
||||
}
|
||||
Reference in New Issue
Block a user