Closes the historian leg of Phase 7. Scripted alarm transitions now batch-flow through the existing Galaxy.Host pipe + queue durably in a local SQLite store- and-forward when Galaxy is the registered driver, instead of being dropped into NullAlarmHistorianSink. ## GalaxyHistorianWriter (Driver.Galaxy.Proxy.Ipc) IAlarmHistorianWriter implementation. Translates AlarmHistorianEvent → HistorianAlarmEventDto (Stream D contract), batches via the existing GalaxyIpcClient.CallAsync round-trip on MessageKind.HistorianAlarmEventRequest / Response, maps per-event HistorianAlarmEventOutcomeDto bytes back to HistorianWriteOutcome (Ack/RetryPlease/PermanentFail) so the SQLite drain worker knows what to ack vs dead-letter vs retry. Empty-batch fast path. Pipe-level transport faults (broken pipe, host crash) bubble up as GalaxyIpcException which the SQLite sink's drain worker translates to whole-batch RetryPlease per its catch contract. ## GalaxyProxyDriver implements IAlarmHistorianWriter Marker interface lets Phase7Composer discover it via type check at compose time. WriteBatchAsync delegates to a thin GalaxyHistorianWriter wrapping the driver's existing _client. Throws InvalidOperationException if InitializeAsync hasn't connected yet — the SQLite drain worker treats that as a transient batch failure and retries. ## Phase7Composer.ResolveHistorianSink Replaces the injected sink dep when any registered driver implements IAlarmHistorianWriter. Constructs SqliteStoreAndForwardSink at %ProgramData%/OtOpcUa/alarm-historian-queue.db (falls back to %TEMP% when ProgramData unavailable, e.g. dev), starts the 2s drain timer, owns the sink disposable for clean teardown. When no driver provides the writer, keeps the NullAlarmHistorianSink wired by Program.cs (#246). DisposeAsync now also disposes the owned SQLite sink in the right order: bridge → engines → owned sink → injected fallback. ## Tests — 7 new GalaxyHistorianWriterMappingTests ToDto round-trips every field; preserves null Comment; per-byte outcome enum mapping (Ack / RetryPlease / PermanentFail) via [Theory]; unknown byte throws; ctor null-guard. The IPC round-trip itself is covered by the live Host suite (task #240) which constructs a real pipe. Server.Phase7 tests: 34/34 still pass; Galaxy.Proxy tests: 25/25 (+7 = 32 total). ## Phase 7 production wiring chain — COMPLETE - ✅ #243 composition kernel - ✅ #245 scripted-alarm IReadable adapter - ✅ #244 driver bridge - ✅ #246 Program.cs wire-in - ✅ #247 this — Galaxy.Host historian writer + SQLite sink activation What unblocks now: task #240 live OPC UA E2E smoke. With a Galaxy driver registered, scripted alarm transitions flow end-to-end through the engine → SQLite queue → drain worker → Galaxy.Host IPC → Aveva Historian alarm schema. Without Galaxy, NullSink keeps the engines functional and the queue dormant.
84 lines
3.1 KiB
C#
84 lines
3.1 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
|
|
|
/// <summary>
|
|
/// Phase 7 follow-up #247 — covers the wire-format translation between the
|
|
/// <see cref="AlarmHistorianEvent"/> the SQLite sink hands to the writer + the
|
|
/// <see cref="HistorianAlarmEventDto"/> the Galaxy.Host IPC contract expects, plus
|
|
/// the per-event outcome enum mapping. Pure functions; the round-trip over a real
|
|
/// pipe is exercised by the live Host suite (task #240).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class GalaxyHistorianWriterMappingTests
|
|
{
|
|
[Fact]
|
|
public void ToDto_round_trips_every_field()
|
|
{
|
|
var ts = new DateTime(2026, 4, 20, 14, 30, 0, DateTimeKind.Utc);
|
|
var e = new AlarmHistorianEvent(
|
|
AlarmId: "al-7",
|
|
EquipmentPath: "/Site/Line/Cell",
|
|
AlarmName: "HighTemp",
|
|
AlarmTypeName: "LimitAlarm",
|
|
Severity: AlarmSeverity.High,
|
|
EventKind: "RaiseEvent",
|
|
Message: "Temp 92°C exceeded 90°C",
|
|
User: "operator-7",
|
|
Comment: "ack with reason",
|
|
TimestampUtc: ts);
|
|
|
|
var dto = GalaxyHistorianWriter.ToDto(e);
|
|
|
|
dto.AlarmId.ShouldBe("al-7");
|
|
dto.EquipmentPath.ShouldBe("/Site/Line/Cell");
|
|
dto.AlarmName.ShouldBe("HighTemp");
|
|
dto.AlarmTypeName.ShouldBe("LimitAlarm");
|
|
dto.Severity.ShouldBe((int)AlarmSeverity.High);
|
|
dto.EventKind.ShouldBe("RaiseEvent");
|
|
dto.Message.ShouldBe("Temp 92°C exceeded 90°C");
|
|
dto.User.ShouldBe("operator-7");
|
|
dto.Comment.ShouldBe("ack with reason");
|
|
dto.TimestampUtcUnixMs.ShouldBe(new DateTimeOffset(ts, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDto_preserves_null_Comment()
|
|
{
|
|
var e = new AlarmHistorianEvent(
|
|
"a", "/p", "n", "AlarmCondition", AlarmSeverity.Low, "RaiseEvent", "m",
|
|
User: "system", Comment: null, TimestampUtc: DateTime.UtcNow);
|
|
|
|
GalaxyHistorianWriter.ToDto(e).Comment.ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HistorianAlarmEventOutcomeDto.Ack, HistorianWriteOutcome.Ack)]
|
|
[InlineData(HistorianAlarmEventOutcomeDto.RetryPlease, HistorianWriteOutcome.RetryPlease)]
|
|
[InlineData(HistorianAlarmEventOutcomeDto.PermanentFail, HistorianWriteOutcome.PermanentFail)]
|
|
public void MapOutcome_round_trips_every_byte(
|
|
HistorianAlarmEventOutcomeDto wire, HistorianWriteOutcome expected)
|
|
{
|
|
GalaxyHistorianWriter.MapOutcome(wire).ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapOutcome_unknown_byte_throws()
|
|
{
|
|
Should.Throw<InvalidOperationException>(
|
|
() => GalaxyHistorianWriter.MapOutcome((HistorianAlarmEventOutcomeDto)0xFF));
|
|
}
|
|
|
|
[Fact]
|
|
public void Null_client_rejected()
|
|
{
|
|
Should.Throw<ArgumentNullException>(() => new GalaxyHistorianWriter(null!));
|
|
}
|
|
|
|
}
|