rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-test coverage for <see cref="WnWrapAlarmConsumer"/>'s pure
|
||||
/// parsing helpers — XML payload → <see cref="MxAlarmSnapshotRecord"/>
|
||||
/// dictionary, and the 32-char-hex GUID round-trip. The COM-side
|
||||
/// polling loop is verified separately by the Skip-gated
|
||||
/// <c>WnWrapConsumerProbeTests</c> on a live AVEVA install.
|
||||
/// </summary>
|
||||
public sealed class WnWrapAlarmConsumerXmlTests
|
||||
{
|
||||
/// <summary>Captured XML from the dev rig (probe run 2026-05-01).</summary>
|
||||
private const string SingleAlarmActiveXml =
|
||||
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
||||
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
||||
"<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>" +
|
||||
"<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
|
||||
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>" +
|
||||
"<PROVIDER_NAME>Galaxy</PROVIDER_NAME>" +
|
||||
"<GROUP>TestArea</GROUP>" +
|
||||
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>" +
|
||||
"<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
|
||||
"<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>" +
|
||||
"<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
|
||||
"<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT></ALARM>" +
|
||||
"</ALARM_RECORDS>";
|
||||
|
||||
private const string EmptyXml =
|
||||
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary()
|
||||
{
|
||||
var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml);
|
||||
Assert.Empty(records);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary()
|
||||
{
|
||||
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(""));
|
||||
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord()
|
||||
{
|
||||
var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml);
|
||||
|
||||
Assert.Single(records);
|
||||
Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
|
||||
var record = records[expectedGuid];
|
||||
Assert.Equal(expectedGuid, record.AlarmGuid);
|
||||
Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode);
|
||||
Assert.Equal("Galaxy", record.ProviderName);
|
||||
Assert.Equal("TestArea", record.Group);
|
||||
Assert.Equal("TestMachine_001.TestAlarm001", record.TagName);
|
||||
Assert.Equal("DSC", record.Type);
|
||||
Assert.Equal("true", record.Value);
|
||||
Assert.Equal("true", record.Limit);
|
||||
Assert.Equal(500, record.Priority);
|
||||
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
|
||||
Assert.Equal("Test alarm #1", record.AlarmComment);
|
||||
Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind);
|
||||
// 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC.
|
||||
Assert.Equal(17, record.TransitionTimestampUtc.Hour);
|
||||
Assert.Equal(26, record.TransitionTimestampUtc.Minute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords()
|
||||
{
|
||||
string xml = SingleAlarmActiveXml.Replace(
|
||||
"<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>",
|
||||
"<GUID>not-a-guid</GUID>");
|
||||
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
||||
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
|
||||
public void TryParseHexGuid_WithDashless32CharHex_Parses(string hex, string expected)
|
||||
{
|
||||
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
||||
Assert.Equal(new Guid(expected), guid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
||||
public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical)
|
||||
{
|
||||
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid));
|
||||
Assert.Equal(new Guid(canonical), guid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("nope")]
|
||||
[InlineData("0123456789ABCDEF")] // too short
|
||||
[InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long
|
||||
public void TryParseHexGuid_WithInvalidInput_Rejects(string? hex)
|
||||
{
|
||||
Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
||||
Assert.Equal(Guid.Empty, guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-001 regression: the consumer must own no internal
|
||||
/// <see cref="Timer"/>. A thread-pool timer calling the
|
||||
/// apartment-threaded wnwrap COM object off its owning STA can
|
||||
/// deadlock on cross-apartment marshaling, so the timer field and
|
||||
/// callback must not exist on the type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WnWrapAlarmConsumer_ByReflection_HasNoInternalTimerField()
|
||||
{
|
||||
FieldInfo[] fields = typeof(WnWrapAlarmConsumer)
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(Timer));
|
||||
Assert.Null(typeof(WnWrapAlarmConsumer).GetMethod(
|
||||
"OnPoll",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-001 regression: no public constructor may accept a
|
||||
/// poll-interval parameter. A non-zero poll interval was the only
|
||||
/// way to arm the off-STA timer; removing the parameter makes the
|
||||
/// footgun structurally unreachable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WnWrapAlarmConsumer_ByReflection_ExposesNoPollIntervalConstructorParameter()
|
||||
{
|
||||
foreach (ConstructorInfo constructor in typeof(WnWrapAlarmConsumer)
|
||||
.GetConstructors(BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
Assert.DoesNotContain(
|
||||
constructor.GetParameters(),
|
||||
parameter => parameter.Name is not null
|
||||
&& parameter.Name.IndexOf("poll", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-022: pins the "new alarm sighting" branch of
|
||||
/// <see cref="WnWrapAlarmConsumer.ComputeTransitions"/>. A GUID
|
||||
/// that appears in <c>next</c> but not in <c>previous</c> must
|
||||
/// produce exactly one transition with
|
||||
/// <see cref="MxAlarmStateKind.Unspecified"/> as the previous
|
||||
/// state — the proto layer relies on this sentinel to map a
|
||||
/// first sighting to a <c>Raise</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeTransitions_WhenAlarmIsNewInNextSnapshot_EmitsTransitionWithUnspecifiedPreviousState()
|
||||
{
|
||||
Guid alarmGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new();
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
||||
{
|
||||
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
||||
};
|
||||
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
||||
|
||||
MxAlarmTransitionEvent single = Assert.Single(transitions);
|
||||
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
|
||||
Assert.Equal(MxAlarmStateKind.UnackAlm, single.Record.State);
|
||||
Assert.Equal(MxAlarmStateKind.Unspecified, single.PreviousState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-022: pins the "state unchanged" branch. A GUID
|
||||
/// present in both snapshots with identical
|
||||
/// <see cref="MxAlarmSnapshotRecord.State"/> must produce no
|
||||
/// transition — a regression that emits a transition every poll
|
||||
/// regardless of state change would slip through without this
|
||||
/// test.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeTransitions_WhenAlarmStateUnchanged_EmitsNoTransition()
|
||||
{
|
||||
Guid alarmGuid = Guid.NewGuid();
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
||||
{
|
||||
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
||||
};
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
||||
{
|
||||
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
||||
};
|
||||
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
||||
|
||||
Assert.Empty(transitions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-022: pins the "state changed" branch. A GUID
|
||||
/// present in both snapshots with a different state must produce
|
||||
/// one transition carrying the prior state so the proto layer
|
||||
/// can distinguish e.g. <c>UnackAlm</c>→<c>AckAlm</c>
|
||||
/// (Acknowledge) from <c>Unspecified</c>→<c>UnackAlm</c> (Raise).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeTransitions_WhenAlarmStateChanged_EmitsTransitionWithPriorState()
|
||||
{
|
||||
Guid alarmGuid = Guid.NewGuid();
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
||||
{
|
||||
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
||||
};
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
||||
{
|
||||
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.AckAlm),
|
||||
};
|
||||
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
||||
|
||||
MxAlarmTransitionEvent single = Assert.Single(transitions);
|
||||
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
|
||||
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
|
||||
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-022: pins the "alarm cleared from the active set"
|
||||
/// branch. AVEVA drops cleared alarms from
|
||||
/// <c>GetXmlCurrentAlarms2</c>'s active set rather than emitting a
|
||||
/// transition record. A GUID present in
|
||||
/// <c>previous</c> but absent from <c>next</c> must therefore
|
||||
/// produce no transition; the diff treats disappearance as an
|
||||
/// implicit clear that the proto layer recognises by the missing
|
||||
/// GUID, not by an emitted event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeTransitions_WhenAlarmDroppedFromActiveSet_EmitsNoTransition()
|
||||
{
|
||||
Guid alarmGuid = Guid.NewGuid();
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
||||
{
|
||||
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
||||
};
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = new();
|
||||
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
||||
|
||||
Assert.Empty(transitions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-022: pins the multi-alarm fan-out. Multiple
|
||||
/// simultaneous transitions (new + changed + unchanged + dropped)
|
||||
/// in one snapshot must produce exactly the changed and new
|
||||
/// entries — not the unchanged and not the dropped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeTransitions_WithMixedDelta_EmitsOnlyNewAndChangedTransitions()
|
||||
{
|
||||
Guid newGuid = Guid.NewGuid();
|
||||
Guid changedGuid = Guid.NewGuid();
|
||||
Guid unchangedGuid = Guid.NewGuid();
|
||||
Guid droppedGuid = Guid.NewGuid();
|
||||
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
||||
{
|
||||
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.UnackAlm),
|
||||
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
|
||||
[droppedGuid] = NewRecord(droppedGuid, MxAlarmStateKind.UnackAlm),
|
||||
};
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
||||
{
|
||||
[newGuid] = NewRecord(newGuid, MxAlarmStateKind.UnackAlm),
|
||||
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.AckAlm),
|
||||
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
|
||||
};
|
||||
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
||||
|
||||
Assert.Equal(2, transitions.Count);
|
||||
|
||||
MxAlarmTransitionEvent newTransition = Assert.Single(
|
||||
transitions,
|
||||
t => t.Record.AlarmGuid == newGuid);
|
||||
Assert.Equal(MxAlarmStateKind.Unspecified, newTransition.PreviousState);
|
||||
Assert.Equal(MxAlarmStateKind.UnackAlm, newTransition.Record.State);
|
||||
|
||||
MxAlarmTransitionEvent changedTransition = Assert.Single(
|
||||
transitions,
|
||||
t => t.Record.AlarmGuid == changedGuid);
|
||||
Assert.Equal(MxAlarmStateKind.UnackAlm, changedTransition.PreviousState);
|
||||
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
|
||||
}
|
||||
|
||||
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
|
||||
{
|
||||
return new MxAlarmSnapshotRecord
|
||||
{
|
||||
AlarmGuid = guid,
|
||||
State = state,
|
||||
TagName = "TestMachine.TestAlarm",
|
||||
ProviderNode = "TEST-NODE",
|
||||
ProviderName = "Galaxy",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user