docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -27,6 +27,7 @@ public class ArchitecturalConstraintTests
return Path.GetDirectoryName(csproj)!;
}
/// <summary>Verifies that the Commons project file declares none of the forbidden package references.</summary>
[Fact]
public void Csproj_ShouldNotHaveForbiddenPackageReferences()
{
@@ -51,6 +52,7 @@ public class ArchitecturalConstraintTests
}
}
/// <summary>Verifies that the Commons assembly references none of the forbidden assemblies.</summary>
[Fact]
public void Assembly_ShouldNotReferenceForbiddenAssemblies()
{
@@ -68,6 +70,7 @@ public class ArchitecturalConstraintTests
}
}
/// <summary>Verifies that Commons contains no service or actor implementations.</summary>
[Fact]
public void Commons_ShouldNotContainServiceOrActorImplementations()
{
@@ -99,6 +102,7 @@ public class ArchitecturalConstraintTests
}
}
/// <summary>Verifies that no source file calls <c>ToLocalTime</c>, since all timestamps are UTC.</summary>
[Fact]
public void SourceFiles_ShouldNotContainToLocalTime()
{
@@ -112,6 +116,7 @@ public class ArchitecturalConstraintTests
}
}
/// <summary>Verifies that no entity class carries Entity Framework attributes.</summary>
[Fact]
public void AllEntities_ShouldHaveNoEfAttributes()
{
@@ -145,6 +150,7 @@ public class ArchitecturalConstraintTests
}
}
/// <summary>Verifies that the retired <c>DebugStreamEvent</c> type does not exist.</summary>
[Fact]
public void DebugStreamEvent_ShouldNotExist()
{
@@ -154,6 +160,7 @@ public class ArchitecturalConstraintTests
Assert.Null(type);
}
/// <summary>Verifies that every enum has a singular name.</summary>
[Fact]
public void AllEnums_ShouldBeSingularNamed()
{
@@ -168,6 +175,7 @@ public class ArchitecturalConstraintTests
}
}
/// <summary>Verifies that every message type is a record.</summary>
[Fact]
public void AllMessageTypes_ShouldBeRecords()
{
@@ -187,6 +195,7 @@ public class ArchitecturalConstraintTests
}
}
/// <summary>Verifies that the key entities use <see cref="DateTimeOffset"/> for their timestamp properties.</summary>
[Fact]
public void KeyEntities_ShouldHaveDateTimeOffsetTimestamps()
{
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Audit;
/// </summary>
public class ScriptTraceEnumTests
{
/// <summary>Verifies that each script trace kind fits the central audit columns.</summary>
/// <param name="kind">The script trace audit kind under test.</param>
[Theory]
[InlineData(AuditKind.RouteCall)]
[InlineData(AuditKind.ScriptRun)]
@@ -22,6 +24,7 @@ public class ScriptTraceEnumTests
Assert.Equal($"ScriptTrace.{kind}", AuditFieldBuilders.BuildAction(AuditChannel.ScriptTrace, kind));
}
/// <summary>Verifies that the script trace values are appended after the cluster values so existing ordinals stay stable.</summary>
[Fact]
public void ScriptTrace_IsAppendedAfterCluster_SoExistingOrdinalsAreStable()
{
@@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>
/// Final review M1: the ONE parser behind the graph node's exception subtitle and the step
/// The single parser behind the graph node's exception subtitle and the step
/// detail panel's exception block. Before it, the panel showed the OUTERMOST exception of a
/// wrapped chain while the node showed the innermost — the two now agree by construction.
/// </summary>
public class ExceptionHeaderTests
{
/// <summary>Verifies that a plain detail splits the type from the message and shortens the type only when asked.</summary>
[Fact]
public void Parse_PlainDetail_SplitsTypeFromMessage_AndShortensOnlyWhenAsked()
{
@@ -18,6 +19,7 @@ public class ExceptionHeaderTests
Assert.Equal(("System.InvalidOperationException", "Recipe 42 rejected"), ExceptionHeader.Parse(detail, shortenType: false));
}
/// <summary>Verifies that a wrapped exception chain resolves to the innermost exception.</summary>
[Fact]
public void Parse_WrappedChain_TakesTheINNERMOSTException()
{
@@ -28,6 +30,7 @@ public class ExceptionHeaderTests
Assert.Equal(("C.Innermost", "the real one"), ExceptionHeader.Parse(detail, shortenType: false));
}
/// <summary>Verifies that a generic type keeps its arity and arguments verbatim and only the head is shortened.</summary>
[Fact]
public void Parse_GenericType_KeepsTheArityAndArgumentsVerbatim_ShorteningOnlyTheHead()
{
@@ -37,6 +40,7 @@ public class ExceptionHeaderTests
ExceptionHeader.Parse("RecipeException`1[System.String]: msg", shortenType: true));
}
/// <summary>Verifies that the chain marker is matched after trimming, so a trailing marker is not treated as a chain.</summary>
[Fact]
public void Parse_ChainMarkerIsMatchedAfterTrimming_SoATrailingMarkerIsNotAChain()
{
@@ -46,12 +50,14 @@ public class ExceptionHeaderTests
Assert.Equal(("Outer", "outer --->"), ExceptionHeader.Parse("A.Outer: outer ---> ", shortenType: true));
}
/// <summary>Verifies that a line without a colon is treated as all type and no message.</summary>
[Fact]
public void Parse_LineWithoutAColon_IsAllTypeAndNoMessage()
{
Assert.Equal(("SomethingWentWrong", string.Empty), ExceptionHeader.Parse("SomethingWentWrong", shortenType: true));
}
/// <summary>Verifies that a CRLF detail has the carriage return trimmed off the first line.</summary>
[Fact]
public void Parse_CrLfDetail_TrimsTheCarriageReturnOffTheFirstLine()
{
@@ -59,6 +65,8 @@ public class ExceptionHeaderTests
ExceptionHeader.Parse("System.TimeoutException: took too long\r\n at X\r\n", shortenType: true));
}
/// <summary>Verifies that a missing or blank detail returns null.</summary>
/// <param name="detail">The exception detail text to parse.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
public class FlowEnumContractTests
{
/// <summary>Verifies that the enum names and numeric values remain stable.</summary>
[Fact]
public void EnumNamesAndValues_RemainStable()
{
@@ -15,6 +16,7 @@ public class FlowEnumContractTests
Assert.Equal(Enumerable.Range(0, 7), Enum.GetValues<FlowStepOutcome>().Select(x => (int)x));
}
/// <summary>Verifies that the kind rank follows the display order independent of the enum's numeric value.</summary>
[Fact]
public void KindRank_UsesDisplayOrderIndependentOfEnumValue()
{
@@ -51,6 +51,7 @@ public class FlowGraphBuilderLogLineTests
private static FlowStep StepOf(FlowGraph graph, FlowStepKind kind) => Assert.Single(graph.Steps, s => s.Kind == kind);
/// <summary>Verifies that a log line carrying a step event id attaches to that step.</summary>
[Fact]
public void LogLine_WithStepEventId_AttachesToThatStep()
{
@@ -65,6 +66,7 @@ public class FlowGraphBuilderLogLineTests
Assert.Null(StepOf(graph, FlowStepKind.ScriptRun).Logs);
}
/// <summary>Verifies that a log line without a step id attaches to the execution anchor.</summary>
[Fact]
public void LogLine_WithoutStepId_AttachesToTheExecutionAnchor()
{
@@ -76,6 +78,7 @@ public class FlowGraphBuilderLogLineTests
Assert.Null(StepOf(graph, FlowStepKind.AttributeWrite).Logs);
}
/// <summary>Verifies that a central log line attaches to the inbound row rather than the response.</summary>
[Fact]
public void CentralLine_AttachesToInboundRow_NotResponse()
{
@@ -87,6 +90,7 @@ public class FlowGraphBuilderLogLineTests
Assert.Null(StepOf(graph, FlowStepKind.Response).Logs);
}
/// <summary>Verifies that a script-entry log line attaches to the route-call root.</summary>
[Fact]
public void ScriptEntryLine_AttachesToRouteCallRoot()
{
@@ -97,6 +101,7 @@ public class FlowGraphBuilderLogLineTests
Assert.Single(StepOf(graph, FlowStepKind.RouteCall).Logs!);
}
/// <summary>Verifies that a log line with an unknown step id falls back to the anchor.</summary>
[Fact]
public void LogLine_UnknownStepId_FallsBackToAnchor()
{
@@ -108,6 +113,7 @@ public class FlowGraphBuilderLogLineTests
Assert.Equal(0, graph.UnattachedLogLines);
}
/// <summary>Verifies that a log line with no anchor falls back to the first step.</summary>
[Fact]
public void LogLine_NoAnchor_FallsBackToFirstStep()
{
@@ -119,6 +125,7 @@ public class FlowGraphBuilderLogLineTests
Assert.Equal(0, graph.UnattachedLogLines);
}
/// <summary>Verifies that a log line whose execution has no step is counted as unattached.</summary>
[Fact]
public void LogLine_NoStepInExecution_IsCountedUnattached()
{
@@ -130,6 +137,7 @@ public class FlowGraphBuilderLogLineTests
Assert.All(graph.Steps, s => Assert.True(s.Logs is null or { Count: 0 }));
}
/// <summary>Verifies that building from log lines only returns an empty graph with the unattached count.</summary>
[Fact]
public void Build_LinesOnly_ReturnsEmptyGraphWithUnattachedCount()
{
@@ -141,6 +149,7 @@ public class FlowGraphBuilderLogLineTests
Assert.True(graph.LogsTruncated);
}
/// <summary>Verifies that lines within the same millisecond are ordered by sequence with null sequences last.</summary>
[Fact]
public void Lines_OrderBySeqWithinSameMillisecond_NullSeqLast()
{
@@ -160,6 +169,7 @@ public class FlowGraphBuilderLogLineTests
Assert.Equal(new[] { "one", "two", "three", "null" }, logs.Select(l => l.Message).ToArray());
}
/// <summary>Verifies that log lines never become graph steps.</summary>
[Fact]
public void LogLines_NeverBecomeSteps()
{
@@ -177,6 +187,7 @@ public class FlowGraphBuilderLogLineTests
Assert.DoesNotContain(graph.Edges, e => lineIds.Contains(e.FromEventId) || lineIds.Contains(e.ToEventId));
}
/// <summary>Verifies that the builder passes the logs-truncated flag through to the graph.</summary>
[Fact]
public void Build_PassesLogsTruncatedThrough()
{
@@ -188,6 +199,7 @@ public class FlowGraphBuilderLogLineTests
Assert.False(graph.Truncated);
}
/// <summary>Verifies that a log line on a folded outbound step attaches by the first row id.</summary>
[Fact]
public void LogLine_OnFoldedOutboundStep_AttachesByFirstRowId()
{
@@ -52,6 +52,7 @@ public class FlowGraphBuilderTests
return [inbound, route, wait, run, write2, write1, cascade];
}
/// <summary>Verifies that an inferred device cascade edge is drawn dashed.</summary>
[Fact]
public void InferredDeviceCascade_IsDashed()
{
@@ -65,6 +66,9 @@ public class FlowGraphBuilderTests
Assert.True(edge.External);
}
/// <summary>Verifies that a folded node uses the latest error detail from its rows.</summary>
/// <param name="initialDetail">The error detail on the initial row.</param>
/// <param name="finalDetail">The error detail on the final row.</param>
[Theory]
[InlineData(null, "terminal stack")]
[InlineData("old stack", null)]
@@ -81,6 +85,7 @@ public class FlowGraphBuilderTests
Assert.Equal(finalDetail, step.Lifecycle[1].ErrorDetail);
}
/// <summary>Verifies that the Delmia call shape projects the expected lanes and edges plus a synthesized response.</summary>
[Fact]
public void DelmiaShape_ProjectsLanes_Edges_AndSynthesizedResponse()
{
@@ -142,6 +147,7 @@ public class FlowGraphBuilderTests
Assert.All(graph.Steps, s => Assert.Equal(FlowStepOutcome.Ok, s.Outcome));
}
/// <summary>Verifies that building the graph is deterministic and the synthesized response id is stable.</summary>
[Fact]
public void Build_IsDeterministic_ResponseIdIsStable()
{
@@ -152,6 +158,7 @@ public class FlowGraphBuilderTests
Assert.Equal(a.Edges, b.Edges);
}
/// <summary>Verifies that the truncated flag passes through to the built graph.</summary>
[Fact]
public void Truncated_PassesThrough()
{
@@ -160,6 +167,7 @@ public class FlowGraphBuilderTests
Assert.Empty(FlowGraphBuilder.Build(Array.Empty<AuditEvent>()).Steps);
}
/// <summary>Verifies that an unrecognized step kind projects to an unknown node without throwing.</summary>
[Fact]
public void UnknownKind_ProjectsToUnknown_NeverThrows()
{
@@ -190,6 +198,7 @@ public class FlowGraphBuilderTests
Assert.Empty(graph.Edges);
}
/// <summary>Verifies that a failed script run with a timeout message is classified as timed out.</summary>
[Fact]
public void FailedScriptRun_WithTimeoutMessage_IsTimedOut()
{
@@ -205,6 +214,7 @@ public class FlowGraphBuilderTests
Assert.Single(graph.Edges);
}
/// <summary>Verifies that the script trace extra payload round-trips as camelCase JSON and ignores unknown members.</summary>
[Fact]
public void ScriptTraceExtra_RoundTrips_CamelCase_AndIgnoresUnknownMembers()
{
@@ -219,7 +229,7 @@ public class FlowGraphBuilderTests
Assert.Null(ScriptTraceExtra.TryParse("nope"));
}
// ---- Outbound calls (2026-09-06 plan, Task 2) --------------------------------------------
// ---- Outbound calls (2026-09-06 plan) --------------------------------------------
private static AuditEvent SiteRunRow(int ms, Guid executionId = default) => ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
@@ -240,6 +250,7 @@ public class FlowGraphBuilderTests
executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs,
requestSummary: request, responseSummary: response);
/// <summary>Verifies that the four rows of a cached API call fold into a single asynchronous node.</summary>
[Fact]
public void CachedApiCall_FourRows_FoldIntoOneAsyncNode()
{
@@ -286,6 +297,7 @@ public class FlowGraphBuilderTests
Assert.Equal(3, graph.Edges.Count);
}
/// <summary>Verifies that a synchronous API call sits in the chain without an asynchronous edge.</summary>
[Fact]
public void SyncApiCall_SitsInChain_NoAsyncEdge()
{
@@ -309,6 +321,7 @@ public class FlowGraphBuilderTests
Assert.DoesNotContain(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[3].EventId);
}
/// <summary>Verifies that two synchronous calls with no correlation id are not folded together.</summary>
[Fact]
public void TwoSyncCalls_NullCorrelation_AreNotFoldedTogether()
{
@@ -326,6 +339,7 @@ public class FlowGraphBuilderTests
Assert.Equal(2, graph.Edges.Count);
}
/// <summary>Verifies that a notification send and its parked central delivery fold into one parked notify node.</summary>
[Fact]
public void NotifySend_PlusCentralDeliverParked_FoldsIntoOneParkedNotifyNode()
{
@@ -354,6 +368,7 @@ public class FlowGraphBuilderTests
Assert.Equal(notify.EventId, async.ToEventId);
}
/// <summary>Verifies that a cached call with only a submit row is pending and has no duration.</summary>
[Fact]
public void CachedSubmitOnly_IsPending_WithNoDuration()
{
@@ -374,6 +389,7 @@ public class FlowGraphBuilderTests
Assert.Equal(graph.Steps[0].EventId, async.FromEventId);
}
/// <summary>Verifies that the response links from the last step of the inbound execution rather than the last cascaded step.</summary>
[Fact]
public void ResponseLinksFromTheInboundExecutionsLastStep_NotTheLastCascadedStep()
{
@@ -385,6 +401,7 @@ public class FlowGraphBuilderTests
Assert.Equal(FlowStepKind.RouteCall, graph.Steps[1].Kind);
}
/// <summary>Verifies that the response falls back to the inbound row when no other row shares its execution.</summary>
[Fact]
public void ResponseFallsBackToTheInboundRow_WhenNothingElseSharesItsExecution()
{
@@ -422,6 +439,7 @@ public class FlowGraphBuilderTests
return [inbound, route, run, write];
}
/// <summary>Verifies that with a site clock running ahead, site steps still follow their route call and precede the response.</summary>
[Fact]
public void SiteClockAhead_SiteStepsStillFollowTheirRouteCall_AndPrecedeResponse()
{
@@ -440,6 +458,7 @@ public class FlowGraphBuilderTests
Assert.Equal(T0.AddMilliseconds(900), graph.Steps[4].OccurredAtUtc.UtcDateTime);
}
/// <summary>Verifies that with a site clock running behind, site steps do not sort ahead of the inbound request.</summary>
[Fact]
public void SiteClockBehind_SiteStepsDoNotSortAheadOfTheInboundRequest()
{
@@ -456,6 +475,7 @@ public class FlowGraphBuilderTests
Assert.Equal(events.Select(e => e.EventId), graph.Steps.Take(4).Select(s => s.EventId));
}
/// <summary>Verifies that with a site clock running behind, a trigger cascade spawns from the last parent step rather than the first.</summary>
[Fact]
public void SiteClockBehind_TriggerCascade_SpawnsFromTheLastParentStep_NotTheFirst()
{
@@ -477,6 +497,7 @@ public class FlowGraphBuilderTests
Assert.Equal(run.EventId, spawn.ToEventId);
}
/// <summary>Verifies that on a shared clock a trigger cascade spawns from the write that fired it rather than the parent's last step.</summary>
[Fact]
public void SameClock_TriggerCascade_SpawnsFromTheWriteThatFiredIt_NotTheParentsLastStep()
{
@@ -502,6 +523,7 @@ public class FlowGraphBuilderTests
Assert.Equal(cascade.EventId, spawn.ToEventId);
}
/// <summary>Verifies that with simultaneous writes a trigger cascade spawns from the write of the trigger attribute rather than the last write.</summary>
[Fact]
public void SimultaneousWrites_TriggerCascade_SpawnsFromTheWriteOfTheTriggerAttribute_NotTheLastWrite()
{
@@ -524,6 +546,7 @@ public class FlowGraphBuilderTests
Assert.Equal(cascade.EventId, spawn.ToEventId);
}
/// <summary>Verifies that a trigger cascade whose attribute the parent never wrote falls back to time-based placement.</summary>
[Fact]
public void TriggerCascade_WhoseAttributeTheParentNeverWrote_FallsBackToTheTimeRule()
{
@@ -543,6 +566,7 @@ public class FlowGraphBuilderTests
Assert.Equal(writeA.EventId, spawn.FromEventId);
}
/// <summary>Verifies that a folded node takes the emission index of its earliest emitted row.</summary>
[Fact]
public void FoldedNode_TakesTheEmissionIndexOfItsEarliestEmittedRow()
{
@@ -562,8 +586,9 @@ public class FlowGraphBuilderTests
Assert.Equal(new[] { FlowStepKind.ApiCall, FlowStepKind.ScriptRun }, graph.Steps.Select(s => s.Kind));
}
// ---- Review findings on Task 2 (2026-09-06) --------------------------------------------
// ---- Review findings on outbound calls (2026-09-06) --------------------------------------------
/// <summary>Verifies that leading asynchronous steps hang off the spawner and are never orphaned.</summary>
[Fact]
public void LeadingAsyncSteps_HangOffTheSpawner_NeverOrphaned()
{
@@ -591,6 +616,7 @@ public class FlowGraphBuilderTests
Assert.Equal(3, graph.Edges.Count);
}
/// <summary>Verifies that when an execution has only notify rows, all of them link from the spawner.</summary>
[Fact]
public void OnlyNotifyRows_InAnExecution_AllLinkFromTheSpawner()
{
@@ -606,6 +632,7 @@ public class FlowGraphBuilderTests
Assert.All(graph.Edges, e => { Assert.True(e.Async); Assert.True(e.Spawn); Assert.Equal(graph.Steps[0].EventId, e.FromEventId); });
}
/// <summary>Verifies that folding is independent of row order and ties are broken by lifecycle rank.</summary>
[Fact]
public void Fold_IsOrderIndependent_AndTiesBreakByLifecycleRank()
{
@@ -628,6 +655,7 @@ public class FlowGraphBuilderTests
Assert.Equal(sa.Lifecycle!.Select(r => r.Kind), sb.Lifecycle!.Select(r => r.Kind));
}
/// <summary>Verifies that a cached step whose submit row is missing is still treated as asynchronous.</summary>
[Fact]
public void CachedStep_WhoseSubmitRowIsMissing_IsStillAsync()
{
@@ -642,6 +670,7 @@ public class FlowGraphBuilderTests
Assert.Single(graph.Edges, e => e.Async && e.ToEventId == api.EventId);
}
/// <summary>Verifies that edges on the external lane never count as cluster boundary crossings.</summary>
[Fact]
public void ExternalLaneEdges_NeverCountAsClusterBoundaryCrossings()
{
@@ -660,8 +689,9 @@ public class FlowGraphBuilderTests
Assert.Equal(graph.Steps[1].EventId, into.FromEventId);
}
// ---- Script entry point (2026-09-08 plan, Task 6) -----------------------------------------
// ---- Script entry point (2026-09-08 plan) -----------------------------------------
/// <summary>Verifies that a route-call root without an inbound row builds a central-to-site graph with no response.</summary>
[Fact]
public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse()
{
@@ -5,12 +5,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>
/// Flow Debugger step-logs plan (2026-09-09) Task 1: the Commons log-line vocabulary —
/// Flow Debugger step-logs plan (2026-09-09): the Commons log-line vocabulary —
/// exception detail on a step (D3), and the <c>ScriptTrace.ScriptLog</c> row projecting
/// onto a <see cref="FlowLogLine"/> rather than a node (D1).
/// </summary>
public class FlowStepProjectorLogLineTests
{
/// <summary>Verifies that projecting an audit event carries its error detail onto the step.</summary>
[Fact]
public void Project_CarriesErrorDetail()
{
@@ -23,6 +24,7 @@ public class FlowStepProjectorLogLineTests
Assert.StartsWith("System.InvalidOperationException", step.ErrorDetail);
}
/// <summary>Verifies that the projected error detail is null when the audit event has none.</summary>
[Fact]
public void Project_ErrorDetail_IsNullWhenAbsent()
{
@@ -31,6 +33,7 @@ public class FlowStepProjectorLogLineTests
Assert.Null(FlowStepProjector.Project(evt).ErrorDetail);
}
/// <summary>Verifies that a script log row projects to the unknown kind and never to a step kind of its own.</summary>
[Fact]
public void ScriptLog_ProjectsToUnknownKind_NeverItsOwnStepKind()
{
@@ -40,6 +43,7 @@ public class FlowStepProjectorLogLineTests
Assert.False(Enum.GetNames<FlowStepKind>().Contains("ScriptLog"));
}
/// <summary>Verifies that a row is a log line only for the script trace channel with the script log kind.</summary>
[Fact]
public void IsLogLine_TrueOnlyForScriptTraceScriptLog()
{
@@ -50,6 +54,7 @@ public class FlowStepProjectorLogLineTests
Assert.False(FlowStepProjector.IsLogLine(run));
}
/// <summary>Verifies that rows on other channels are not log lines.</summary>
[Fact]
public void IsLogLine_FalseForOtherChannels()
{
@@ -58,6 +63,7 @@ public class FlowStepProjectorLogLineTests
Assert.False(FlowStepProjector.IsLogLine(outbound));
}
/// <summary>Verifies that converting a row to a log line reads its level, source and exception.</summary>
[Fact]
public void ToLogLine_ReadsLevelSourceAndException()
{
@@ -81,6 +87,7 @@ public class FlowStepProjectorLogLineTests
Assert.Equal(3, line.Seq);
}
/// <summary>Verifies that converting a row to a log line defaults the level and source when the extra payload is missing.</summary>
[Fact]
public void ToLogLine_DefaultsLevelAndSourceWhenExtraMissing()
{
@@ -97,6 +104,7 @@ public class FlowStepProjectorLogLineTests
Assert.Null(line.Seq);
}
/// <summary>Verifies that the log members of the script trace extra round-trip through JSON.</summary>
[Fact]
public void ScriptTraceExtra_LogMembers_RoundTripThroughJson()
{
@@ -111,6 +119,7 @@ public class FlowStepProjectorLogLineTests
Assert.Equal(12, parsed.Seq);
}
/// <summary>Verifies that a flow step's logs and error detail default to null.</summary>
[Fact]
public void FlowStep_LogsAndErrorDetail_DefaultToNull()
{
@@ -124,6 +133,7 @@ public class FlowStepProjectorLogLineTests
Assert.Single((step with { Logs = [line] }).Logs!);
}
/// <summary>Verifies that the log truncation members of a flow graph default to off.</summary>
[Fact]
public void FlowGraph_LogTruncationMembers_DefaultToOff()
{
@@ -6,13 +6,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>
/// Flow Debugger outbound-calls plan (2026-09-06) Task 0: the Commons vocabulary — External lane,
/// Flow Debugger outbound-calls plan (2026-09-06): the Commons vocabulary — External lane,
/// the three outbound kinds, Pending/Parked outcomes, the lifecycle row, and additive edge/step shapes.
/// </summary>
public class FlowStepProjectorTests
{
private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero);
/// <summary>Verifies that the vocabulary has an External lane, three outbound kinds, and Pending and Parked outcomes.</summary>
[Fact]
public void Vocabulary_HasExternalLane_ThreeOutboundKinds_AndPendingParkedOutcomes()
{
@@ -26,6 +27,7 @@ public class FlowStepProjectorTests
Assert.True(Enum.IsDefined(FlowStepOutcome.Parked));
}
/// <summary>Verifies that a flow step can be constructed without a lifecycle and defaults it to null.</summary>
[Fact]
public void FlowStep_WithoutLifecycle_StillConstructs_AndDefaultsToNull()
{
@@ -39,6 +41,7 @@ public class FlowStepProjectorTests
Assert.Equal("Submitted", withLifecycle.Lifecycle![0].Status);
}
/// <summary>Verifies that the Async and Spawn flags of a flow edge default to false.</summary>
[Fact]
public void FlowEdge_AsyncAndSpawn_DefaultFalse()
{
@@ -51,8 +54,9 @@ public class FlowStepProjectorTests
Assert.True(new FlowEdge(a, b, false, false, Async: false, Spawn: true).Spawn);
}
// ---- Task 1: outbound rows project onto the External lane ---------------------------------
// ---- Outbound rows project onto the External lane ---------------------------------
/// <summary>Verifies that a delivered synchronous API call projects to an Ok API-call step on the External lane with a sync subtitle.</summary>
[Fact]
public void SyncApiCall_Delivered_IsApiCall_External_Ok_SyncSubtitle()
{
@@ -73,6 +77,7 @@ public class FlowStepProjectorTests
Assert.Equal(212, step.DurationMs);
}
/// <summary>Verifies that a cached submit on the database outbound channel projects to a pending database-call step with a cached subtitle.</summary>
[Fact]
public void CachedSubmit_InDbOutbound_IsDbCall_Pending_CachedSubtitle()
{
@@ -87,6 +92,7 @@ public class FlowStepProjectorTests
Assert.Equal("Submitted · cached", step.Subtitle);
}
/// <summary>Verifies that a database write with a rows-affected extra has a subtitle combining the operation and the row count.</summary>
[Fact]
public void DbWrite_WithRowsAffectedExtra_SubtitleIsOpAndRows()
{
@@ -105,6 +111,7 @@ public class FlowStepProjectorTests
Assert.Equal("read · 1 row", FlowStepProjector.Project(read).Subtitle);
}
/// <summary>Verifies that a submitted notification send projects to a pending notify step.</summary>
[Fact]
public void NotifySend_Submitted_IsNotify_Pending()
{
@@ -120,6 +127,9 @@ public class FlowStepProjectorTests
Assert.Equal("Submitted", step.Subtitle);
}
/// <summary>Verifies that each outbound audit status maps to the expected flow step outcome.</summary>
/// <param name="status">The outbound audit status.</param>
/// <param name="expected">The expected flow step outcome.</param>
[Theory]
[InlineData(AuditStatus.Parked, FlowStepOutcome.Parked)]
[InlineData(AuditStatus.Discarded, FlowStepOutcome.Parked)]
@@ -139,6 +149,7 @@ public class FlowStepProjectorTests
Assert.Equal($"{status} · cached", step.Subtitle);
}
/// <summary>Verifies that script-trace outcomes are unchanged, so a failed status stays failed and other statuses are Ok.</summary>
[Fact]
public void ScriptTraceOutcome_Unchanged_FailedStaysFailed_OtherStatusesOk()
{
@@ -150,6 +161,9 @@ public class FlowStepProjectorTests
Assert.Equal(FlowStepOutcome.Failed, FlowStepProjector.Project(failed).Outcome);
}
/// <summary>Verifies that building a database subtitle never throws on odd extra values and yields the expected text.</summary>
/// <param name="extra">The extra payload of the audit row.</param>
/// <param name="expected">The expected subtitle.</param>
[Theory]
[InlineData("{\"op\":\"write\",\"rowsAffected\":2147483648}", "write")]
[InlineData("{\"op\":\"write\",\"rowsAffected\":1.5}", "write")]
@@ -163,6 +177,7 @@ public class FlowStepProjectorTests
Assert.Equal(expected, FlowStepProjector.Project(evt).Subtitle);
}
/// <summary>Verifies that projection carries the audit row's correlation id onto the step.</summary>
[Fact]
public void Project_CarriesTheAuditRowsCorrelationId()
{
@@ -173,6 +188,7 @@ public class FlowStepProjectorTests
Assert.Equal(corr, FlowStepProjector.Project(evt).CorrelationId);
}
/// <summary>Verifies that a null correlation id on the audit row leaves the step's correlation id null.</summary>
[Fact]
public void Project_NullCorrelation_LeavesCorrelationIdNull()
{
@@ -24,6 +24,7 @@ public class FlowTraceCollectorTests
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
target: "S", requestSummary: details);
/// <summary>Verifies that add, when beyond cap, keeps first n and flags truncated.</summary>
[Fact]
public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated()
{
@@ -39,6 +40,7 @@ public class FlowTraceCollectorTests
Assert.Equal("499", snap[499].Target);
}
/// <summary>Verifies that snapshot, when is a copy, in emission order.</summary>
[Fact]
public void Snapshot_IsACopy_InEmissionOrder()
{
@@ -52,6 +54,8 @@ public class FlowTraceCollectorTests
Assert.Equal(new[] { "1", "2" }, c.Snapshot().Select(e => e.Target));
}
/// <summary>Verifies that concurrent adds, when never throw, and never exceed cap.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConcurrentAdds_NeverThrow_AndNeverExceedCap()
{
@@ -66,6 +70,7 @@ public class FlowTraceCollectorTests
Assert.True(c.Truncated);
}
/// <summary>Verifies that default cap, when is 500, and non-positive cap is rejected.</summary>
[Fact]
public void DefaultCap_Is500_AndNonPositiveCapIsRejected()
{
@@ -73,6 +78,7 @@ public class FlowTraceCollectorTests
Assert.Throws<ArgumentOutOfRangeException>(() => new FlowTraceCollector(0));
}
/// <summary>Verifies that complete, when closes the collector, later adds are ignored and never truncate.</summary>
[Fact]
public void Complete_ClosesTheCollector_LaterAddsAreIgnored_AndNeverTruncate()
{
@@ -198,6 +204,9 @@ public class FlowTraceCollectorTests
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
private sealed class UpperRedactor : IAuditRedactor
{
/// <summary>Applies the upper-casing redaction.</summary>
/// <param name="rawEvent">The raw event.</param>
/// <returns>The event with upper-cased details.</returns>
public AuditEvent Apply(AuditEvent rawEvent)
=> rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() };
}
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests;
public class DeploymentFetchTokenTests
{
/// <summary>Verifies that generate, returns url safe token of adequate length.</summary>
[Fact]
public void Generate_ReturnsUrlSafeTokenOfAdequateLength()
{
@@ -14,6 +15,7 @@ public class DeploymentFetchTokenTests
Assert.DoesNotContain('/', token);
}
/// <summary>Verifies that generate, two calls return different values.</summary>
[Fact]
public void Generate_TwoCallsReturnDifferentValues()
{
@@ -23,6 +25,7 @@ public class DeploymentFetchTokenTests
Assert.NotEqual(a, b);
}
/// <summary>Verifies that constant time equals, same token, returns true.</summary>
[Fact]
public void ConstantTimeEquals_SameToken_ReturnsTrue()
{
@@ -31,6 +34,7 @@ public class DeploymentFetchTokenTests
Assert.True(DeploymentFetchToken.ConstantTimeEquals(token, token));
}
/// <summary>Verifies that constant time equals, token with suffix, returns false.</summary>
[Fact]
public void ConstantTimeEquals_TokenWithSuffix_ReturnsFalse()
{
@@ -39,6 +43,7 @@ public class DeploymentFetchTokenTests
Assert.False(DeploymentFetchToken.ConstantTimeEquals(token, token + "x"));
}
/// <summary>Verifies that constant time equals, both empty, returns true.</summary>
[Fact]
public void ConstantTimeEquals_BothEmpty_ReturnsTrue()
{
@@ -23,6 +23,7 @@ public class SiteCallTests
IngestedAtUtc = new DateTime(2026, 5, 23, 12, 0, 1, DateTimeKind.Utc),
};
/// <summary>Verifies that a site call carries its source node.</summary>
[Fact]
public void SiteCall_carries_SourceNode()
{
@@ -12,6 +12,7 @@ public class EntityConventionTests
.Where(t => t.IsClass && !t.IsAbstract && t.Namespace != null
&& t.Namespace.Contains(".Entities."));
/// <summary>Verifies that no entity class carries Entity Framework attributes.</summary>
[Fact]
public void AllEntities_ShouldHaveNoEfAttributes()
{
@@ -45,6 +46,7 @@ public class EntityConventionTests
}
}
/// <summary>Verifies that every timestamp property on an entity is a DateTimeOffset.</summary>
[Fact]
public void AllTimestampProperties_ShouldBeDateTimeOffset()
{
@@ -68,6 +70,7 @@ public class EntityConventionTests
}
}
/// <summary>Verifies that collection navigation properties on entities are declared as ICollection.</summary>
[Fact]
public void NavigationProperties_ShouldBeICollection()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
/// </summary>
public class ExternalSystemDefinitionTests
{
/// <summary>Verifies that the timeout seconds default to zero, meaning the gateway default is used.</summary>
[Fact]
public void ExternalSystemDefinition_TimeoutSeconds_DefaultsToZeroMeaningUseGatewayDefault()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
public class NativeAlarmSourceEntityTests
{
/// <summary>Verifies that a template native alarm source is constructed with its required name.</summary>
[Fact]
public void TemplateNativeAlarmSource_RequiresName()
{
@@ -13,6 +14,7 @@ public class NativeAlarmSourceEntityTests
Assert.False(s.IsLocked);
}
/// <summary>Verifies that a flattened configuration has an empty native alarm source list by default.</summary>
[Fact]
public void FlattenedConfiguration_HasNativeAlarmSourcesDefaultEmpty()
{
@@ -20,6 +22,7 @@ public class NativeAlarmSourceEntityTests
Assert.Empty(f.NativeAlarmSources);
}
/// <summary>Verifies that a resolved native alarm source has a default source template value.</summary>
[Fact]
public void ResolvedNativeAlarmSource_DefaultsSourceTemplate()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
/// </summary>
public class NotificationEntityTests
{
/// <summary>Verifies that the constructor sets the expected default values.</summary>
[Fact]
public void Constructor_SetsDefaults()
{
@@ -21,10 +22,11 @@ public class NotificationEntityTests
Assert.Equal("SiteA", n.SourceSiteId);
}
/// <summary>Verifies that OriginExecutionId defaults to null and can be set.</summary>
[Fact]
public void OriginExecutionId_DefaultsToNull_AndIsSettable()
{
// Audit Log #23: OriginExecutionId carries the originating script
// OriginExecutionId carries the originating script
// execution's id from the site so the dispatcher can echo it onto
// NotifyDeliver rows. Null for notifications submitted before the
// column existed; settable from the NotificationSubmit message.
@@ -36,6 +38,7 @@ public class NotificationEntityTests
Assert.Equal(executionId, n.OriginExecutionId);
}
/// <summary>Verifies that OriginParentExecutionId defaults to null and can be set.</summary>
[Fact]
public void OriginParentExecutionId_DefaultsToNull_AndIsSettable()
{
@@ -51,6 +54,7 @@ public class NotificationEntityTests
Assert.Equal(parentExecutionId, n.OriginParentExecutionId);
}
/// <summary>Verifies that SourceNode defaults to null and can be set.</summary>
[Fact]
public void SourceNode_DefaultsToNull_AndIsSettable()
{
@@ -65,6 +69,7 @@ public class NotificationEntityTests
Assert.Equal("node-a", n.SourceNode);
}
/// <summary>Verifies that the constructor throws for null arguments.</summary>
[Fact]
public void Constructor_NullArguments_Throw()
{
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
/// </summary>
public class NotificationRecipientTests
{
/// <summary>Verifies that creating an email recipient sets the email address and leaves the phone number null.</summary>
[Fact]
public void ForEmail_SetsEmail_LeavesPhoneNull()
{
@@ -18,6 +19,7 @@ public class NotificationRecipientTests
Assert.Null(r.PhoneNumber);
}
/// <summary>Verifies that creating an SMS recipient sets the phone number and leaves the email address null.</summary>
[Fact]
public void ForSms_SetsPhone_LeavesEmailNull()
{
@@ -28,6 +30,8 @@ public class NotificationRecipientTests
Assert.Null(r.EmailAddress);
}
/// <summary>Verifies that creating an email recipient with a blank name throws.</summary>
/// <param name="name">The blank recipient name.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -35,6 +39,8 @@ public class NotificationRecipientTests
public void ForEmail_BlankName_Throws(string? name)
=> Assert.Throws<ArgumentException>(() => NotificationRecipient.ForEmail(name!, "jane@example.com"));
/// <summary>Verifies that creating an SMS recipient with a blank name throws.</summary>
/// <param name="name">The blank recipient name.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -42,14 +48,18 @@ public class NotificationRecipientTests
public void ForSms_BlankName_Throws(string? name)
=> Assert.Throws<ArgumentException>(() => NotificationRecipient.ForSms(name!, "+15551234567"));
/// <summary>Verifies that creating an email recipient with a null email address throws.</summary>
[Fact]
public void ForEmail_NullEmail_Throws()
=> Assert.Throws<ArgumentNullException>(() => NotificationRecipient.ForEmail("Jane", null!));
/// <summary>Verifies that creating an SMS recipient with a null phone number throws.</summary>
[Fact]
public void ForSms_NullPhone_Throws()
=> Assert.Throws<ArgumentNullException>(() => NotificationRecipient.ForSms("Jane", null!));
/// <summary>Verifies that the public constructor throws for a blank name.</summary>
/// <param name="name">The blank recipient name.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -57,6 +67,7 @@ public class NotificationRecipientTests
public void PublicCtor_BlankName_Throws(string? name)
=> Assert.Throws<ArgumentException>(() => new NotificationRecipient(name!, "jane@example.com"));
/// <summary>Verifies that the public constructor throws for a null email address.</summary>
[Fact]
public void PublicCtor_NullEmail_Throws()
=> Assert.Throws<ArgumentNullException>(() => new NotificationRecipient("Jane", null!));
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
public class TemplateFolderTests
{
/// <summary>Verifies that the constructor sets the folder name.</summary>
[Fact]
public void Constructor_SetsName()
{
@@ -15,12 +16,14 @@ public class TemplateFolderTests
Assert.Equal(0, folder.SortOrder);
}
/// <summary>Verifies that the constructor throws when the name is null.</summary>
[Fact]
public void Constructor_NullName_Throws()
{
Assert.Throws<ArgumentNullException>(() => new TemplateFolder(null!));
}
/// <summary>Verifies that a template's folder id defaults to null.</summary>
[Fact]
public void Template_FolderId_DefaultsToNull()
{
@@ -5,12 +5,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Protocol;
/// <summary>
/// Single-source-of-truth coverage for <see cref="AlarmCapableProtocols.IsAlarmCapable"/>.
/// The OpcUa/MxGateway literal set previously lived inline in the Central UI
/// (TemplateEdit / InstanceConfigure); #53 collapsed those copies into this helper,
/// (TemplateEdit / InstanceConfigure); those copies were collapsed into this helper,
/// so the literal set is pinned here rather than against page markup. Keep this in
/// sync with the alarm-subscribable adapters registered in DataConnectionFactory.
/// </summary>
public class AlarmCapableProtocolsTests
{
/// <summary>Verifies that alarm-subscribable protocols are reported as alarm capable.</summary>
/// <param name="protocol">The protocol name under test.</param>
[Theory]
[InlineData("OpcUa")]
[InlineData("opcua")] // case-insensitive — matches DataConnectionFactory's OrdinalIgnoreCase key lookup
@@ -22,6 +24,8 @@ public class AlarmCapableProtocolsTests
Assert.True(AlarmCapableProtocols.IsAlarmCapable(protocol));
}
/// <summary>Verifies that non-alarm-capable, null or blank protocols are not reported as alarm capable.</summary>
/// <param name="protocol">The protocol name under test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -6,11 +6,13 @@ using ICentralAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IC
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services;
/// <summary>
/// Reflection-level contract guards for the Audit Log (#23) writer interfaces.
/// Reflection-level contract guards for the Audit Log writer interfaces.
/// Locks in method signature so DI bindings + adapter implementations stay aligned.
/// </summary>
public class AuditWriterContractTests
{
/// <summary>Verifies that the writer interface exposes <c>WriteAsync</c> with the expected signature.</summary>
/// <param name="writerType">The writer interface type under test.</param>
[Theory]
[InlineData(typeof(IAuditWriter))]
[InlineData(typeof(ICentralAuditWriter))]
@@ -27,6 +29,7 @@ public class AuditWriterContractTests
Assert.True(parameters[1].HasDefaultValue);
}
/// <summary>Verifies that the site and central audit writer interfaces are distinct types.</summary>
[Fact]
public void IAuditWriter_AndICentralAuditWriter_AreDistinctTypes()
{
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services;
/// <summary>
/// Tests for <see cref="ExternalCallResult"/>, in particular the Commons-021
/// Tests for <see cref="ExternalCallResult"/>, in particular the
/// thread-safe lazy parse of <c>Response</c>. The pre-fix implementation used
/// two mutable fields (<c>_response</c>/<c>_responseParsed</c>) with no
/// synchronization, so concurrent readers could each construct a fresh
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services;
/// </summary>
public class ExternalCallResultTests
{
/// <summary>Verifies that a null or empty response JSON yields a null response.</summary>
[Fact]
public void Response_NullOrEmptyJson_ReturnsNull()
{
@@ -24,6 +25,7 @@ public class ExternalCallResultTests
Assert.Null(withEmpty.Response);
}
/// <summary>Verifies that the response JSON is parsed into a dynamic element.</summary>
[Fact]
public void Response_ParsesJsonIntoDynamicElement()
{
@@ -37,7 +39,7 @@ public class ExternalCallResultTests
}
/// <summary>
/// Commons-021: concurrent readers must observe the same parsed instance
/// Concurrent readers must observe the same parsed instance
/// (a `Lazy&lt;T&gt;` invariant). Under the pre-fix code two threads could
/// both produce a fresh `DynamicJsonElement` and one would win the race —
/// `ReferenceEquals` would then occasionally fail. With the fix every
@@ -86,15 +86,24 @@ public class KpiMetricAggregationTests
(KpiSources.SiteHealth, "localDbReplicationConnected"),
};
/// <summary>Supplies every emitted source and metric pair as theory data.</summary>
/// <returns>The theory data rows of source and metric names.</returns>
public static IEnumerable<object[]> AllEmittedCases() =>
AllEmittedMetrics.Select(m => new object[] { m.Source, m.Metric });
/// <summary>Supplies the rate metric source and metric pairs as theory data.</summary>
/// <returns>The theory data rows of source and metric names.</returns>
public static IEnumerable<object[]> RateCases() =>
RateMetrics.Select(m => new object[] { m.Source, m.Metric });
/// <summary>Supplies the gauge-sampled source and metric pairs as theory data.</summary>
/// <returns>The theory data rows of source and metric names.</returns>
public static IEnumerable<object[]> GaugeCases() =>
GaugeSampling.Select(m => new object[] { m.Source, m.Metric });
/// <summary>Verifies that every emitted metric resolves to a defined aggregation.</summary>
/// <param name="source">The KPI source name.</param>
/// <param name="metric">The metric name.</param>
[Theory]
[MemberData(nameof(AllEmittedCases))]
public void Resolve_EveryEmittedMetric_ReturnsDefinedAggregation(string source, string metric)
@@ -106,6 +115,9 @@ public class KpiMetricAggregationTests
$"({source}, {metric}) resolved to an undefined aggregation: {result}");
}
/// <summary>Verifies that rate metrics are classified as rate.</summary>
/// <param name="source">The KPI source name.</param>
/// <param name="metric">The metric name.</param>
[Theory]
[MemberData(nameof(RateCases))]
public void Resolve_RateMetrics_ClassifiedAsRate(string source, string metric)
@@ -113,6 +125,9 @@ public class KpiMetricAggregationTests
Assert.Equal(KpiRollupAggregation.Rate, KpiMetricAggregationCatalog.Resolve(source, metric));
}
/// <summary>Verifies that gauge-sampled metrics are classified as gauge.</summary>
/// <param name="source">The KPI source name.</param>
/// <param name="metric">The metric name.</param>
[Theory]
[MemberData(nameof(GaugeCases))]
public void Resolve_GaugeSampling_ClassifiedAsGauge(string source, string metric)
@@ -120,6 +135,7 @@ public class KpiMetricAggregationTests
Assert.Equal(KpiRollupAggregation.Gauge, KpiMetricAggregationCatalog.Resolve(source, metric));
}
/// <summary>Verifies that last-hour audit metrics resolve as gauges rather than rates.</summary>
[Fact]
public void Resolve_LastHourAuditMetrics_AreGaugeNotRate()
{
@@ -133,6 +149,9 @@ public class KpiMetricAggregationTests
KpiMetricAggregationCatalog.Resolve(KpiSources.AuditLog, "errorEventsLastHour"));
}
/// <summary>Verifies that an unknown metric defaults to a gauge.</summary>
/// <param name="source">The KPI source name.</param>
/// <param name="metric">The metric name.</param>
[Theory]
[InlineData(KpiSources.NotificationOutbox, "someBrandNewMetricAddedLater")]
[InlineData("AnEntirelyNewSource", "anyMetric")]
@@ -144,6 +163,7 @@ public class KpiMetricAggregationTests
Assert.Equal(KpiRollupAggregation.Gauge, KpiMetricAggregationCatalog.Resolve(source, metric));
}
/// <summary>Verifies that the same metric name under a different source is keyed on the source and metric pair.</summary>
[Fact]
public void Resolve_SameMetricNameDifferentSource_KeysOnSourcePair()
{
@@ -157,8 +177,9 @@ public class KpiMetricAggregationTests
KpiMetricAggregationCatalog.Resolve(KpiSources.SiteCallAudit, "deliveredLastInterval"));
}
// ---- Task 8: catalog metric literals promoted to shared KpiMetrics constants (R4) ----
// ---- Catalog metric literals promoted to shared KpiMetrics constants (R4) ----
/// <summary>Verifies that the promoted metric constants lock the historical persisted values.</summary>
[Fact]
public void PromotedMetricConstants_LockHistoricalPersistedValues()
{
@@ -168,6 +189,9 @@ public class KpiMetricAggregationTests
Assert.Equal("eventLogWriteFailures", KpiMetrics.SiteHealth.EventLogWriteFailures);
}
/// <summary>Verifies that the promoted rate pairs still classify as rate.</summary>
/// <param name="source">The KPI source name.</param>
/// <param name="metric">The metric name.</param>
[Theory]
[InlineData(KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.DeliveredLastInterval)]
[InlineData(KpiSources.SiteHealth, KpiMetrics.SiteHealth.AlarmEvalErrors)]
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi;
public class KpiSampleTests
{
/// <summary>Verifies that a KpiSample constructed with its required members round-trips the values.</summary>
[Fact]
public void KpiSample_ConstructedWithRequiredMembers_RoundTripsValues()
{
@@ -31,6 +32,7 @@ public class KpiSampleTests
Assert.Equal(DateTimeKind.Utc, sample.CapturedAtUtc.Kind);
}
/// <summary>Verifies that a scoped sample carries its scope key.</summary>
[Fact]
public void KpiSample_ScopedSample_CarriesScopeKey()
{
@@ -49,6 +51,7 @@ public class KpiSampleTests
Assert.Equal(12.5, sample.Value);
}
/// <summary>Verifies that each KPI scope and source constant equals its own name.</summary>
[Fact]
public void KpiScopesAndSources_ConstantValues_EqualTheirNames()
{
@@ -62,6 +65,7 @@ public class KpiSampleTests
Assert.Equal("Node", KpiScopes.Node);
}
/// <summary>Verifies that KpiSeriesPoint is a value record struct with component-wise equality.</summary>
[Fact]
public void KpiSeriesPoint_IsValueRecordStruct_WithComponentEquality()
{
@@ -16,6 +16,7 @@ public class KpiSeriesBucketerTests
// Guard / invalid-argument contracts
// -----------------------------------------------------------------------
/// <summary>Verifies that a maximum point count below two throws an ArgumentOutOfRangeException.</summary>
[Fact]
public void Bucket_MaxPointsLessThan2_ThrowsArgumentOutOfRangeException()
{
@@ -25,6 +26,7 @@ public class KpiSeriesBucketerTests
KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 1));
}
/// <summary>Verifies that a maximum point count of zero throws an ArgumentOutOfRangeException.</summary>
[Fact]
public void Bucket_MaxPointsZero_ThrowsArgumentOutOfRangeException()
{
@@ -34,6 +36,7 @@ public class KpiSeriesBucketerTests
KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 0));
}
/// <summary>Verifies that a window whose end equals its start throws an ArgumentOutOfRangeException.</summary>
[Fact]
public void Bucket_ToUtcEqualFromUtc_ThrowsArgumentOutOfRangeException()
{
@@ -43,6 +46,7 @@ public class KpiSeriesBucketerTests
KpiSeriesBucketer.Bucket(raw, T(30), T(30), maxPoints: 5));
}
/// <summary>Verifies that a window that ends before it starts throws an ArgumentOutOfRangeException.</summary>
[Fact]
public void Bucket_ToUtcBeforeFromUtc_ThrowsArgumentOutOfRangeException()
{
@@ -56,6 +60,7 @@ public class KpiSeriesBucketerTests
// Empty / null raw series
// -----------------------------------------------------------------------
/// <summary>Verifies that a null raw series returns an empty list.</summary>
[Fact]
public void Bucket_NullRaw_ReturnsEmptyList()
{
@@ -64,6 +69,7 @@ public class KpiSeriesBucketerTests
Assert.Empty(result);
}
/// <summary>Verifies that an empty raw series returns an empty list.</summary>
[Fact]
public void Bucket_EmptyRaw_ReturnsEmptyList()
{
@@ -77,6 +83,7 @@ public class KpiSeriesBucketerTests
// raw.Count <= maxPoints → returned unchanged (same reference)
// -----------------------------------------------------------------------
/// <summary>Verifies that a raw series with exactly the maximum number of points is returned as the same reference.</summary>
[Fact]
public void Bucket_RawCountEqualsMaxPoints_ReturnsSameReference()
{
@@ -92,6 +99,7 @@ public class KpiSeriesBucketerTests
Assert.Same(raw, result);
}
/// <summary>Verifies that a raw series with fewer points than the maximum is returned as the same reference.</summary>
[Fact]
public void Bucket_RawCountLessThanMaxPoints_ReturnsSameReference()
{
@@ -106,6 +114,7 @@ public class KpiSeriesBucketerTests
Assert.Same(raw, result);
}
/// <summary>Verifies that a single-point series is returned as the same reference.</summary>
[Fact]
public void Bucket_SinglePoint_ReturnsSameReference()
{
@@ -120,6 +129,7 @@ public class KpiSeriesBucketerTests
// Downsampling: correct bucket count and last-value selection
// -----------------------------------------------------------------------
/// <summary>Verifies that a series with more points than the maximum is reduced to the maximum number of buckets.</summary>
[Fact]
public void Bucket_MorePointsThanMaxPoints_ReducesToMaxPointsBuckets()
{
@@ -140,6 +150,7 @@ public class KpiSeriesBucketerTests
Assert.Equal(30.0, result[2].Value);
}
/// <summary>Verifies that when several points fall in the same bucket the last value wins.</summary>
[Fact]
public void Bucket_MultiplePointsInSameBucket_LastValueWins()
{
@@ -160,6 +171,7 @@ public class KpiSeriesBucketerTests
Assert.Equal(5.0, result[1].Value); // only point in bucket 1
}
/// <summary>Verifies that each output point carries the bucket start rather than the raw point timestamp.</summary>
[Fact]
public void Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp()
{
@@ -187,10 +199,11 @@ public class KpiSeriesBucketerTests
// Unsorted input: last-in-iteration wins within a bucket (NOT largest timestamp)
// -----------------------------------------------------------------------
/// <summary>Verifies that for unsorted input the last point in iteration order is selected, not the point with the largest timestamp.</summary>
[Fact]
public void Bucket_UnsortedInput_SelectsLastInIterationNotLargestTimestamp()
{
// KpiHistory-006 regression: pins the documented contract that for unsorted input the
// Regression: pins the documented contract that for unsorted input the
// bucketer selects the LAST point in iteration order within a bucket — it does NOT pick
// the largest-timestamp point. Both points below fall in bucket 0 ([0, 30)); the later
// one in iteration order, T(5)=value 1.0, arrives second, so it overwrites T(20)=value
@@ -216,10 +229,11 @@ public class KpiSeriesBucketerTests
// Short series (raw.Count <= maxPoints): returned unchanged → raw capture timestamps
// -----------------------------------------------------------------------
/// <summary>Verifies that a short series keeps its raw capture timestamps instead of bucket boundaries.</summary>
[Fact]
public void Bucket_ShortSeries_ReturnsRawCaptureTimestampsNotBucketBoundaries()
{
// KpiHistory-005/003 regression: the short-series early-return path returns the input
// Regression: the short-series early-return path returns the input
// unchanged, so each output point's BucketStartUtc is the RAW capture timestamp — NOT a
// bucket-boundary timestamp (which is what the downsample path emits, asserted by
// Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp). This pins the
@@ -243,6 +257,7 @@ public class KpiSeriesBucketerTests
// Right-edge: point exactly at toUtc lands in the last bucket
// -----------------------------------------------------------------------
/// <summary>Verifies that a point exactly at the window end lands in the last bucket.</summary>
[Fact]
public void Bucket_PointAtToUtc_LandsInLastBucket()
{
@@ -264,6 +279,7 @@ public class KpiSeriesBucketerTests
Assert.Equal(7.0, result[1].Value);
}
/// <summary>Verifies that in a three-bucket window a point exactly at the window end lands in the last bucket.</summary>
[Fact]
public void Bucket_ThreeBucketWindow_PointAtToUtc_LandsInLastBucket()
{
@@ -291,6 +307,7 @@ public class KpiSeriesBucketerTests
// Empty buckets omitted — no gap-filling
// -----------------------------------------------------------------------
/// <summary>Verifies that empty buckets caused by a gap in the raw data are omitted.</summary>
[Fact]
public void Bucket_GapInRaw_EmptyBucketsOmitted()
{
@@ -329,6 +346,7 @@ public class KpiSeriesBucketerTests
// Points outside [fromUtc, toUtc] are ignored
// -----------------------------------------------------------------------
/// <summary>Verifies that points outside the window are ignored.</summary>
[Fact]
public void Bucket_PointsOutsideWindow_AreIgnored()
{
@@ -353,6 +371,7 @@ public class KpiSeriesBucketerTests
// Two-point minimum — maxPoints == 2 works correctly
// -----------------------------------------------------------------------
/// <summary>Verifies that a maximum of exactly two points produces at most two buckets.</summary>
[Fact]
public void Bucket_MaxPointsExactly2_ProducesAtMostTwoBuckets()
{
@@ -374,6 +393,7 @@ public class KpiSeriesBucketerTests
// Per-metric reduction: Rate series sum per bucket (arch-review 04 round 2, R3)
// -----------------------------------------------------------------------
/// <summary>Verifies that a rate series sums the values in each bucket instead of taking the last value.</summary>
[Fact]
public void Bucket_RateSeries_SumsPerBucket_InsteadOfLastValue()
{
@@ -397,6 +417,7 @@ public class KpiSeriesBucketerTests
Assert.Equal(30.0, result[1].Value);
}
/// <summary>Verifies that a short rate series still sums clustered points.</summary>
[Fact]
public void Bucket_RateSeries_ShortSeries_StillSumsClusteredPoints()
{
@@ -418,6 +439,7 @@ public class KpiSeriesBucketerTests
Assert.Equal(10.0, result[1].Value); // T(30) alone
}
/// <summary>Verifies that bucketing a rate series preserves the series total.</summary>
[Fact]
public void Bucket_RateSeries_PreservesSeriesTotal()
{
@@ -436,6 +458,7 @@ public class KpiSeriesBucketerTests
Assert.Equal(inWindowTotal, result.Sum(p => p.Value));
}
/// <summary>Verifies that the default aggregation is gauge and its behavior is unchanged.</summary>
[Fact]
public void Bucket_DefaultAggregation_IsGauge_AndBehaviorUnchanged()
{
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
public class AlarmStateChangedEnrichmentTests
{
/// <summary>Verifies that a default alarm state change is of the computed kind with auto-acknowledge.</summary>
[Fact]
public void Defaults_AreComputedKind_WithAutoAck()
{
@@ -17,6 +18,7 @@ public class AlarmStateChangedEnrichmentTests
Assert.Equal("", m.SourceReference);
}
/// <summary>Verifies that the computed factory maps priority and state onto the message.</summary>
[Fact]
public void Factory_ForComputed_MapsPriorityAndState()
{
@@ -28,6 +30,7 @@ public class AlarmStateChangedEnrichmentTests
// ── MES alarm-status API §6.4: additive AckTime ──
/// <summary>Verifies that the acknowledge time defaults to null when using the positional constructor.</summary>
[Fact]
public void AckTime_DefaultsToNull_OnThePositionalConstructor()
{
@@ -40,6 +43,7 @@ public class AlarmStateChangedEnrichmentTests
Assert.Null(m.AckTime); // …yet still no ack instant
}
/// <summary>Verifies that the acknowledge time round-trips through its init property.</summary>
[Fact]
public void AckTime_RoundTripsThroughTheInitProperty()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// </summary>
public class BrowseCommandsRegistryTests
{
/// <summary>Verifies that the command registry discovers the browse-node command.</summary>
[Fact]
public void Registry_discovers_BrowseNodeCommand()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Task 21 (PLAN-07): the additive optional <c>SiteIdentifier</c> trailing param on the
/// The additive optional <c>SiteIdentifier</c> trailing param on the
/// Browse/Verify/Cert-trust management commands. Verifies backward compatibility (legacy
/// JSON without the field deserializes to null) and round-trip survival when set.
/// </summary>
@@ -17,6 +17,7 @@ public class BrowseVerifyCertSiteIdentifierTests
// ── Backward compatibility: legacy JSON (no SiteIdentifier) → null ──
/// <summary>Verifies that browse node command legacy json site identifier null.</summary>
[Fact]
public void BrowseNodeCommand_LegacyJson_SiteIdentifierNull()
{
@@ -26,6 +27,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal("conn", cmd.ConnectionName);
}
/// <summary>Verifies that search address space command legacy json site identifier null.</summary>
[Fact]
public void SearchAddressSpaceCommand_LegacyJson_SiteIdentifierNull()
{
@@ -35,6 +37,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal("pump", cmd.Query);
}
/// <summary>Verifies that verify endpoint command legacy json site identifier null.</summary>
[Fact]
public void VerifyEndpointCommand_LegacyJson_SiteIdentifierNull()
{
@@ -44,6 +47,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal("OpcUa", cmd.Protocol);
}
/// <summary>Verifies that trust server cert command legacy json site identifier null.</summary>
[Fact]
public void TrustServerCertCommand_LegacyJson_SiteIdentifierNull()
{
@@ -53,6 +57,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal("ABCD", cmd.Thumbprint);
}
/// <summary>Verifies that remove server cert command legacy json site identifier null.</summary>
[Fact]
public void RemoveServerCertCommand_LegacyJson_SiteIdentifierNull()
{
@@ -62,6 +67,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal("ABCD", cmd.Thumbprint);
}
/// <summary>Verifies that list server certs command legacy json site identifier null.</summary>
[Fact]
public void ListServerCertsCommand_LegacyJson_SiteIdentifierNull()
{
@@ -72,6 +78,7 @@ public class BrowseVerifyCertSiteIdentifierTests
// ── Round-trip: SiteIdentifier set survives serialize → deserialize ──
/// <summary>Verifies that browse node command round trip site identifier survives.</summary>
[Fact]
public void BrowseNodeCommand_RoundTrip_SiteIdentifierSurvives()
{
@@ -82,6 +89,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal(original, round);
}
/// <summary>Verifies that search address space command round trip site identifier survives.</summary>
[Fact]
public void SearchAddressSpaceCommand_RoundTrip_SiteIdentifierSurvives()
{
@@ -92,6 +100,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal(original, round);
}
/// <summary>Verifies that verify endpoint command round trip site identifier survives.</summary>
[Fact]
public void VerifyEndpointCommand_RoundTrip_SiteIdentifierSurvives()
{
@@ -102,6 +111,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal(original, round);
}
/// <summary>Verifies that trust server cert command round trip site identifier survives.</summary>
[Fact]
public void TrustServerCertCommand_RoundTrip_SiteIdentifierSurvives()
{
@@ -112,6 +122,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal(original, round);
}
/// <summary>Verifies that remove server cert command round trip site identifier survives.</summary>
[Fact]
public void RemoveServerCertCommand_RoundTrip_SiteIdentifierSurvives()
{
@@ -122,6 +133,7 @@ public class BrowseVerifyCertSiteIdentifierTests
Assert.Equal(original, round);
}
/// <summary>Verifies that list server certs command round trip site identifier survives.</summary>
[Fact]
public void ListServerCertsCommand_RoundTrip_SiteIdentifierSurvives()
{
@@ -39,6 +39,7 @@ public class ClusterClientContractLockTests
// (a) Old-shape payloads: newer trailing optional fields absent → defaults.
// ---------------------------------------------------------------------
/// <summary>Verifies that an old-shape <c>CreateDataConnectionCommand</c> without the newer trailing fields still deserializes.</summary>
[Fact]
public void CreateDataConnectionCommand_OldShape_WithoutNewerTrailingFields_Deserializes()
{
@@ -62,6 +63,7 @@ public class ClusterClientContractLockTests
Assert.Equal(0, cmd.FailoverRetryCount); // value-type missing field → default(int), not 3
}
/// <summary>Verifies that an old-shape <c>DeployInstanceCommand</c> without the fetch fields still deserializes.</summary>
[Fact]
public void DeployInstanceCommand_OldShape_WithoutFetchFields_Deserializes()
{
@@ -78,6 +80,7 @@ public class ClusterClientContractLockTests
Assert.Null(cmd.FetchToken); // default applied
}
/// <summary>Verifies that an old-shape <c>NotificationSubmit</c> without the execution correlation fields still deserializes.</summary>
[Fact]
public void NotificationSubmit_OldShape_WithoutExecutionCorrelationFields_Deserializes()
{
@@ -99,6 +102,7 @@ public class ClusterClientContractLockTests
// (b) Unknown fields from a newer peer are ignored (never throw).
// ---------------------------------------------------------------------
/// <summary>Verifies that an unknown field from a newer peer is ignored when deserializing <c>AttributeValueChanged</c>.</summary>
[Fact]
public void AttributeValueChanged_UnknownFieldFromNewerPeer_IsIgnored()
{
@@ -112,6 +116,7 @@ public class ClusterClientContractLockTests
Assert.Equal("Good", evt.Quality);
}
/// <summary>Verifies that an unknown field from a newer peer is ignored when deserializing <c>GetAttributeRequest</c>.</summary>
[Fact]
public void GetAttributeRequest_UnknownFieldFromNewerPeer_IsIgnored()
{
@@ -125,6 +130,7 @@ public class ClusterClientContractLockTests
Assert.Equal("a", req.AttributeName);
}
/// <summary>Verifies that an unknown field from a newer peer is ignored when deserializing <c>SetStaticAttributeCommand</c>.</summary>
[Fact]
public void SetStaticAttributeCommand_UnknownFieldFromNewerPeer_IsIgnored()
{
@@ -142,6 +148,7 @@ public class ClusterClientContractLockTests
// (c) Round-trip preserves values.
// ---------------------------------------------------------------------
/// <summary>Verifies that a <c>ManagementEnvelope</c> round trip preserves the correlation id and user.</summary>
[Fact]
public void ManagementEnvelope_RoundTrip_PreservesCorrelationIdAndUser()
{
@@ -159,6 +166,7 @@ public class ClusterClientContractLockTests
Assert.Equal(new[] { "site-1", "site-2" }, round.User.PermittedSiteIds);
}
/// <summary>Verifies that an <c>AttributeValueChanged</c> round trip preserves its values.</summary>
[Fact]
public void AttributeValueChanged_RoundTrip_PreservesValues()
{
@@ -175,6 +183,7 @@ public class ClusterClientContractLockTests
Assert.Equal(evt.Timestamp, round.Timestamp);
}
/// <summary>Verifies that a <c>CreateDataConnectionCommand</c> round trip preserves its values.</summary>
[Fact]
public void CreateDataConnectionCommand_RoundTrip_PreservesValues()
{
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// WP-9 (Phase 8): Message contract compatibility tests.
/// Message contract compatibility tests.
/// Verifies forward compatibility (unknown fields), backward compatibility (missing optional fields),
/// and version skew scenarios for all critical message types.
/// </summary>
@@ -24,6 +24,7 @@ public class CompatibilityTests
// ── Forward Compatibility: unknown fields are ignored ──
/// <summary>Verifies that a deploy-instance command ignores an unknown field when deserialized.</summary>
[Fact]
public void ForwardCompat_DeployInstanceCommand_UnknownFieldIgnored()
{
@@ -46,6 +47,7 @@ public class CompatibilityTests
Assert.Equal("abc123", msg.RevisionHash);
}
/// <summary>Verifies that a site health report ignores an unknown field when deserialized.</summary>
[Fact]
public void ForwardCompat_SiteHealthReport_UnknownFieldIgnored()
{
@@ -70,6 +72,7 @@ public class CompatibilityTests
Assert.Equal(5, msg.SequenceNumber);
}
/// <summary>Verifies that a script call request ignores an unknown field when deserialized.</summary>
[Fact]
public void ForwardCompat_ScriptCallRequest_UnknownFieldIgnored()
{
@@ -90,6 +93,7 @@ public class CompatibilityTests
Assert.Equal("OnTrigger", msg.ScriptName);
}
/// <summary>Verifies that an attribute-value-changed message ignores an unknown field when deserialized.</summary>
[Fact]
public void ForwardCompat_AttributeValueChanged_UnknownFieldIgnored()
{
@@ -112,6 +116,7 @@ public class CompatibilityTests
// ── Backward Compatibility: missing optional fields ──
/// <summary>Verifies that a deployment status response deserializes when the error message is missing.</summary>
[Fact]
public void BackwardCompat_DeploymentStatusResponse_MissingErrorMessage()
{
@@ -130,6 +135,7 @@ public class CompatibilityTests
Assert.Null(msg.ErrorMessage);
}
/// <summary>Verifies that a script call result deserializes when the return value is missing.</summary>
[Fact]
public void BackwardCompat_ScriptCallResult_MissingReturnValue()
{
@@ -147,6 +153,7 @@ public class CompatibilityTests
Assert.Null(msg.ReturnValue);
}
/// <summary>Verifies that a deploy-artifacts command deserializes when the optional lists are missing.</summary>
[Fact]
public void BackwardCompat_DeployArtifactsCommand_MissingOptionalLists()
{
@@ -164,6 +171,7 @@ public class CompatibilityTests
Assert.Null(msg.ExternalSystems);
}
/// <summary>Verifies that an instance lifecycle response deserializes when the error message is missing.</summary>
[Fact]
public void BackwardCompat_InstanceLifecycleResponse_MissingErrorMessage()
{
@@ -184,6 +192,7 @@ public class CompatibilityTests
// ── Version Skew: old message format still deserializable ──
/// <summary>Verifies that an old-shape deploy command deserializes with default values for newer fields.</summary>
[Fact]
public void VersionSkew_OldDeployCommand_DeserializesWithDefaults()
{
@@ -205,6 +214,7 @@ public class CompatibilityTests
Assert.Equal("old-hash", msg.RevisionHash);
}
/// <summary>Verifies that an old-shape health report deserializes correctly.</summary>
[Fact]
public void VersionSkew_OldHealthReport_DeserializesCorrectly()
{
@@ -230,11 +240,12 @@ public class CompatibilityTests
// ── Round-trip serialization for all key message types ──
// Communication-016: RoundTrip_ConnectionStateChanged_Succeeds removed
// RoundTrip_ConnectionStateChanged_Succeeds removed
// alongside the dead ConnectionStateChanged message record. No production
// code emits or receives this message — disconnect detection is owned by
// the gRPC keepalive and the Ask-timeout path.
/// <summary>Verifies that an alarm-state-changed message survives a serialization round trip.</summary>
[Fact]
public void RoundTrip_AlarmStateChanged_Succeeds()
{
@@ -247,6 +258,7 @@ public class CompatibilityTests
Assert.Equal("HighTemp", deserialized.AlarmName);
}
/// <summary>Verifies that an alarm-state-changed message preserves its acknowledgement time across a round trip.</summary>
[Fact]
public void RoundTrip_AlarmStateChanged_PreservesAckTime()
{
@@ -264,6 +276,7 @@ public class CompatibilityTests
Assert.Equal(ackedAt, deserialized!.AckTime);
}
/// <summary>Verifies that a missing acknowledgement time deserializes as null.</summary>
[Fact]
public void BackwardCompat_AlarmStateChanged_MissingAckTime_DefaultsToNull()
{
@@ -285,6 +298,7 @@ public class CompatibilityTests
Assert.Null(deserialized!.AckTime);
}
/// <summary>Verifies that a missing MES-reportable flag deserializes as false.</summary>
[Fact]
public void BackwardCompat_AlarmStateChanged_MissingIsMesReportable_DefaultsToFalse()
{
@@ -308,6 +322,7 @@ public class CompatibilityTests
Assert.False(deserialized!.IsMesReportable);
}
/// <summary>Verifies that a heartbeat message survives a serialization round trip.</summary>
[Fact]
public void RoundTrip_HeartbeatMessage_Succeeds()
{
@@ -320,6 +335,7 @@ public class CompatibilityTests
Assert.Equal("node-a", deserialized.NodeHostname);
}
/// <summary>Verifies that a disable-instance command survives a serialization round trip.</summary>
[Fact]
public void RoundTrip_DisableInstanceCommand_Succeeds()
{
@@ -331,6 +347,7 @@ public class CompatibilityTests
Assert.Equal("cmd-1", deserialized!.CommandId);
}
/// <summary>Verifies that an enable-instance command survives a serialization round trip.</summary>
[Fact]
public void RoundTrip_EnableInstanceCommand_Succeeds()
{
@@ -342,6 +359,7 @@ public class CompatibilityTests
Assert.Equal("cmd-2", deserialized!.CommandId);
}
/// <summary>Verifies that a delete-instance command survives a serialization round trip.</summary>
[Fact]
public void RoundTrip_DeleteInstanceCommand_Succeeds()
{
@@ -355,6 +373,7 @@ public class CompatibilityTests
// ── Additive-only evolution: new fields added as nullable ──
/// <summary>Verifies that newly added nullable fields do not break deserialization of older payloads.</summary>
[Fact]
public void AdditiveEvolution_NewNullableFields_DoNotBreakDeserialization()
{
@@ -369,6 +388,7 @@ public class CompatibilityTests
Assert.Null(msg!.ErrorMessage); // Optional field defaults to null
}
/// <summary>Verifies that an unknown enum value is handled gracefully during deserialization.</summary>
[Fact]
public void EnumDeserialization_UnknownValue_HandledGracefully()
{
@@ -381,8 +401,9 @@ public class CompatibilityTests
Assert.Equal((DeploymentStatus)99, msg!.Status);
}
// ── DeploymentManager-006: query-the-site-before-redeploy contracts ──
// ── query-the-site-before-redeploy contracts ──
/// <summary>Verifies that a deployment-state query request survives a serialization round trip.</summary>
[Fact]
public void RoundTrip_DeploymentStateQueryRequest_Succeeds()
{
@@ -395,6 +416,7 @@ public class CompatibilityTests
Assert.Equal("inst-1", deserialized.InstanceUniqueName);
}
/// <summary>Verifies that a deployed deployment-state query response survives a serialization round trip.</summary>
[Fact]
public void RoundTrip_DeploymentStateQueryResponse_Deployed_Succeeds()
{
@@ -409,6 +431,7 @@ public class CompatibilityTests
Assert.Equal("sha256:abc", deserialized.AppliedRevisionHash);
}
/// <summary>Verifies that a not-deployed deployment-state query response keeps its null applied state across a round trip.</summary>
[Fact]
public void RoundTrip_DeploymentStateQueryResponse_NotDeployed_NullApplied()
{
@@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Regression tests for Commons-008 — <see cref="SetConnectionBindingsCommand"/>
/// Regression tests: <see cref="SetConnectionBindingsCommand"/>
/// previously declared its bindings as <c>IReadOnlyList&lt;(string, int)&gt;</c>.
/// A <c>ValueTuple</c> serializes as <c>Item1</c>/<c>Item2</c> and cannot evolve
/// additively (REQ-COM-5a). It is now a named <see cref="ConnectionBinding"/> record.
/// </summary>
public class ConnectionBindingSerializationTests
{
/// <summary>Verifies that connection binding serializes with named properties.</summary>
[Fact]
public void ConnectionBinding_SerializesWithNamedProperties()
{
@@ -26,6 +27,7 @@ public class ConnectionBindingSerializationTests
Assert.False(doc.RootElement.TryGetProperty("Item2", out _));
}
/// <summary>Verifies that set connection bindings command round trips through json.</summary>
[Fact]
public void SetConnectionBindingsCommand_RoundTripsThroughJson()
{
@@ -52,6 +54,7 @@ public class ConnectionBindingSerializationTests
Assert.Equal(original.Bindings, deserialized.Bindings);
}
/// <summary>Verifies that roundtrip preserves override when set.</summary>
[Fact]
public void Roundtrip_preserves_override_when_set()
{
@@ -65,6 +68,7 @@ public class ConnectionBindingSerializationTests
Assert.Equal("ns=2;s=Pump1.Speed", roundtripped!.DataSourceReferenceOverride);
}
/// <summary>Verifies that roundtrip defaults override to null when absent.</summary>
[Fact]
public void Roundtrip_defaults_override_to_null_when_absent()
{
@@ -6,12 +6,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
/// <summary>
/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs.
/// Audit Log telemetry handoff: envelope + pull request/response DTOs.
/// At-least-once from sites; idempotent at central on <see cref="AuditEvent.EventId"/>.
/// </summary>
public class AuditTelemetryMessagesTests
{
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent built via the shared
// Canonical ZB.MOM.WW.Audit.AuditEvent built via the shared
// factory (domain fields ride in DetailsJson). These tests assert only the
// envelope/response DTO behaviour, so the audit row's contents are incidental.
private static AuditEvent MakeEvent(Guid? id = null) =>
@@ -21,6 +21,7 @@ public class AuditTelemetryMessagesTests
status: AuditStatus.Delivered,
eventId: id ?? Guid.NewGuid());
/// <summary>Verifies that a telemetry envelope constructed with three events can be enumerated.</summary>
[Fact]
public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable()
{
@@ -42,6 +43,7 @@ public class AuditTelemetryMessagesTests
Assert.Equal(3, collected.Count);
}
/// <summary>Verifies that the telemetry envelope is immutable and compares its event list by reference identity.</summary>
[Fact]
public void AuditTelemetryEnvelope_IsImmutable_RecordEqualityOnReferenceIdentityOfList()
{
@@ -62,6 +64,7 @@ public class AuditTelemetryMessagesTests
Assert.Equal("site-01", a.SourceSiteId);
}
/// <summary>Verifies that a pull request can be constructed and is immutable.</summary>
[Fact]
public void PullAuditEventsRequest_ConstructsAndIsImmutable()
{
@@ -77,6 +80,7 @@ public class AuditTelemetryMessagesTests
Assert.Equal(500, bigger.BatchSize);
}
/// <summary>Verifies that a pull response with more-available set can be constructed and enumerated.</summary>
[Fact]
public void PullAuditEventsResponse_ConstructsWithMoreAvailableTrue_AndIsEnumerable()
{
@@ -94,6 +98,7 @@ public class AuditTelemetryMessagesTests
Assert.Equal(2, collected.Count);
}
/// <summary>Verifies that a with-expression on a pull response changes only the targeted field.</summary>
[Fact]
public void PullAuditEventsResponse_WithExpression_ChangesSingleField()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
/// <summary>
/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined
/// Tests for the combined
/// audit + operational telemetry packet emitted per cached-call lifecycle event
/// (<c>Submit</c> → per-attempt <c>ApiCallCached</c> / <c>DbWriteCached</c> →
/// terminal <c>Resolve</c>). The site emits one packet per event; central writes
@@ -29,7 +29,7 @@ public class CachedCallTelemetryTests
string? errorMessage = null,
int? httpStatus = null)
{
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the
// Build the canonical ZB.MOM.WW.Audit.AuditEvent via the
// shared factory; the ScadaBridge domain fields ride in DetailsJson and
// are read back as typed properties via AsRow() in the assertions.
// ForwardState is no longer a packet field — it is a site-storage-only
@@ -61,7 +61,7 @@ public class CachedCallTelemetryTests
Channel: nameof(AuditChannel.ApiOutbound),
Target: "ERP.GetOrder",
SourceSite: SiteId,
// SourceNode: actual stamping arrives with Task 14; for now the
// SourceNode: actual stamping is not done here; the
// packet builder leaves the column null so existing assertions on
// the packet's other fields stay intact.
SourceNode: null,
@@ -74,6 +74,7 @@ public class CachedCallTelemetryTests
TerminalAtUtc: terminalAtUtc);
}
/// <summary>Verifies that a submit packet carries a CachedSubmit audit row and an operational retry count of zero.</summary>
[Fact]
public void SubmitPacket_AuditCarriesCachedSubmit_AndOperationalRetryCountZero()
{
@@ -91,6 +92,7 @@ public class CachedCallTelemetryTests
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
}
/// <summary>Verifies that an attempted packet carries an ApiCallCached audit row whose retry count matches the operational record.</summary>
[Fact]
public void AttemptedPacket_AuditCarriesApiCallCached_RetryCountAlignsBetweenAuditAndOperational()
{
@@ -121,6 +123,7 @@ public class CachedCallTelemetryTests
Assert.Null(packet.Operational.TerminalAtUtc);
}
/// <summary>Verifies that an attempted database-write packet carries the DbWriteCached kind.</summary>
[Fact]
public void AttemptedPacket_DbWriteCached_CarriesDbWriteCachedKind()
{
@@ -144,6 +147,8 @@ public class CachedCallTelemetryTests
Assert.Equal(1, packet.Operational.RetryCount);
}
/// <summary>Verifies that a resolve packet carries a CachedResolve audit row and sets the operational terminal timestamp.</summary>
/// <param name="terminalStatus">The terminal audit status being resolved.</param>
[Theory]
[InlineData(AuditStatus.Delivered)]
[InlineData(AuditStatus.Failed)]
@@ -169,6 +174,7 @@ public class CachedCallTelemetryTests
Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc);
}
/// <summary>Verifies that a cached-call telemetry packet is equal to a copy built from the same values.</summary>
[Fact]
public void CachedCallTelemetry_RoundTripEquality()
{
@@ -190,6 +196,7 @@ public class CachedCallTelemetryTests
Assert.Equal(1, c.Operational.RetryCount);
}
/// <summary>Verifies that a site-call operational record supports value equality and non-destructive copying with a with-expression.</summary>
[Fact]
public void SiteCallOperational_RoundTripEquality_AndWithExpression()
{
@@ -4,8 +4,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Tests for <see cref="ManagementCommandRegistry"/>, including the Commons-004
/// regression: <c>GetCommandName</c> and <c>Resolve</c> must be symmetric — every
/// Tests for <see cref="ManagementCommandRegistry"/>, including the
/// regression where <c>GetCommandName</c> and <c>Resolve</c> must be symmetric — every
/// type for which <c>GetCommandName</c> yields a name must round-trip back to the
/// same type via <c>Resolve</c>.
/// </summary>
@@ -19,6 +19,7 @@ public class ManagementCommandRegistryTests
&& t != typeof(ReadTagValuesCommand)
&& t != typeof(ResolveRolesCommand));
/// <summary>Verifies that every registered command type round-trips through <c>GetCommandName</c> and <c>Resolve</c>.</summary>
[Fact]
public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand()
{
@@ -30,6 +31,7 @@ public class ManagementCommandRegistryTests
}
}
/// <summary>Verifies that resolving a known command name returns its type.</summary>
[Fact]
public void Resolve_KnownCommand_ReturnsType()
{
@@ -37,12 +39,14 @@ public class ManagementCommandRegistryTests
Assert.Equal(typeof(CreateSiteCommand), type);
}
/// <summary>Verifies that resolving an unknown command name returns null.</summary>
[Fact]
public void Resolve_UnknownCommand_ReturnsNull()
{
Assert.Null(ManagementCommandRegistry.Resolve("NoSuchCommand"));
}
/// <summary>Verifies that resolving a command name is case-insensitive.</summary>
[Fact]
public void Resolve_IsCaseInsensitive()
{
@@ -50,7 +54,7 @@ public class ManagementCommandRegistryTests
}
/// <summary>
/// Commons-004: <c>GetCommandName</c> previously stripped a <c>Command</c> suffix
/// <c>GetCommandName</c> previously stripped a <c>Command</c> suffix
/// from <em>any</em> type, producing names the registry cannot resolve. It must
/// only return a name for a command type the registry actually contains.
/// </summary>
@@ -62,6 +66,7 @@ public class ManagementCommandRegistryTests
() => ManagementCommandRegistry.GetCommandName(typeof(UnregisteredFakeCommand)));
}
/// <summary>Verifies that <c>GetCommandName</c> throws for a type that is not a registered command.</summary>
[Fact]
public void GetCommandName_NonCommandType_Throws()
{
@@ -69,12 +74,14 @@ public class ManagementCommandRegistryTests
() => ManagementCommandRegistry.GetCommandName(typeof(string)));
}
/// <summary>Verifies that <c>GetCommandName</c> returns the command name with the <c>Command</c> suffix stripped.</summary>
[Fact]
public void GetCommandName_RegisteredCommand_ReturnsStrippedName()
{
Assert.Equal("CreateSite", ManagementCommandRegistry.GetCommandName(typeof(CreateSiteCommand)));
}
/// <summary>Verifies that all native alarm source commands are registered.</summary>
[Fact]
public void Resolve_NativeAlarmSourceCommands_AllRegistered()
{
@@ -95,7 +102,7 @@ public class ManagementCommandRegistryTests
}
/// <summary>
/// M9-T32c: the schema-library CRUD commands must auto-register by reflection
/// The schema-library CRUD commands must auto-register by reflection
/// (no manual registry entry) so the HTTP management boundary can route them.
/// </summary>
[Fact]
@@ -17,6 +17,7 @@ public class MessageConventionTests
&& !t.Name.StartsWith("<") // exclude compiler-generated types
&& (t.IsClass || (t.IsValueType && !t.IsPrimitive)));
/// <summary>Verifies that every message type is a record.</summary>
[Fact]
public void AllMessageTypes_ShouldBeRecords()
{
@@ -29,6 +30,7 @@ public class MessageConventionTests
}
}
/// <summary>Verifies that every message timestamp property is a <c>DateTimeOffset</c>.</summary>
[Fact]
public void AllMessageTimestampProperties_ShouldBeDateTimeOffset()
{
@@ -46,6 +48,7 @@ public class MessageConventionTests
}
}
/// <summary>Verifies that a deploy-instance command survives a JSON round trip.</summary>
[Fact]
public void JsonRoundTrip_DeployInstanceCommand_ShouldSucceed()
{
@@ -60,6 +63,7 @@ public class MessageConventionTests
Assert.Equal(msg.InstanceUniqueName, deserialized.InstanceUniqueName);
}
/// <summary>Verifies that JSON containing an unknown field still deserializes.</summary>
[Fact]
public void JsonForwardCompatibility_UnknownField_ShouldDeserialize()
{
@@ -72,6 +76,7 @@ public class MessageConventionTests
Assert.Equal("dep-1", deserialized!.DeploymentId);
}
/// <summary>Verifies that JSON missing an optional field still deserializes.</summary>
[Fact]
public void JsonBackwardCompatibility_MissingOptionalField_ShouldDeserialize()
{
@@ -85,6 +90,7 @@ public class MessageConventionTests
Assert.Null(deserialized.ErrorMessage);
}
/// <summary>Verifies that a site health report survives a JSON round trip.</summary>
[Fact]
public void JsonRoundTrip_SiteHealthReport_ShouldSucceed()
{
@@ -110,6 +116,7 @@ public class MessageConventionTests
Assert.Equal(1, deserialized.SequenceNumber);
}
/// <summary>Verifies that a deploy-artifacts command survives a JSON round trip.</summary>
[Fact]
public void JsonRoundTrip_DeployArtifactsCommand_ShouldSucceed()
{
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
public class NativeAlarmMessagesTests
{
/// <summary>Verifies that subscribe alarms request: carries source and filter.</summary>
[Fact]
public void SubscribeAlarmsRequest_CarriesSourceAndFilter()
{
@@ -14,6 +15,7 @@ public class NativeAlarmMessagesTests
Assert.Null(r.ConditionFilter);
}
/// <summary>Verifies that native alarm transition update: wraps transition.</summary>
[Fact]
public void NativeAlarmTransitionUpdate_WrapsTransition()
{
@@ -25,6 +27,7 @@ public class NativeAlarmMessagesTests
Assert.Equal("Tank01", u.Transition.SourceObjectReference);
}
/// <summary>Verifies that native alarm transition, when ack time, is an additive trailing parameter.</summary>
[Fact]
public void NativeAlarmTransition_AckTime_IsAnAdditiveTrailingParameter()
{
@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// </summary>
public class NotificationMessagesTests
{
// ── Task 7: site/central notification message contracts ──
// ── Site/central notification message contracts ──
/// <summary>Verifies that positional construction of a notification submit sets all fields.</summary>
[Fact]
public void NotificationSubmit_PositionalConstruction_SetsAllFields()
{
@@ -29,6 +30,7 @@ public class NotificationMessagesTests
Assert.Equal(enqueuedAt, msg.SiteEnqueuedAt);
}
/// <summary>Verifies that a notification submit allows null optional source fields.</summary>
[Fact]
public void NotificationSubmit_AllowsNullOptionalSourceFields()
{
@@ -40,10 +42,11 @@ public class NotificationMessagesTests
Assert.Null(msg.SourceScript);
}
/// <summary>Verifies that the origin execution id of a notification submit defaults to null.</summary>
[Fact]
public void NotificationSubmit_OriginExecutionId_DefaultsToNull()
{
// Audit Log #23: OriginExecutionId is an additive trailing member — a
// OriginExecutionId is an additive trailing member — a
// submit built without it (old call sites / old serialized payloads)
// leaves the id null.
var msg = new NotificationSubmit(
@@ -53,6 +56,7 @@ public class NotificationMessagesTests
Assert.Null(msg.OriginExecutionId);
}
/// <summary>Verifies that the origin execution id round-trips when supplied.</summary>
[Fact]
public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied()
{
@@ -64,6 +68,7 @@ public class NotificationMessagesTests
Assert.Equal(executionId, msg.OriginExecutionId);
}
/// <summary>Verifies that the origin execution id survives a JSON round trip.</summary>
[Fact]
public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip()
{
@@ -81,6 +86,7 @@ public class NotificationMessagesTests
Assert.Equal(executionId, roundTripped!.OriginExecutionId);
}
/// <summary>Verifies that the origin parent execution id defaults to null.</summary>
[Fact]
public void NotificationSubmit_OriginParentExecutionId_DefaultsToNull()
{
@@ -94,6 +100,7 @@ public class NotificationMessagesTests
Assert.Null(msg.OriginParentExecutionId);
}
/// <summary>Verifies that the origin parent execution id round-trips when supplied.</summary>
[Fact]
public void NotificationSubmit_OriginParentExecutionId_RoundTripsWhenSupplied()
{
@@ -107,6 +114,7 @@ public class NotificationMessagesTests
Assert.Equal(parentExecutionId, msg.OriginParentExecutionId);
}
/// <summary>Verifies that the origin parent execution id survives a JSON round trip.</summary>
[Fact]
public void NotificationSubmit_OriginParentExecutionId_SurvivesJsonRoundTrip()
{
@@ -126,6 +134,7 @@ public class NotificationMessagesTests
Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId);
}
/// <summary>Verifies that a notification submit carries the source node.</summary>
[Fact]
public void NotificationSubmit_carries_SourceNode()
{
@@ -152,6 +161,7 @@ public class NotificationMessagesTests
Assert.Equal("node-a", roundTripped!.SourceNode);
}
/// <summary>Verifies that two notification submits are equal when all fields match.</summary>
[Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{
@@ -163,6 +173,7 @@ public class NotificationMessagesTests
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
/// <summary>Verifies that a with-expression on a notification submit ack changes a single field.</summary>
[Fact]
public void NotificationSubmitAck_WithExpression_ChangesSingleField()
{
@@ -176,6 +187,7 @@ public class NotificationMessagesTests
Assert.NotEqual(ack, rejected);
}
/// <summary>Verifies that positional construction of a notification status query sets all fields.</summary>
[Fact]
public void NotificationStatusQuery_PositionalConstruction_SetsAllFields()
{
@@ -185,6 +197,7 @@ public class NotificationMessagesTests
Assert.Equal("notif-9", msg.NotificationId);
}
/// <summary>Verifies that positional construction of a notification status response sets all fields.</summary>
[Fact]
public void NotificationStatusResponse_PositionalConstruction_SetsAllFields()
{
@@ -200,8 +213,9 @@ public class NotificationMessagesTests
Assert.Equal(deliveredAt, msg.DeliveredAt);
}
// ── Task 8: outbox UI query/action contracts ──
// ── Outbox UI query/action contracts ──
/// <summary>Verifies that positional construction of a notification outbox query request sets all fields.</summary>
[Fact]
public void NotificationOutboxQueryRequest_PositionalConstruction_SetsAllFields()
{
@@ -224,6 +238,7 @@ public class NotificationMessagesTests
Assert.Equal(50, msg.PageSize);
}
/// <summary>Verifies that two notification summaries are equal when all fields match.</summary>
[Fact]
public void NotificationSummary_ValueEquality_EqualWhenAllFieldsMatch()
{
@@ -239,6 +254,7 @@ public class NotificationMessagesTests
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
/// <summary>Verifies that positional construction of a notification outbox query response sets all fields.</summary>
[Fact]
public void NotificationOutboxQueryResponse_PositionalConstruction_SetsAllFields()
{
@@ -255,6 +271,7 @@ public class NotificationMessagesTests
Assert.Equal(1, msg.TotalCount);
}
/// <summary>Verifies that the retry notification request and response round-trip their fields.</summary>
[Fact]
public void RetryNotificationRequestAndResponse_RoundTripFields()
{
@@ -267,6 +284,7 @@ public class NotificationMessagesTests
Assert.Null(response.ErrorMessage);
}
/// <summary>Verifies that the discard notification request and response round-trip their fields.</summary>
[Fact]
public void DiscardNotificationRequestAndResponse_RoundTripFields()
{
@@ -278,6 +296,7 @@ public class NotificationMessagesTests
Assert.Equal("not found", response.ErrorMessage);
}
/// <summary>Verifies that a with-expression on a notification KPI response changes a single field.</summary>
[Fact]
public void NotificationKpiResponse_WithExpression_ChangesSingleField()
{
@@ -294,6 +313,7 @@ public class NotificationMessagesTests
Assert.NotEqual(kpi, updated);
}
/// <summary>Verifies that positional construction of a notification KPI request sets the correlation id.</summary>
[Fact]
public void NotificationKpiRequest_PositionalConstruction_SetsCorrelationId()
{
@@ -301,6 +321,7 @@ public class NotificationMessagesTests
Assert.Equal("corr-1", msg.CorrelationId);
}
/// <summary>Verifies that the per-site notification KPI request carries the correlation id.</summary>
[Fact]
public void PerSiteNotificationKpiRequest_CarriesCorrelationId()
{
@@ -308,6 +329,7 @@ public class NotificationMessagesTests
Assert.Equal("corr-1", request.CorrelationId);
}
/// <summary>Verifies that the per-site notification KPI response carries the per-site snapshots.</summary>
[Fact]
public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots()
{
@@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
public class ReadTagValuesCommandRegistryTests
{
/// <summary>Verifies that site-only and retired commands are not registered as HTTP commands.</summary>
/// <param name="type">The command message type.</param>
/// <param name="wireName">The command wire name.</param>
[Theory]
[InlineData(typeof(ReadTagValuesCommand), "ReadTagValues")]
[InlineData(typeof(ResolveRolesCommand), "ResolveRoles")]
@@ -4,13 +4,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Site Call Audit (#22): construction, value-equality and optionality tests
/// Site Call Audit: construction, value-equality and optionality tests
/// for the Site Calls UI query / KPI / detail message contracts. Mirrors the
/// Notification Outbox <c>NotificationMessagesTests</c> coverage of the read
/// side, scoped to the contracts the Site Calls page consumes.
/// </summary>
public class SiteCallQueriesTests
{
/// <summary>Verifies that positional construction of a site call query request sets all fields.</summary>
[Fact]
public void SiteCallQueryRequest_PositionalConstruction_SetsAllFields()
{
@@ -33,6 +34,7 @@ public class SiteCallQueriesTests
Assert.Equal(50, request.PageSize);
}
/// <summary>Verifies that a site call query request allows its optional filters to be null.</summary>
[Fact]
public void SiteCallQueryRequest_AllowsNullOptionalFilters()
{
@@ -48,6 +50,7 @@ public class SiteCallQueriesTests
Assert.Null(request.AfterId);
}
/// <summary>Verifies that two site call query responses are equal when all their fields match.</summary>
[Fact]
public void SiteCallQueryResponse_ValueEquality_EqualWhenAllFieldsMatch()
{
@@ -58,6 +61,7 @@ public class SiteCallQueriesTests
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
/// <summary>Verifies that a site call summary carries the entity columns and the stuck flag.</summary>
[Fact]
public void SiteCallSummary_CarriesEntityColumnsAndStuckFlag()
{
@@ -77,6 +81,7 @@ public class SiteCallQueriesTests
Assert.True(summary.IsStuck);
}
/// <summary>Verifies that a detail response for a missing row has a null detail.</summary>
[Fact]
public void SiteCallDetailResponse_MissingRow_HasNullDetail()
{
@@ -87,6 +92,7 @@ public class SiteCallQueriesTests
Assert.Equal("site call not found", response.ErrorMessage);
}
/// <summary>Verifies that a failed KPI response zeroes its KPI fields.</summary>
[Fact]
public void SiteCallKpiResponse_FailureShape_ZeroesKpiFields()
{
@@ -101,6 +107,7 @@ public class SiteCallQueriesTests
Assert.Null(response.OldestPendingAge);
}
/// <summary>Verifies that the per-site KPI response carries a snapshot for each site.</summary>
[Fact]
public void PerSiteSiteCallKpiResponse_CarriesPerSiteSnapshots()
{
@@ -119,6 +126,7 @@ public class SiteCallQueriesTests
Assert.Equal(TimeSpan.FromMinutes(15), site.OldestPendingAge);
}
/// <summary>Verifies that the oldest pending age of a KPI snapshot can be null for an empty table.</summary>
[Fact]
public void SiteCallKpiSnapshot_OldestPendingAge_IsNullableForEmptyTable()
{
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// </summary>
public class TraceFlagDefaultsTests
{
/// <summary>Verifies that existing positional constructions default to untraced.</summary>
[Fact]
public void ExistingPositionalConstructions_DefaultToUntraced()
{
@@ -31,6 +32,7 @@ public class TraceFlagDefaultsTests
Assert.Null(result.TraceEvents);
}
/// <summary>Verifies that trace events are audit events.</summary>
[Fact]
public void TraceEvents_AreAuditEvents()
{
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests;
public class NativeAlarmSourceOverrideCsvParserTests
{
/// <summary>Verifies that a simple file parses into rows with no errors.</summary>
[Fact]
public void Parse_SimpleFile_ReturnsRowsNoErrors()
{
@@ -30,6 +31,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Equal(3, result.Rows[1].LineNumber);
}
/// <summary>Verifies that blank override fields become null so the inherited value applies.</summary>
[Fact]
public void Parse_BlankOverrideFields_BecomeNullInherited()
{
@@ -47,6 +49,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Null(row.Filter);
}
/// <summary>Verifies that header names are matched case-insensitively.</summary>
[Fact]
public void Parse_HeaderCaseInsensitive_Accepted()
{
@@ -60,6 +63,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Single(result.Rows);
}
/// <summary>Verifies that a quoted field preserves an embedded comma.</summary>
[Fact]
public void Parse_QuotedFieldWithComma_PreservesEmbeddedComma()
{
@@ -74,6 +78,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Equal("ns=2;s=a,b,c", row.SourceReference);
}
/// <summary>Verifies that a missing header returns a header error.</summary>
[Fact]
public void Parse_MissingHeader_ReturnsHeaderError()
{
@@ -86,6 +91,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Contains("SourceName,Connection,SourceReference,Filter", error);
}
/// <summary>Verifies that a row with the wrong column count reports a per-line error and is skipped.</summary>
[Fact]
public void Parse_WrongColumnCount_ReportsPerLineErrorAndSkipsRow()
{
@@ -104,6 +110,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Equal("Temp", row.SourceName);
}
/// <summary>Verifies that a blank source name reports an error.</summary>
[Fact]
public void Parse_BlankSourceName_ReportsError()
{
@@ -118,6 +125,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Contains("SourceName must not be blank", error);
}
/// <summary>Verifies that an unterminated quote reports an error.</summary>
[Fact]
public void Parse_UnterminatedQuote_ReportsError()
{
@@ -131,6 +139,7 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Contains(result.Errors, e => e.Contains("Unterminated quoted field"));
}
/// <summary>Verifies that blank lines are skipped without an error.</summary>
[Fact]
public void Parse_BlankLinesSkippedWithoutError()
{
@@ -23,6 +23,7 @@ public sealed class DataConnectionGaugeTests
private const string ConnectedGauge = "scadabridge.site.data_connections.connected";
private const string TotalGauge = "scadabridge.site.data_connections.total";
/// <summary>Verifies that the connected state raises both the connected and total gauges.</summary>
[Fact]
public void ConnectedState_RaisesConnectedAndTotal()
{
@@ -43,6 +44,8 @@ public sealed class DataConnectionGaugeTests
}
}
/// <summary>Verifies that a non-connected state raises only the total gauge.</summary>
/// <param name="state">The state.</param>
[Theory]
[InlineData(ConnectionHealth.Disconnected)]
[InlineData(ConnectionHealth.Connecting)]
@@ -70,6 +73,7 @@ public sealed class DataConnectionGaugeTests
}
}
/// <summary>Verifies that a transition from connected to disconnected drops the connected gauge but keeps the total.</summary>
[Fact]
public void TransitionFromConnectedToDisconnected_DropsConnectedButKeepsTotal()
{
@@ -94,6 +98,7 @@ public sealed class DataConnectionGaugeTests
}
}
/// <summary>Verifies that removing a data connection drops it from both gauges.</summary>
[Fact]
public void RemoveDataConnection_DropsItFromBothGauges()
{
@@ -111,6 +116,7 @@ public sealed class DataConnectionGaugeTests
Assert.Equal(totalBefore, ReadGauge(TotalGauge));
}
/// <summary>Verifies that removing a data connection is idempotent.</summary>
[Fact]
public void RemoveDataConnection_IsIdempotent()
{
@@ -123,6 +129,7 @@ public sealed class DataConnectionGaugeTests
Assert.Null(ex);
}
/// <summary>Verifies that a null or empty connection name is ignored.</summary>
[Fact]
public void NullOrEmptyConnectionName_IsIgnored()
{
@@ -136,6 +143,7 @@ public sealed class DataConnectionGaugeTests
Assert.Equal(totalBefore, ReadGauge(TotalGauge));
}
/// <summary>Verifies that the stream subscriber gauge is published under its renamed name.</summary>
[Fact]
public void StreamSubscriberGauge_IsPublishedUnderTheRenamedName()
{
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests;
public class OverrideCsvParserTests
{
/// <summary>Verifies that a simple three-column file parses into two rows with no errors.</summary>
[Fact]
public void Parse_SimpleThreeColumnFile_ReturnsTwoRowsNoErrors()
{
@@ -25,6 +26,7 @@ public class OverrideCsvParserTests
Assert.Equal(3, result.Rows[1].LineNumber);
}
/// <summary>Verifies that rows from a two-column file without an element type have a null element type.</summary>
[Fact]
public void Parse_TwoColumnFileWithoutElementType_RowsHaveNullElementType()
{
@@ -39,6 +41,7 @@ public class OverrideCsvParserTests
Assert.Equal("Pump A", result.Rows[1].Value);
}
/// <summary>Verifies that a quoted value containing a comma keeps the embedded comma.</summary>
[Fact]
public void Parse_QuotedValueWithComma_PreservesEmbeddedComma()
{
@@ -53,6 +56,7 @@ public class OverrideCsvParserTests
Assert.Null(row.ElementType);
}
/// <summary>Verifies that a doubled quote escape is unescaped to a single quote.</summary>
[Fact]
public void Parse_DoubledQuoteEscape_UnescapesToSingleQuote()
{
@@ -65,6 +69,7 @@ public class OverrideCsvParserTests
Assert.Equal("he said \"hi\"", row.Value);
}
/// <summary>Verifies that an empty value field yields a null value.</summary>
[Fact]
public void Parse_EmptyValueField_YieldsNullValue()
{
@@ -79,6 +84,7 @@ public class OverrideCsvParserTests
Assert.Null(row.ElementType);
}
/// <summary>Verifies that a row with too few columns produces a line-numbered error and is excluded from the result.</summary>
[Fact]
public void Parse_RowWithTooFewColumns_ProducesLineNumberedErrorAndExcludesRow()
{
@@ -95,6 +101,7 @@ public class OverrideCsvParserTests
Assert.Contains("2", error);
}
/// <summary>Verifies that a blank attribute name produces a line-numbered error.</summary>
[Fact]
public void Parse_BlankAttributeName_ProducesLineNumberedError()
{
@@ -107,6 +114,7 @@ public class OverrideCsvParserTests
Assert.Contains("2", error);
}
/// <summary>Verifies that blank lines are skipped without raising an error.</summary>
[Fact]
public void Parse_BlankLines_AreSkippedWithoutError()
{
@@ -121,6 +129,7 @@ public class OverrideCsvParserTests
Assert.Equal(5, result.Rows[1].LineNumber);
}
/// <summary>Verifies that a missing header returns zero rows and a header error.</summary>
[Fact]
public void Parse_MissingHeader_ReturnsZeroRowsAndHeaderError()
{
@@ -133,6 +142,7 @@ public class OverrideCsvParserTests
Assert.Contains("header", error, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that the header match is case-insensitive and trims surrounding whitespace.</summary>
[Fact]
public void Parse_HeaderIsCaseInsensitiveAndTrimsWhitespace()
{
@@ -147,6 +157,7 @@ public class OverrideCsvParserTests
Assert.Equal("Int32", row.ElementType);
}
/// <summary>Verifies that empty input returns a header error.</summary>
[Fact]
public void Parse_EmptyInput_ReturnsHeaderError()
{
@@ -156,6 +167,7 @@ public class OverrideCsvParserTests
Assert.Single(result.Errors);
}
/// <summary>Verifies that unquoted whitespace is trimmed while quoted whitespace is preserved.</summary>
[Fact]
public void Parse_UnquotedWhitespace_IsTrimmedButQuotedWhitespacePreserved()
{
@@ -170,6 +182,7 @@ public class OverrideCsvParserTests
Assert.Equal(" spaced ", result.Rows[1].Value);
}
/// <summary>Verifies that an unquoted quote character in the middle of a field is preserved literally.</summary>
[Fact]
public void Parse_MidFieldUnquotedQuote_IsPreservedAsLiteral()
{
@@ -186,6 +199,7 @@ public class OverrideCsvParserTests
Assert.Equal("Type", row.ElementType);
}
/// <summary>Verifies that an unterminated quoted field produces a line-numbered error and is excluded from the result.</summary>
[Fact]
public void Parse_UnterminatedQuotedField_ProducesLineNumberedErrorAndExcludesRow()
{
@@ -200,6 +214,7 @@ public class OverrideCsvParserTests
Assert.Contains("Unterminated", error, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a well-formed quoted field containing a comma still parses correctly.</summary>
[Fact]
public void Parse_WellFormedQuotedFieldWithComma_StillParses()
{
@@ -216,6 +231,7 @@ public class OverrideCsvParserTests
Assert.Equal("Type", row.ElementType);
}
/// <summary>Verifies that an empty quoted field yields a null value.</summary>
[Fact]
public void Parse_EmptyQuotedField_YieldsNullValue()
{
@@ -231,6 +247,7 @@ public class OverrideCsvParserTests
Assert.Equal("Type", row.ElementType);
}
/// <summary>Verifies that a quoted field followed by trailing whitespace after the closing quote is accepted.</summary>
[Fact]
public void Parse_QuotedFieldWithTrailingWhitespaceAfterClose_IsAccepted()
{
@@ -5,6 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Scripts;
/// <summary>Flow Debugger script entry (design 2026-09-08 §2): the monitored attribute of a ValueChange/Conditional trigger.</summary>
public class ScriptTriggerAttributeTests
{
/// <summary>Verifies the case: write Kinds, return The Monitored Attribute.</summary>
/// <param name="kind">The kind test input.</param>
/// <param name="config">The config test input.</param>
/// <param name="expected">The expected test input.</param>
[Theory]
[InlineData("ValueChange", """{"attributeName":"Level"}""", "Level")]
[InlineData("valuechange", """{"attribute":"Level"}""", "Level")]
@@ -17,6 +21,9 @@ public class ScriptTriggerAttributeTests
Assert.True(ScriptTriggerAttribute.IsWriteKind(kind));
}
/// <summary>Verifies the case: other Kinds, return False, even When The Config Names An Attribute.</summary>
/// <param name="kind">The kind test input.</param>
/// <param name="config">The config test input.</param>
[Theory]
[InlineData("Call", """{"attributeName":"Level"}""")]
[InlineData("Interval", """{"intervalMs":1000}""")]
@@ -30,6 +37,9 @@ public class ScriptTriggerAttributeTests
Assert.False(ScriptTriggerAttribute.IsWriteKind(kind));
}
/// <summary>Verifies the case: write Kind, without AReadable Attribute, returns False, but Is Still AWrite Kind.</summary>
/// <param name="kind">The kind test input.</param>
/// <param name="config">The config test input.</param>
[Theory]
[InlineData("ValueChange", null)]
[InlineData("ValueChange", "")]
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Alarms;
public class AlarmConditionStateTests
{
/// <summary>Verifies that a default alarm condition state is normal and unshelved.</summary>
[Fact]
public void AlarmConditionState_DefaultsAreNormalUnshelved()
{
@@ -17,6 +18,7 @@ public class AlarmConditionStateTests
Assert.Equal(0, s.Severity);
}
/// <summary>Verifies that a native alarm transition carries its source and condition.</summary>
[Fact]
public void NativeAlarmTransition_CarriesSourceAndCondition()
{
@@ -12,15 +12,18 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// </summary>
public class AttributeValueCodecTests
{
/// <summary>Verifies that a string list encodes as a JSON array.</summary>
[Fact]
public void Encode_StringList_ProducesJsonArray() =>
Assert.Equal("[\"WO-1\",\"WO-2\"]",
AttributeValueCodec.Encode(new List<string> { "WO-1", "WO-2" }));
/// <summary>Verifies that a scalar string is encoded unchanged.</summary>
[Fact]
public void Encode_Scalar_String_ReturnedAsIs() =>
Assert.Equal("hello", AttributeValueCodec.Encode("hello"));
/// <summary>Verifies that a scalar double is encoded using the invariant culture.</summary>
[Fact]
public void Encode_Scalar_Double_IsInvariant()
{
@@ -36,19 +39,23 @@ public class AttributeValueCodecTests
}
}
/// <summary>Verifies that encoding null returns null.</summary>
[Fact]
public void Encode_Null_ReturnsNull() =>
Assert.Null(AttributeValueCodec.Encode(null));
/// <summary>Verifies that an empty list encodes as an empty bracket pair.</summary>
[Fact]
public void Encode_EmptyList_IsBracketPair() =>
Assert.Equal("[]", AttributeValueCodec.Encode(new List<string>()));
/// <summary>Verifies that a string containing a comma is escaped inside the encoded array.</summary>
[Fact]
public void Encode_StringWithComma_IsEscaped() =>
Assert.Equal("[\"ACME, Inc.\"]",
AttributeValueCodec.Encode(new List<string> { "ACME, Inc." }));
/// <summary>Verifies that a float list survives an encode and decode round trip.</summary>
[Fact]
public void RoundTrip_FloatList()
{
@@ -57,6 +64,7 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { 1.5f, 2.25f, -3.75f }, back);
}
/// <summary>Verifies that a double list is encoded using the invariant culture.</summary>
[Fact]
public void Encode_DoubleList_IsInvariant()
{
@@ -73,21 +81,25 @@ public class AttributeValueCodecTests
}
}
/// <summary>Verifies that an integer list encodes as native JSON numbers.</summary>
[Fact]
public void Encode_Int32List_ProducesNativeNumbers() =>
Assert.Equal("[10,20,30]",
AttributeValueCodec.Encode(new List<int> { 10, 20, 30 }));
/// <summary>Verifies that a boolean list encodes as native JSON booleans.</summary>
[Fact]
public void Encode_BoolList_ProducesNativeBooleans() =>
Assert.Equal("[true,false]",
AttributeValueCodec.Encode(new List<bool> { true, false }));
/// <summary>Verifies that string elements remain quoted in the encoded array.</summary>
[Fact]
public void Encode_StringList_StaysQuoted() =>
Assert.Equal("[\"a\",\"b\"]",
AttributeValueCodec.Encode(new List<string> { "a", "b" }));
/// <summary>Verifies that a date-time list encodes its elements in ISO 8601 form.</summary>
[Fact]
public void Encode_DateTimeList_IsIso8601()
{
@@ -98,6 +110,7 @@ public class AttributeValueCodecTests
Assert.DoesNotContain("06/16/2026", json);
}
/// <summary>Verifies that the old string form of a float list still decodes.</summary>
[Fact]
public void Decode_OldStringFloatForm_BackwardCompatible()
{
@@ -105,6 +118,7 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { 1.5f, 2.25f }, back);
}
/// <summary>Verifies that the old string form of a date-time list still decodes.</summary>
[Fact]
public void Decode_OldStringDateTimeForm_BackwardCompatible()
{
@@ -115,6 +129,7 @@ public class AttributeValueCodecTests
Assert.Equal(new DateTime(2026, 6, 16, 12, 30, 45, DateTimeKind.Unspecified), back[0]);
}
/// <summary>Verifies that the native integer array form decodes correctly.</summary>
[Fact]
public void Decode_NewNativeIntForm_Parses()
{
@@ -122,6 +137,7 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { 10, 20 }, back);
}
/// <summary>Verifies that the old string form of an integer list still decodes.</summary>
[Fact]
public void Decode_OldStringIntForm_BackwardCompatible()
{
@@ -129,6 +145,8 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { 10, 20 }, back);
}
/// <summary>Verifies that both the native and the string boolean array forms decode.</summary>
/// <param name="json">The encoded boolean array to decode.</param>
[Theory]
[InlineData("[true,false]")]
[InlineData("[\"True\",\"False\"]")]
@@ -138,6 +156,7 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { true, false }, back);
}
/// <summary>Verifies that an integer list survives an encode and decode round trip.</summary>
[Fact]
public void RoundTrip_Int32List()
{
@@ -146,6 +165,7 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { 1, 2, 3 }, back);
}
/// <summary>Verifies that a double list round-trips regardless of the current culture.</summary>
[Fact]
public void RoundTrip_DoubleList_IsCultureInvariant()
{
@@ -163,6 +183,7 @@ public class AttributeValueCodecTests
}
}
/// <summary>Verifies that a boolean list survives an encode and decode round trip.</summary>
[Fact]
public void RoundTrip_BoolList()
{
@@ -171,6 +192,7 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { true, false, true }, back);
}
/// <summary>Verifies that a date-time list round-trips through its ISO 8601 form.</summary>
[Fact]
public void RoundTrip_DateTimeList_Iso8601()
{
@@ -181,6 +203,7 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { a, b }, back);
}
/// <summary>Verifies that a JSON string array decodes to a list of strings.</summary>
[Fact]
public void Decode_StringArray_ProducesListOfString()
{
@@ -188,38 +211,48 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { "a", "b" }, back);
}
/// <summary>Verifies that decoding a scalar value returns it as a string.</summary>
[Fact]
public void Decode_Scalar_ReturnsString() =>
Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null));
/// <summary>Verifies that decoding a null list value returns null.</summary>
[Fact]
public void Decode_List_NullValue_ReturnsNull() =>
Assert.Null(AttributeValueCodec.Decode(null, DataType.List, DataType.String));
/// <summary>Verifies that decoding an empty list value returns null.</summary>
[Fact]
public void Decode_List_EmptyValue_ReturnsNull() =>
Assert.Null(AttributeValueCodec.Decode("", DataType.List, DataType.String));
/// <summary>Verifies that malformed JSON throws when decoding a list.</summary>
[Fact]
public void Decode_MalformedJson_Throws() =>
Assert.Throws<FormatException>(() =>
AttributeValueCodec.Decode("not json", DataType.List, DataType.String));
/// <summary>Verifies that an element that cannot be parsed throws when decoding a list.</summary>
[Fact]
public void Decode_UnparseableElement_Throws() =>
Assert.Throws<FormatException>(() =>
AttributeValueCodec.Decode("[\"abc\"]", DataType.List, DataType.Int32));
/// <summary>Verifies that a null element throws when decoding a list.</summary>
[Fact]
public void Decode_NullElement_Throws() =>
Assert.Throws<FormatException>(() =>
AttributeValueCodec.Decode("[null]", DataType.List, DataType.String));
/// <summary>Verifies that decoding a list without an element type throws.</summary>
[Fact]
public void Decode_List_WithoutElementType_Throws() =>
Assert.Throws<FormatException>(() =>
AttributeValueCodec.Decode("[\"a\"]", DataType.List, null));
/// <summary>Verifies that only scalar data types are valid list element types.</summary>
/// <param name="t">The data type to check.</param>
/// <param name="expected">Whether the type is expected to be a valid element type.</param>
[Theory]
[InlineData(DataType.String, true)]
[InlineData(DataType.Int32, true)]
@@ -237,6 +270,9 @@ public class AttributeValueCodecTests
// string-input path in Decode (JSON elements): here elements arrive as boxed
// CLR values and are converted via Convert.ToXxx (or parsed when string).
/// <summary>Verifies that each scalar data type maps to its expected CLR type.</summary>
/// <param name="t">The scalar data type to map.</param>
/// <param name="expected">The expected CLR type.</param>
[Theory]
[InlineData(DataType.String, typeof(string))]
[InlineData(DataType.Int32, typeof(int))]
@@ -247,10 +283,12 @@ public class AttributeValueCodecTests
public void ElementClrType_MapsEachScalar(DataType t, Type expected) =>
Assert.Equal(expected, AttributeValueCodec.ElementClrType(t));
/// <summary>Verifies that a data type that is not a valid element type is rejected.</summary>
[Fact]
public void ElementClrType_RejectsNonElementType() =>
Assert.Throws<FormatException>(() => AttributeValueCodec.ElementClrType(DataType.List));
/// <summary>Verifies that coercing an enumerable builds a list of the requested element type.</summary>
[Fact]
public void CoerceEnumerable_BuildsTypedList()
{
@@ -259,16 +297,18 @@ public class AttributeValueCodecTests
Assert.Equal(new[] { 10, 20, 30 }, typed);
}
/// <summary>Verifies that elements of mixed CLR types are converted to the requested element type.</summary>
[Fact]
public void CoerceEnumerable_ConvertsMixedClrElementTypes()
{
// Ints and numeric strings both coerce to List<double> via Convert/parse —
// the object-input behavior InstanceActor's MV-8 coercion depends on.
// the object-input behavior the Instance Actor's coercion depends on.
var list = AttributeValueCodec.CoerceEnumerable(new object[] { 1, "2.5", 3.75 }, DataType.Double);
var typed = Assert.IsType<List<double>>(list);
Assert.Equal(new[] { 1.0, 2.5, 3.75 }, typed);
}
/// <summary>Verifies that date-time elements round-trip through the invariant culture during coercion.</summary>
[Fact]
public void CoerceEnumerable_DateTimeRoundtripsViaInvariantCulture()
{
@@ -279,6 +319,7 @@ public class AttributeValueCodecTests
Assert.Equal(when, typed[0]);
}
/// <summary>Verifies that coercion throws for an unsupported element type.</summary>
[Fact]
public void CoerceEnumerable_ThrowsOnUnsupportedElementType() =>
Assert.Throws<FormatException>(
@@ -40,6 +40,7 @@ public class AuditDetailsCodecTests
// Round-trip: all non-null fields survive
// ---------------------------------------------------------------------------
/// <summary>Verifies that details with every field populated survive a serialize and deserialize round trip.</summary>
[Fact]
public void RoundTrip_AllFields_Populated()
{
@@ -67,6 +68,7 @@ public class AuditDetailsCodecTests
// Round-trip: all-null record
// ---------------------------------------------------------------------------
/// <summary>Verifies that details whose optional fields are all null round trip to empty details.</summary>
[Fact]
public void RoundTrip_AllNullOptionals_ReturnsEmptyDetails()
{
@@ -97,6 +99,7 @@ public class AuditDetailsCodecTests
// Null / empty JSON deserialize → empty AuditDetails (no throw)
// ---------------------------------------------------------------------------
/// <summary>Verifies that deserializing null returns empty details.</summary>
[Fact]
public void Deserialize_Null_ReturnsEmpty()
{
@@ -105,6 +108,7 @@ public class AuditDetailsCodecTests
Assert.Null(d.Channel);
}
/// <summary>Verifies that deserializing an empty string returns empty details.</summary>
[Fact]
public void Deserialize_EmptyString_ReturnsEmpty()
{
@@ -117,6 +121,7 @@ public class AuditDetailsCodecTests
// Determinism: same input → byte-identical JSON across two calls
// ---------------------------------------------------------------------------
/// <summary>Verifies that serializing the same input repeatedly produces byte-identical output.</summary>
[Fact]
public void Serialize_SameInput_ProducesByteIdenticalOutput_AcrossCalls()
{
@@ -135,6 +140,7 @@ public class AuditDetailsCodecTests
// null fields omitted, PayloadTruncated=false present because it is non-nullable bool)
// ---------------------------------------------------------------------------
/// <summary>Verifies that serializing a known input produces the expected JSON.</summary>
[Fact]
public void Serialize_KnownInput_MatchesExpectedJson()
{
@@ -178,6 +184,7 @@ public class AuditDetailsCodecTests
// Redaction marker preserved verbatim (UnsafeRelaxedJsonEscaping must not mangle <>)
// ---------------------------------------------------------------------------
/// <summary>Verifies that a redaction marker is preserved verbatim through serialization.</summary>
[Fact]
public void Serialize_RedactionMarker_IsPreservedVerbatim()
{
@@ -12,6 +12,10 @@ public class AuditFieldBuildersTests
// BuildAction: "{channel}.{kind}"
// ---------------------------------------------------------------------------
/// <summary>Verifies that building an action yields the channel and kind joined by a dot.</summary>
/// <param name="channel">The audit channel.</param>
/// <param name="kind">The audit kind.</param>
/// <param name="expected">The expected action string.</param>
[Theory]
[InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCall, "ApiOutbound.ApiCall")]
[InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, "ApiOutbound.ApiCallCached")]
@@ -32,6 +36,9 @@ public class AuditFieldBuildersTests
// BuildCategory: channel.ToString()
// ---------------------------------------------------------------------------
/// <summary>Verifies that building a category yields the channel name.</summary>
/// <param name="channel">The audit channel.</param>
/// <param name="expected">The expected category string.</param>
[Theory]
[InlineData(AuditChannel.ApiOutbound, "ApiOutbound")]
[InlineData(AuditChannel.DbOutbound, "DbOutbound")]
@@ -14,6 +14,8 @@ public class AuditOutcomeProjectorTests
// InboundAuthFailure → Denied, regardless of status (checked FIRST)
// ---------------------------------------------------------------------------
/// <summary>Verifies that an inbound auth failure always projects to a denied outcome, whatever the status.</summary>
/// <param name="status">The audit status under test.</param>
[Theory]
[InlineData(AuditStatus.Submitted)]
[InlineData(AuditStatus.Forwarded)]
@@ -33,6 +35,8 @@ public class AuditOutcomeProjectorTests
// Delivered → Success (for all non-auth-failure kinds)
// ---------------------------------------------------------------------------
/// <summary>Verifies that a delivered status projects to success for every kind other than an auth failure.</summary>
/// <param name="kind">The audit kind under test.</param>
[Theory]
[InlineData(AuditKind.ApiCall)]
[InlineData(AuditKind.ApiCallCached)]
@@ -53,6 +57,8 @@ public class AuditOutcomeProjectorTests
// Failure statuses → Failure outcome
// ---------------------------------------------------------------------------
/// <summary>Verifies that failed, parked and discarded statuses project to a failure outcome.</summary>
/// <param name="status">The failure status under test.</param>
[Theory]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
@@ -65,6 +71,8 @@ public class AuditOutcomeProjectorTests
}
// InboundAuthFailure still wins over failure statuses
/// <summary>Verifies that an inbound auth failure projects to denied even when the status is a failure status.</summary>
/// <param name="status">The failure status under test.</param>
[Theory]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
@@ -79,6 +87,8 @@ public class AuditOutcomeProjectorTests
// In-progress / short-circuit statuses → Success
// ---------------------------------------------------------------------------
/// <summary>Verifies that in-progress and skipped statuses project to a success outcome.</summary>
/// <param name="status">The in-progress status under test.</param>
[Theory]
[InlineData(AuditStatus.Submitted)]
[InlineData(AuditStatus.Forwarded)]
@@ -94,6 +104,10 @@ public class AuditOutcomeProjectorTests
// Full status × non-auth-failure kind cross-product (spot-check each status once more)
// ---------------------------------------------------------------------------
/// <summary>Verifies that each status and kind pair projects to the outcome in the specification table.</summary>
/// <param name="status">The audit status under test.</param>
/// <param name="kind">The audit kind under test.</param>
/// <param name="expected">The expected projected outcome.</param>
[Theory]
[InlineData(AuditStatus.Submitted, AuditKind.DbWrite, AuditOutcome.Success)]
[InlineData(AuditStatus.Forwarded, AuditKind.CachedSubmit, AuditOutcome.Success)]
@@ -15,6 +15,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
/// </summary>
public class ScadaBridgeAuditEventFactoryActorTests
{
/// <summary>Verifies that system originated emit, preserves caller actor, verbatim.</summary>
/// <param name="systemActor">Test case input.</param>
[Theory]
// Mirrors the literal system actors the outbound emitters pass:
// NotificationOutboxActor → "system"; AuditingDbCommand → the source script.
@@ -33,6 +35,7 @@ public class ScadaBridgeAuditEventFactoryActorTests
Assert.Equal(systemActor, evt.Actor);
}
/// <summary>Verifies that authenticated emit, preserves caller actor, verbatim.</summary>
[Fact]
public void AuthenticatedEmit_PreservesCallerActor_Verbatim()
{
@@ -47,6 +50,7 @@ public class ScadaBridgeAuditEventFactoryActorTests
Assert.Equal("alice", evt.Actor);
}
/// <summary>Verifies that null actor, maps to empty string, on canonical record.</summary>
[Fact]
public void NullActor_MapsToEmptyString_OnCanonicalRecord()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Audit Log #23 (M8): tests for the shared multi-value query-param parsers used by
/// Tests for the shared multi-value query-param parsers used by
/// the ManagementService + CentralUI audit endpoints and the <c>AuditLogPage</c>
/// drill-in parser. Two contracts are under test:
/// <list type="bullet">
@@ -18,18 +18,21 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// </summary>
public class AuditQueryParamParsersTests
{
/// <summary>Verifies that parse enum list null input returns null.</summary>
[Fact]
public void ParseEnumList_NullInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null));
}
/// <summary>Verifies that parse enum list empty input returns null.</summary>
[Fact]
public void ParseEnumList_EmptyInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>()));
}
/// <summary>Verifies that parse enum list all values valid parses everything.</summary>
[Fact]
public void ParseEnumList_AllValuesValid_ParsesEverything()
{
@@ -38,6 +41,7 @@ public class AuditQueryParamParsersTests
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
}
/// <summary>Verifies that parse enum list is case insensitive.</summary>
[Fact]
public void ParseEnumList_IsCaseInsensitive()
{
@@ -45,6 +49,7 @@ public class AuditQueryParamParsersTests
Assert.Equal(new[] { AuditChannel.ApiOutbound }, result);
}
/// <summary>Verifies that parse enum list drops unparseable element keeps the rest.</summary>
[Fact]
public void ParseEnumList_DropsUnparseableElement_KeepsTheRest()
{
@@ -53,6 +58,7 @@ public class AuditQueryParamParsersTests
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result);
}
/// <summary>Verifies that parse enum list all values unparseable returns null.</summary>
[Fact]
public void ParseEnumList_AllValuesUnparseable_ReturnsNull()
{
@@ -65,18 +71,22 @@ public class AuditQueryParamParsersTests
// case-SENSITIVE AcceptOnlyFromAmong guard and still fail loudly on garbage.
// ========================================================================
/// <summary>Verifies that parse enum list strict null input returns null.</summary>
[Fact]
public void ParseEnumListStrict_NullInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(null, "status"));
}
/// <summary>Verifies that parse enum list strict empty input returns null.</summary>
[Fact]
public void ParseEnumListStrict_EmptyInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(Array.Empty<string?>(), "status"));
}
/// <summary>Verifies that parse enum list strict is case insensitive and trims.</summary>
/// <param name="raw">The raw value.</param>
[Theory]
[InlineData("delivered")]
[InlineData("DELIVERED")]
@@ -88,6 +98,7 @@ public class AuditQueryParamParsersTests
Assert.Equal(new[] { AuditStatus.Delivered }, result);
}
/// <summary>Verifies that parse enum list strict all values valid parses everything.</summary>
[Fact]
public void ParseEnumListStrict_AllValuesValid_ParsesEverything()
{
@@ -96,6 +107,7 @@ public class AuditQueryParamParsersTests
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
}
/// <summary>Verifies that parse enum list strict unrecognised value throws naming the valid set.</summary>
[Fact]
public void ParseEnumListStrict_UnrecognisedValue_ThrowsNamingTheValidSet()
{
@@ -113,6 +125,8 @@ public class AuditQueryParamParsersTests
}
}
/// <summary>Verifies that parse enum list strict numeric string is rejected.</summary>
/// <param name="raw">The raw value.</param>
[Theory]
// "7" is out of range; "3" lands on a REAL member. Enum.TryParse accepts BOTH —
// a filter is a name-based contract, never an ordinal one, so both are rejected.
@@ -128,6 +142,7 @@ public class AuditQueryParamParsersTests
Assert.Contains(raw, ex.Message);
}
/// <summary>Verifies that parse enum list strict blank elements are still dropped silently.</summary>
[Fact]
public void ParseEnumListStrict_BlankElements_AreStillDroppedSilently()
{
@@ -138,6 +153,7 @@ public class AuditQueryParamParsersTests
Assert.Equal(new[] { AuditStatus.Delivered }, result);
}
/// <summary>Verifies that parse enum list strict all blank returns null.</summary>
[Fact]
public void ParseEnumListStrict_AllBlank_ReturnsNull()
{
@@ -146,8 +162,7 @@ public class AuditQueryParamParsersTests
}
// ========================================================================
// Comma-joined single token (arch-review 2026-08-19 P6-3, register row 99
// closure defect): a bare Enum.TryParse splits and bitwise-ORs a comma list
// Comma-joined single token (arch-review 2026-08-19 closure defect): a bare Enum.TryParse splits and bitwise-ORs a comma list
// inside ONE token for ANY enum, not just [Flags] ones. AuditStatus is dense
// and 0-based, so the OR'd composite almost always collides with a REAL, but
// WRONG, member. Values arriving as separate tokens (repeated query param /
@@ -157,6 +172,7 @@ public class AuditQueryParamParsersTests
// instead RETURNS the wrong composite value below.
// ========================================================================
/// <summary>Verifies that parse enum list strict comma joined two names is rejected not silently orded into wrong member.</summary>
[Fact]
public void ParseEnumListStrict_CommaJoinedTwoNames_IsRejected_NotSilentlyOrdedIntoWrongMember()
{
@@ -169,6 +185,7 @@ public class AuditQueryParamParsersTests
Assert.Contains("status", ex.Message);
}
/// <summary>Verifies that parse enum list strict comma joined name and ordinal is rejected not smuggled past numeric guard.</summary>
[Fact]
public void ParseEnumListStrict_CommaJoinedNameAndOrdinal_IsRejected_NotSmuggledPastNumericGuard()
{
@@ -181,6 +198,7 @@ public class AuditQueryParamParsersTests
Assert.Contains("Failed,7", ex.Message);
}
/// <summary>Verifies that parse enum list strict comma joined names drop a member is rejected.</summary>
[Fact]
public void ParseEnumListStrict_CommaJoinedNamesDropAMember_IsRejected()
{
@@ -192,6 +210,7 @@ public class AuditQueryParamParsersTests
Assert.Contains("Submitted,Parked", ex.Message);
}
/// <summary>Verifies that parse enum list strict repeated param tokens of valid names still parse independently.</summary>
[Fact]
public void ParseEnumListStrict_RepeatedParamTokensOfValidNames_StillParseIndependently()
{
@@ -205,12 +224,14 @@ public class AuditQueryParamParsersTests
result);
}
/// <summary>Verifies that parse string list null input returns null.</summary>
[Fact]
public void ParseStringList_NullInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseStringList(null));
}
/// <summary>Verifies that parse string list trims values and drops blanks.</summary>
[Fact]
public void ParseStringList_TrimsValuesAndDropsBlanks()
{
@@ -219,6 +240,7 @@ public class AuditQueryParamParsersTests
Assert.Equal(new[] { "site-1", "site-2" }, result);
}
/// <summary>Verifies that parse string list all blank returns null.</summary>
[Fact]
public void ParseStringList_AllBlank_ReturnsNull()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
public class MxGatewayEndpointConfigSerializerTests
{
/// <summary>Verifies that serializing and then deserializing round-trips every field.</summary>
[Fact]
public void Serialize_then_Deserialize_round_trips_all_fields()
{
@@ -33,6 +34,8 @@ public class MxGatewayEndpointConfigSerializerTests
Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs);
}
/// <summary>Verifies that a null, blank or malformed JSON document deserializes to the default configuration.</summary>
/// <param name="json">The JSON document to deserialize.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -46,6 +49,7 @@ public class MxGatewayEndpointConfigSerializerTests
Assert.Equal(def.ReadTimeoutMs, result.ReadTimeoutMs);
}
/// <summary>Verifies that converting to a flat dictionary and back round-trips the configuration.</summary>
[Fact]
public void ToFlatDict_FromFlatDict_round_trips()
{
@@ -74,6 +78,7 @@ public class MxGatewayEndpointConfigSerializerTests
Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs);
}
/// <summary>Verifies that an invalid numeric value in a flat dictionary falls back to the default.</summary>
[Fact]
public void FromFlatDict_invalid_numeric_falls_back_to_default()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
public class OpcUaEndpointConfigSerializerTests
{
/// <summary>Verifies that a typed config round-trips through serialization with all fields preserved.</summary>
[Fact]
public void Serialize_TypedRoundtrip_PreservesAllFields()
{
@@ -44,6 +45,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds);
}
/// <summary>Verifies that serialization uses camelCase property names.</summary>
[Fact]
public void Serialize_UsesCamelCase()
{
@@ -53,6 +55,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.DoesNotContain("\"EndpointUrl\"", json);
}
/// <summary>Verifies that the security mode is serialized as a camelCase string.</summary>
[Fact]
public void Serialize_SecurityModeAsCamelCaseString()
{
@@ -63,6 +66,8 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("signAndEncrypt", doc.RootElement.GetProperty("securityMode").GetString());
}
/// <summary>Verifies that a null or empty input deserializes to the default config.</summary>
/// <param name="input">The null, empty or whitespace JSON input.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -77,6 +82,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Null(config.Heartbeat);
}
/// <summary>Verifies that a legacy flat dictionary is flagged as legacy and its fields are populated.</summary>
[Fact]
public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields()
{
@@ -105,6 +111,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds);
}
/// <summary>Verifies that a legacy dictionary with a PascalCase <c>EndpointUrl</c> key is deserialized correctly.</summary>
[Fact]
public void Deserialize_LegacyWithEndpointUrlPascalKey_Works()
{
@@ -114,6 +121,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
}
/// <summary>Verifies that a legacy dictionary with mixed value types preserves all fields.</summary>
[Fact]
public void Deserialize_LegacyWithMixedTypeValues_PreservesAllFields()
{
@@ -135,6 +143,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(1000, config.PublishingIntervalMs);
}
/// <summary>Verifies that a parsed legacy config reports a legacy status.</summary>
[Fact]
public void Deserialize_LegacyParsed_StatusIsLegacy()
{
@@ -145,6 +154,8 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
}
/// <summary>Verifies that malformed input is reported as malformed rather than legacy.</summary>
/// <param name="input">The malformed JSON input.</param>
[Theory]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
@@ -159,6 +170,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("", result.Config.EndpointUrl);
}
/// <summary>Verifies that an object without an endpoint URL is parsed as legacy.</summary>
[Fact]
public void Deserialize_ObjectWithoutEndpointUrl_ParsesAsLegacy()
{
@@ -168,8 +180,9 @@ public class OpcUaEndpointConfigSerializerTests
Assert.True(result.IsLegacy);
}
// ── Commons-014 regression: a corrupt typed row must not be mislabelled Legacy ──
// ── Regression: a corrupt typed row must not be mislabelled Legacy ──
/// <summary>Verifies that a typed-shaped row with an invalid enum value is reported as malformed rather than legacy.</summary>
[Fact]
public void Deserialize_TypedShapeWithInvalidEnum_ReportsMalformedNotLegacy()
{
@@ -184,6 +197,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.False(result.IsLegacy);
}
/// <summary>Verifies that a typed-shaped row with a wrongly typed field is reported as malformed rather than legacy.</summary>
[Fact]
public void Deserialize_TypedShapeWithWrongTypeField_ReportsMalformedNotLegacy()
{
@@ -196,10 +210,11 @@ public class OpcUaEndpointConfigSerializerTests
Assert.False(result.IsLegacy);
}
/// <summary>Verifies that a valid typed row is still reported as typed.</summary>
[Fact]
public void Deserialize_ValidTypedRow_StillReportsTyped()
{
// Guard: a clean typed row is still classified Typed after the Commons-014 fix.
// Guard: a clean typed row is still classified Typed after the fix.
var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"sign"}""";
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
@@ -209,6 +224,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(OpcUaSecurityMode.Sign, result.Config.SecurityMode);
}
/// <summary>Verifies that deserializing the result into a two-element deconstruction still works.</summary>
[Fact]
public void Deserialize_TwoElementDeconstruction_StillWorks()
{
@@ -219,6 +235,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
}
/// <summary>Verifies that a null heartbeat is omitted from the flat dictionary.</summary>
[Fact]
public void ToFlatDict_OmitsNullHeartbeat()
{
@@ -233,6 +250,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.False(dict.ContainsKey("HeartbeatMaxSilence"));
}
/// <summary>Verifies that the heartbeat is included in the flat dictionary when set.</summary>
[Fact]
public void ToFlatDict_IncludesHeartbeat_WhenSet()
{
@@ -247,6 +265,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("45", dict["HeartbeatMaxSilence"]);
}
/// <summary>Verifies that building a config from a flat dictionary round-trips all keys.</summary>
[Fact]
public void FromFlatDict_RoundTripsAllKeys()
{
@@ -269,6 +288,7 @@ public class OpcUaEndpointConfigSerializerTests
// ── Layer A/B extensions: subscription tuning, auth, deadband ──
/// <summary>Verifies that the new subscription scalar settings round-trip through serialization.</summary>
[Fact]
public void Serialize_RoundtripsNewSubscriptionScalars()
{
@@ -291,6 +311,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(OpcUaTimestampsToReturn.Both, round.TimestampsToReturn);
}
/// <summary>Verifies that the deadband setting round-trips through serialization.</summary>
[Fact]
public void Serialize_RoundtripsDeadband()
{
@@ -312,6 +333,12 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(2.5, round.Deadband.Value);
}
/// <summary>Verifies that the user identity settings round-trip through serialization for each token type.</summary>
/// <param name="tokenType">The user token type.</param>
/// <param name="user">The user name.</param>
/// <param name="pass">The password.</param>
/// <param name="certPath">The user certificate path.</param>
/// <param name="certPass">The user certificate password.</param>
[Theory]
[InlineData(OpcUaUserTokenType.UsernamePassword, "user1", "pass1", "", "")]
[InlineData(OpcUaUserTokenType.X509Certificate, "", "", "/etc/pki/client.pfx", "pfxpass")]
@@ -343,6 +370,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(certPass, round.UserIdentity.CertificatePassword);
}
/// <summary>Verifies that a null user identity and deadband are omitted from the typed JSON.</summary>
[Fact]
public void Serialize_NullUserIdentityAndDeadband_OmittedFromTypedJson()
{
@@ -356,6 +384,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Null(round.Deadband);
}
/// <summary>Verifies that the new scalar settings are included in the flat dictionary.</summary>
[Fact]
public void ToFlatDict_IncludesNewScalars()
{
@@ -375,6 +404,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("Server", dict["TimestampsToReturn"]);
}
/// <summary>Verifies that a null user identity and deadband are omitted from the flat dictionary.</summary>
[Fact]
public void ToFlatDict_OmitsNullUserIdentityAndDeadband()
{
@@ -387,6 +417,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.False(dict.ContainsKey("Deadband.Value"));
}
/// <summary>Verifies that the user identity is included in the flat dictionary when set.</summary>
[Fact]
public void ToFlatDict_IncludesUserIdentity_WhenSet()
{
@@ -407,6 +438,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("secret", dict["UserIdentity.Password"]);
}
/// <summary>Verifies that the deadband is included in the flat dictionary when set.</summary>
[Fact]
public void ToFlatDict_IncludesDeadband_WhenSet()
{
@@ -421,6 +453,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("1.5", dict["Deadband.Value"]);
}
/// <summary>Verifies that building a config from a flat dictionary materializes the user identity.</summary>
[Fact]
public void FromFlatDict_MaterializesUserIdentity()
{
@@ -439,6 +472,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal("hunter2", config.UserIdentity.Password);
}
/// <summary>Verifies that building a config from a flat dictionary materializes the deadband.</summary>
[Fact]
public void FromFlatDict_MaterializesDeadband()
{
@@ -455,6 +489,7 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(0.25, config.Deadband.Value);
}
/// <summary>Verifies that an anonymous token type still materializes the user identity from a flat dictionary.</summary>
[Fact]
public void FromFlatDict_AnonymousTokenTypeStillMaterializesUserIdentity()
{
@@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
public class OpcUaReferenceFormTests
{
/// <summary>Verifies that OPC UA reference strings are classified as durable or non-durable by form.</summary>
/// <param name="reference">The OPC UA reference string under test.</param>
/// <param name="expected">Whether the reference is expected to be durable.</param>
[Theory]
// durable → true
[InlineData("nsu=https://zb.com/otopcua/raw;s=Line1.Pump.Speed", true)]
@@ -16,6 +19,9 @@ public class OpcUaReferenceFormTests
[InlineData(" ", true)]
[InlineData(null, true)]
// bare server-namespace index → false (warn)
/// <summary>Verifies that reference forms are classified as durable or not as expected.</summary>
/// <param name="reference">The OPC UA reference text, or null.</param>
/// <param name="expected">The expected durability classification.</param>
[InlineData("ns=2;s=MyDevice.Temperature", false)]
[InlineData("ns=1;i=1001", false)]
[InlineData("ns=10;s=x", false)]
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Tests for <see cref="DynamicJsonElement"/>, including the Commons-002 regression:
/// Tests for <see cref="DynamicJsonElement"/>, including the regression where
/// a wrapped element must remain valid for deferred (script-time) access even after
/// the <see cref="JsonDocument"/> it was parsed from has been disposed.
/// </summary>
@@ -19,8 +19,9 @@ public class DynamicJsonElementTests
// now throw ObjectDisposedException on the first member access.
}
// ── Commons-002 regression: lifetime independence from the source document ──
// ── Regression: lifetime independence from the source document ──
/// <summary>Verifies that member access still works after the source document is disposed.</summary>
[Fact]
public void MemberAccess_WorksAfterSourceDocumentDisposed()
{
@@ -30,6 +31,7 @@ public class DynamicJsonElementTests
Assert.Equal(7, (int)obj.id);
}
/// <summary>Verifies that index access still works after the source document is disposed.</summary>
[Fact]
public void IndexAccess_WorksAfterSourceDocumentDisposed()
{
@@ -38,6 +40,7 @@ public class DynamicJsonElementTests
Assert.Equal("b", obj.items[1]);
}
/// <summary>Verifies that nested member access still works after the source document is disposed.</summary>
[Fact]
public void NestedAccess_WorksAfterSourceDocumentDisposed()
{
@@ -46,6 +49,7 @@ public class DynamicJsonElementTests
Assert.Equal(42, (int)obj.outer.inner.value);
}
/// <summary>Verifies that <c>ToString</c> still works after the source document is disposed.</summary>
[Fact]
public void ToString_WorksAfterSourceDocumentDisposed()
{
@@ -54,6 +58,7 @@ public class DynamicJsonElementTests
Assert.Equal("site-1", obj.label.ToString());
}
/// <summary>Verifies that access survives garbage collection of the source document.</summary>
[Fact]
public void Access_SurvivesGarbageCollection_OfSourceDocument()
{
@@ -78,6 +83,7 @@ public class DynamicJsonElementTests
// ── Basic conversion / access behavior ──────────────────────────
/// <summary>Verifies that a JSON number converts to an int.</summary>
[Fact]
public void Convert_NumberToInt()
{
@@ -85,6 +91,7 @@ public class DynamicJsonElementTests
Assert.Equal(123, (int)obj.n);
}
/// <summary>Verifies that a JSON boolean converts to a bool.</summary>
[Fact]
public void Convert_BoolFromJson()
{
@@ -92,6 +99,7 @@ public class DynamicJsonElementTests
Assert.True((bool)obj.flag);
}
/// <summary>Verifies that accessing a missing member throws a RuntimeBinderException.</summary>
[Fact]
public void MissingMember_Throws()
{
@@ -102,8 +110,9 @@ public class DynamicJsonElementTests
() => { var _ = obj.doesNotExist; });
}
// ── Commons-006 regression: TryConvert(object) must never null out a present value ──
// ── Regression: TryConvert(object) must never null out a present value ──
/// <summary>Verifies that converting a present value to <c>object</c> returns a non-null result.</summary>
[Fact]
public void TryConvert_ObjectTarget_OnPresentValue_ReturnsNonNull()
{
@@ -118,6 +127,7 @@ public class DynamicJsonElementTests
Assert.NotNull(result);
}
/// <summary>Verifies that converting a JSON null to <c>object</c> succeeds with a null result.</summary>
[Fact]
public void TryConvert_ObjectTarget_OnJsonNull_ReturnsNull()
{
@@ -131,6 +141,7 @@ public class DynamicJsonElementTests
Assert.Null(result);
}
/// <summary>Verifies that converting an unconvertible value to a non-object target reports failure.</summary>
[Fact]
public void TryConvert_NonObjectTarget_OnUnconvertibleValue_ReportsFailure()
{
@@ -145,8 +156,9 @@ public class DynamicJsonElementTests
Assert.Null(result);
}
// ── Commons-013 regression: integral index values other than int must work ──
// ── Regression: integral index values other than int must work ──
/// <summary>Verifies that index access works with a long index.</summary>
[Fact]
public void IndexAccess_WithLongIndex_Works()
{
@@ -158,16 +170,20 @@ public class DynamicJsonElementTests
Assert.Equal("b", obj.items[idx]);
}
/// <summary>Verifies that index access works with an index derived from a wrapped JSON number.</summary>
[Fact]
public void IndexAccess_WithIndexDerivedFromWrappedJsonNumber_Works()
{
// The exact failing case from Commons-013: count is a wrapped JSON number
// The exact failing case from that regression: count is a wrapped JSON number
// (unwrapped as long), so count - 1 is a long.
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ], "count": 3 }""");
Assert.Equal("c", obj.items[obj.count - 1]);
}
/// <summary>Verifies that index access works with integral indexes that widen to an int.</summary>
/// <param name="index">The test input.</param>
/// <param name="expected">The test input.</param>
[Theory]
[InlineData((byte)0, "a")]
[InlineData((short)1, "b")]
@@ -178,6 +194,7 @@ public class DynamicJsonElementTests
Assert.Equal(expected, obj.items[index]);
}
/// <summary>Verifies that a long index outside the array range throws.</summary>
[Fact]
public void IndexAccess_WithLongIndexOutOfRange_Throws()
{
@@ -4,6 +4,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class EnumTests
{
/// <summary>Verifies that an enum has exactly the expected member names, in order.</summary>
/// <param name="enumType">The enum type under test.</param>
/// <param name="expectedNames">The expected member names in declaration order.</param>
[Theory]
[InlineData(typeof(DataType), new[] { "Boolean", "Int32", "Float", "Double", "String", "DateTime", "Binary", "List" })]
[InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })]
@@ -20,6 +23,8 @@ public class EnumTests
Assert.Equal(expectedNames, actualNames);
}
/// <summary>Verifies that an enum type has a singular name rather than a plural one.</summary>
/// <param name="enumType">The enum type under test.</param>
[Theory]
[InlineData(typeof(DataType))]
[InlineData(typeof(InstanceState))]
@@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Enums;
/// <summary>
/// Asserts the exact member sets of the Audit Log (#23) enums.
/// Asserts the exact member sets of the Audit Log enums.
/// Lock-in tests; any addition/removal/rename is a deliberate design change
/// that must come with a corresponding update to alog.md §4.
/// </summary>
public class AuditEnumTests
{
/// <summary>Verifies that audit channel has exactly expected members.</summary>
[Fact]
public void AuditChannel_HasExactlyExpectedMembers()
{
@@ -22,6 +23,7 @@ public class AuditEnumTests
Assert.Equal(expected, actual);
}
/// <summary>Verifies that audit kind has exactly twenty two expected members.</summary>
[Fact]
public void AuditKind_HasExactlyTwentyTwoExpectedMembers()
{
@@ -45,6 +47,7 @@ public class AuditEnumTests
Assert.Equal(expected, actual);
}
/// <summary>Verifies that audit status has exactly eight expected members.</summary>
[Fact]
public void AuditStatus_HasExactlyEightExpectedMembers()
{
@@ -62,6 +65,7 @@ public class AuditEnumTests
Assert.Equal(expected, actual);
}
/// <summary>Verifies that audit forward state has exactly expected members.</summary>
[Fact]
public void AuditForwardState_HasExactlyExpectedMembers()
{
@@ -4,11 +4,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Commons-010: coverage for the small computed-property logic on
/// Coverage for the small computed-property logic on
/// <see cref="ConfigurationDiff"/> and <see cref="ScriptScope"/>.
/// </summary>
public class FlatteningAndScriptScopeTests
{
/// <summary>Verifies that a configuration diff with no changes reports HasChanges as false.</summary>
[Fact]
public void ConfigurationDiff_NoChanges_HasChangesIsFalse()
{
@@ -17,6 +18,7 @@ public class FlatteningAndScriptScopeTests
Assert.False(diff.HasChanges);
}
/// <summary>Verifies that a configuration diff containing an attribute change reports HasChanges as true.</summary>
[Fact]
public void ConfigurationDiff_WithAttributeChange_HasChangesIsTrue()
{
@@ -32,6 +34,7 @@ public class FlatteningAndScriptScopeTests
Assert.True(diff.HasChanges);
}
/// <summary>Verifies that a root script scope has no parent.</summary>
[Fact]
public void ScriptScope_Root_HasNoParent()
{
@@ -39,6 +42,7 @@ public class FlatteningAndScriptScopeTests
Assert.Null(ScriptScope.Root.ParentPath);
}
/// <summary>Verifies that a script scope with a parent path reports HasParent as true.</summary>
[Fact]
public void ScriptScope_WithParentPath_HasParentIsTrue()
{
@@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Gitea #36: <see cref="HandshakeResult"/> lets a script tell WHICH leg of a reset-aware
/// <see cref="HandshakeResult"/> lets a script tell WHICH leg of a reset-aware
/// handshake failed without naming the <see cref="HandshakePhase"/> enum (script bodies
/// import only System/System.Collections.Generic/System.Linq/System.Threading.Tasks).
/// </summary>
public class HandshakeResultTests
{
/// <summary>Verifies that ResetFailed is true only when the reset phase did not match.</summary>
[Fact]
public void ResetFailed_IsTrue_OnlyForAnUnmatchedResetPhase()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.InboundApi;
/// <summary>
/// Tests for the M9-T32b JSON-Schema <c>$ref</c> resolver: the caller-supplied
/// Tests for the JSON-Schema <c>$ref</c> resolver: the caller-supplied
/// resolution seam (<c>lib:Name</c> pointer convention), cycle/depth guarding,
/// and dangling-ref reporting. The resolver is OPTIONAL — schemas without a
/// <c>$ref</c> parse exactly as before (verified by the no-ref-unchanged tests).
@@ -13,6 +13,7 @@ public class InboundApiSchemaRefTests
{
// ── Pointer convention: {"$ref":"lib:Name"} resolves through the seam ──────
/// <summary>Verifies that a library reference resolves to the referenced shape.</summary>
[Fact]
public void ParseWithRefs_LibRef_ResolvesToReferencedShape()
{
@@ -33,6 +34,7 @@ public class InboundApiSchemaRefTests
Assert.Equal("integer", field.Schema.Type);
}
/// <summary>Verifies that a reference on a nested field is resolved inline.</summary>
[Fact]
public void ParseWithRefs_NestedFieldRef_ResolvesInline()
{
@@ -52,6 +54,7 @@ public class InboundApiSchemaRefTests
Assert.Equal("zip", Assert.Single(shipTo.Schema.Fields).Name);
}
/// <summary>Verifies that parsing a library reference with a resolver yields the referenced shape.</summary>
[Fact]
public void Parse_LibRef_WithResolver_ResolvesToReferencedShape()
{
@@ -68,6 +71,7 @@ public class InboundApiSchemaRefTests
// ── Dangling ref: resolver returns null → reported, not silently dropped ──
/// <summary>Verifies that a dangling reference is reported.</summary>
[Fact]
public void ParseWithRefs_DanglingRef_IsReported()
{
@@ -78,6 +82,7 @@ public class InboundApiSchemaRefTests
Assert.Contains("Missing", result.UnresolvedRefs);
}
/// <summary>Verifies that a dangling nested reference is reported.</summary>
[Fact]
public void ParseWithRefs_DanglingNestedRef_IsReported()
{
@@ -88,6 +93,7 @@ public class InboundApiSchemaRefTests
Assert.Contains("Gone", result.UnresolvedRefs);
}
/// <summary>Verifies that a reference parsed without a resolver is reported as dangling.</summary>
[Fact]
public void ParseWithRefs_RefWithNoResolver_IsDangling()
{
@@ -100,6 +106,7 @@ public class InboundApiSchemaRefTests
Assert.Contains("Whatever", result.UnresolvedRefs);
}
/// <summary>Verifies that parsing a dangling reference with a resolver throws.</summary>
[Fact]
public void Parse_DanglingRef_WithResolver_Throws()
{
@@ -111,6 +118,7 @@ public class InboundApiSchemaRefTests
// ── Cycle / depth guard: controlled error, no stack overflow ──────────────
/// <summary>Verifies that a direct reference cycle is reported instead of overflowing the stack.</summary>
[Fact]
public void ParseWithRefs_DirectCycle_IsReportedNotStackOverflow()
{
@@ -128,6 +136,7 @@ public class InboundApiSchemaRefTests
Assert.Contains(result.UnresolvedRefs, r => r.Contains("Foo", StringComparison.Ordinal));
}
/// <summary>Verifies that an indirect reference cycle is reported instead of overflowing the stack.</summary>
[Fact]
public void ParseWithRefs_IndirectCycle_IsReportedNotStackOverflow()
{
@@ -145,6 +154,7 @@ public class InboundApiSchemaRefTests
Assert.NotEmpty(result.UnresolvedRefs);
}
/// <summary>Verifies that parsing a direct cycle with a resolver throws instead of overflowing the stack.</summary>
[Fact]
public void Parse_DirectCycle_WithResolver_ThrowsNotStackOverflow()
{
@@ -159,6 +169,7 @@ public class InboundApiSchemaRefTests
name => schemas.GetValueOrDefault(name)));
}
/// <summary>Verifies that a deep reference chain respects the depth cap without overflowing the stack.</summary>
[Fact]
public void ParseWithRefs_DeepRefChain_RespectsDepthCapWithoutOverflow()
{
@@ -179,6 +190,7 @@ public class InboundApiSchemaRefTests
// ── Behavior unchanged for schemas WITHOUT a $ref ─────────────────────────
/// <summary>Verifies that parsing a schema with no references gives the same result as the plain parse.</summary>
[Fact]
public void ParseWithRefs_NoRef_MatchesParse()
{
@@ -196,6 +208,7 @@ public class InboundApiSchemaRefTests
Assert.False(withRefs.Schema.Fields[1].Required);
}
/// <summary>Verifies that a schema with no references parses identically with or without a resolver.</summary>
[Fact]
public void Parse_NoRef_UnchangedWithOrWithoutResolver()
{
@@ -212,6 +225,7 @@ public class InboundApiSchemaRefTests
Assert.Equal("number", withResolver.Items!.Type);
}
/// <summary>Verifies that an empty input returns a null schema and no references.</summary>
[Fact]
public void ParseWithRefs_Empty_ReturnsNullSchemaNoRefs()
{
@@ -222,6 +236,7 @@ public class InboundApiSchemaRefTests
// ── Diamond: two sibling refs to the SAME name resolve (not a cycle) ───────
/// <summary>Verifies that sibling references to the same target resolve without being reported as a false cycle.</summary>
[Fact]
public void ParseWithRefs_DiamondSiblingRefs_ResolveBothNotFalseCycle()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// </summary>
public class PurgeTimerScheduleTests
{
/// <summary>Verifies that a long interval is capped at the short first tick.</summary>
[Fact]
public void InitialDelay_LongInterval_IsCappedAtShortFirstTick()
{
@@ -17,6 +18,7 @@ public class PurgeTimerScheduleTests
Assert.Equal(PurgeTimerSchedule.ShortFirstTick, PurgeTimerSchedule.InitialDelay(TimeSpan.FromHours(24)));
}
/// <summary>Verifies that an interval shorter than the short first tick is left unchanged.</summary>
[Fact]
public void InitialDelay_IntervalShorterThanShortFirstTick_IsUnchanged()
{
@@ -26,12 +28,14 @@ public class PurgeTimerScheduleTests
Assert.Equal(fast, PurgeTimerSchedule.InitialDelay(fast));
}
/// <summary>Verifies that an interval equal to the short first tick yields that value.</summary>
[Fact]
public void InitialDelay_IntervalEqualToShortFirstTick_IsThatValue()
{
Assert.Equal(PurgeTimerSchedule.ShortFirstTick, PurgeTimerSchedule.InitialDelay(PurgeTimerSchedule.ShortFirstTick));
}
/// <summary>Verifies that the short first tick is five minutes.</summary>
[Fact]
public void ShortFirstTick_IsFiveMinutes()
{
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class ResultTests
{
/// <summary>Verifies that creating a success produces a successful result.</summary>
[Fact]
public void Success_ShouldCreateSuccessfulResult()
{
@@ -14,6 +15,7 @@ public class ResultTests
Assert.Equal(42, result.Value);
}
/// <summary>Verifies that creating a failure produces a failed result.</summary>
[Fact]
public void Failure_ShouldCreateFailedResult()
{
@@ -24,6 +26,7 @@ public class ResultTests
Assert.Equal("something went wrong", result.Error);
}
/// <summary>Verifies that reading the value of a failed result throws.</summary>
[Fact]
public void Value_OnFailure_ShouldThrow()
{
@@ -32,6 +35,7 @@ public class ResultTests
Assert.Throws<InvalidOperationException>(() => result.Value);
}
/// <summary>Verifies that reading the error of a successful result throws.</summary>
[Fact]
public void Error_OnSuccess_ShouldThrow()
{
@@ -40,6 +44,7 @@ public class ResultTests
Assert.Throws<InvalidOperationException>(() => result.Error);
}
/// <summary>Verifies that matching a successful result invokes the success handler.</summary>
[Fact]
public void Match_OnSuccess_ShouldCallOnSuccess()
{
@@ -52,6 +57,7 @@ public class ResultTests
Assert.Equal("value=42", output);
}
/// <summary>Verifies that matching a failed result invokes the failure handler.</summary>
[Fact]
public void Match_OnFailure_ShouldCallOnFailure()
{
@@ -64,6 +70,7 @@ public class ResultTests
Assert.Equal("error=bad", output);
}
/// <summary>Verifies that a success result works with a nullable reference type.</summary>
[Fact]
public void Success_WithNullableReferenceType_ShouldWork()
{
@@ -73,14 +80,17 @@ public class ResultTests
Assert.Equal("hello", result.Value);
}
// ── Commons-011 regression: a failed Result must always carry a message ──
// ── Regression: a failed Result must always carry a message ──
/// <summary>Verifies that creating a failure with a null error throws.</summary>
[Fact]
public void Failure_WithNullError_ShouldThrow()
{
Assert.Throws<ArgumentNullException>(() => Result<int>.Failure(null!));
}
/// <summary>Verifies that creating a failure with a blank error throws.</summary>
/// <param name="error">A blank or whitespace-only error message.</param>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class RetryPolicyTests
{
/// <summary>Verifies that retry policy should be immutable record.</summary>
[Fact]
public void RetryPolicy_ShouldBeImmutableRecord()
{
@@ -13,6 +14,7 @@ public class RetryPolicyTests
Assert.Equal(TimeSpan.FromSeconds(5), policy.Delay);
}
/// <summary>Verifies that retry policy with expression should create new instance.</summary>
[Fact]
public void RetryPolicy_WithExpression_ShouldCreateNewInstance()
{
@@ -24,6 +26,7 @@ public class RetryPolicyTests
Assert.Equal(original.Delay, modified.Delay);
}
/// <summary>Verifies that retry policy equal values should be equal.</summary>
[Fact]
public void RetryPolicy_EqualValues_ShouldBeEqual()
{
@@ -4,17 +4,19 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Commons-010: coverage for <see cref="ScriptArgs.Normalize"/> — the script-call
/// Coverage for <see cref="ScriptArgs.Normalize"/> — the script-call
/// parameter normalizer (dictionary / anonymous-object / primitive-rejection paths).
/// </summary>
public class ScriptArgsTests
{
/// <summary>Verifies that normalizing null returns null.</summary>
[Fact]
public void Normalize_Null_ReturnsNull()
{
Assert.Null(ScriptArgs.Normalize(null));
}
/// <summary>Verifies that a read-only dictionary is returned as is.</summary>
[Fact]
public void Normalize_ReadOnlyDictionary_ReturnedAsIs()
{
@@ -26,6 +28,7 @@ public class ScriptArgsTests
Assert.Same(input, result);
}
/// <summary>Verifies that a plain dictionary is returned as is.</summary>
[Fact]
public void Normalize_PlainDictionary_ReturnedAsIs()
{
@@ -39,6 +42,7 @@ public class ScriptArgsTests
Assert.Equal(1, result!["a"]);
}
/// <summary>Verifies that the keys of a non-generic dictionary are converted to strings.</summary>
[Fact]
public void Normalize_NonGenericDictionary_KeysStringified()
{
@@ -49,6 +53,7 @@ public class ScriptArgsTests
Assert.Equal("answer", result!["42"]);
}
/// <summary>Verifies that the properties of an anonymous object become dictionary entries.</summary>
[Fact]
public void Normalize_AnonymousObject_PropertiesBecomeEntries()
{
@@ -58,6 +63,8 @@ public class ScriptArgsTests
Assert.Equal(3, result["count"]);
}
/// <summary>Verifies that normalizing a primitive value throws.</summary>
/// <param name="primitive">The primitive value to normalize.</param>
[Theory]
[InlineData(42)]
[InlineData(true)]
@@ -67,12 +74,14 @@ public class ScriptArgsTests
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(primitive));
}
/// <summary>Verifies that normalizing a string throws.</summary>
[Fact]
public void Normalize_String_Throws()
{
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize("hello"));
}
/// <summary>Verifies that normalizing a decimal throws.</summary>
[Fact]
public void Normalize_Decimal_Throws()
{
@@ -7,6 +7,7 @@ public class ScriptParametersTests
{
// ── Non-nullable scalar Get<T> ──────────────────────────────────
/// <summary>Verifies that getting an int returns the value when the stored type is already an int.</summary>
[Fact]
public void Get_Int_ExactTypeMatch()
{
@@ -14,6 +15,7 @@ public class ScriptParametersTests
Assert.Equal(42, p.Get<int>("x"));
}
/// <summary>Verifies that getting an int converts a stored long.</summary>
[Fact]
public void Get_Int_FromLong()
{
@@ -21,6 +23,7 @@ public class ScriptParametersTests
Assert.Equal(42, p.Get<int>("x"));
}
/// <summary>Verifies that getting an int parses a stored string.</summary>
[Fact]
public void Get_Int_FromString()
{
@@ -28,6 +31,7 @@ public class ScriptParametersTests
Assert.Equal(42, p.Get<int>("x"));
}
/// <summary>Verifies that getting an int throws when the key is missing.</summary>
[Fact]
public void Get_Int_MissingKey_Throws()
{
@@ -36,6 +40,7 @@ public class ScriptParametersTests
Assert.Contains("Parameter 'x' not found", ex.Message);
}
/// <summary>Verifies that getting an int throws when the stored value is null.</summary>
[Fact]
public void Get_Int_NullValue_Throws()
{
@@ -44,6 +49,7 @@ public class ScriptParametersTests
Assert.Contains("Parameter 'x' value is null", ex.Message);
}
/// <summary>Verifies that getting an int throws when the stored value cannot be parsed.</summary>
[Fact]
public void Get_Int_Unparsable_Throws()
{
@@ -52,6 +58,7 @@ public class ScriptParametersTests
Assert.Contains("could not be parsed as Int32", ex.Message);
}
/// <summary>Verifies that getting a string returns a stored string directly.</summary>
[Fact]
public void Get_String_DirectReturn()
{
@@ -59,6 +66,7 @@ public class ScriptParametersTests
Assert.Equal("hello", p.Get<string>("s"));
}
/// <summary>Verifies that getting a string throws when the stored value is null.</summary>
[Fact]
public void Get_String_NullValue_Throws()
{
@@ -67,6 +75,7 @@ public class ScriptParametersTests
Assert.Contains("value is null", ex.Message);
}
/// <summary>Verifies that getting a string converts a stored int.</summary>
[Fact]
public void Get_String_FromInt()
{
@@ -74,6 +83,7 @@ public class ScriptParametersTests
Assert.Equal("42", p.Get<string>("s"));
}
/// <summary>Verifies that getting a bool returns true for a stored true value.</summary>
[Fact]
public void Get_Bool_True()
{
@@ -81,6 +91,7 @@ public class ScriptParametersTests
Assert.True(p.Get<bool>("b"));
}
/// <summary>Verifies that getting a double converts a stored long.</summary>
[Fact]
public void Get_Double_FromLong()
{
@@ -88,6 +99,7 @@ public class ScriptParametersTests
Assert.Equal(42.0, p.Get<double>("d"));
}
/// <summary>Verifies that getting a double converts a stored float.</summary>
[Fact]
public void Get_Double_FromFloat()
{
@@ -95,6 +107,7 @@ public class ScriptParametersTests
Assert.Equal(3.14, p.Get<double>("d"), 2);
}
/// <summary>Verifies that getting a long converts a stored int.</summary>
[Fact]
public void Get_Long_FromInt()
{
@@ -102,6 +115,7 @@ public class ScriptParametersTests
Assert.Equal(42L, p.Get<long>("l"));
}
/// <summary>Verifies that getting a float converts a stored double.</summary>
[Fact]
public void Get_Float_FromDouble()
{
@@ -109,6 +123,7 @@ public class ScriptParametersTests
Assert.Equal(3.14f, p.Get<float>("f"), 2);
}
/// <summary>Verifies that getting a date and time parses a stored string.</summary>
[Fact]
public void Get_DateTime_FromString()
{
@@ -117,6 +132,7 @@ public class ScriptParametersTests
Assert.Equal(new DateTime(2026, 3, 22, 10, 30, 0, DateTimeKind.Utc), result);
}
/// <summary>Verifies that getting a date and time returns the value when the stored type is already a date and time.</summary>
[Fact]
public void Get_DateTime_ExactType()
{
@@ -127,6 +143,7 @@ public class ScriptParametersTests
// ── Nullable scalar Get<T?> ─────────────────────────────────────
/// <summary>Verifies that getting a nullable int returns null when the key is missing.</summary>
[Fact]
public void Get_NullableInt_MissingKey_ReturnsNull()
{
@@ -134,6 +151,7 @@ public class ScriptParametersTests
Assert.Null(p.Get<int?>("x"));
}
/// <summary>Verifies that getting a nullable int returns null when the stored value is null.</summary>
[Fact]
public void Get_NullableInt_NullValue_ReturnsNull()
{
@@ -141,6 +159,7 @@ public class ScriptParametersTests
Assert.Null(p.Get<int?>("x"));
}
/// <summary>Verifies that getting a nullable int returns the value when it is valid.</summary>
[Fact]
public void Get_NullableInt_ValidValue_ReturnsValue()
{
@@ -148,9 +167,10 @@ public class ScriptParametersTests
Assert.Equal(42, p.Get<int?>("x"));
}
// Commons-003: a parameter that is *present but unconvertible* is a caller/script
// A parameter that is *present but unconvertible* is a caller/script
// bug and must throw — not be silently mapped to null (which a script would
// misread as "not supplied"). Genuinely absent/null still returns null.
/// <summary>Verifies that getting a nullable int throws when the value is present but cannot be parsed.</summary>
[Fact]
public void Get_NullableInt_PresentButUnparsable_Throws()
{
@@ -159,6 +179,7 @@ public class ScriptParametersTests
Assert.Contains("could not be parsed as Int32", ex.Message);
}
/// <summary>Verifies that getting a nullable int throws when the value is present but overflows.</summary>
[Fact]
public void Get_NullableInt_PresentButOverflowing_Throws()
{
@@ -167,6 +188,7 @@ public class ScriptParametersTests
Assert.Contains("could not be parsed as Int32", ex.Message);
}
/// <summary>Verifies that getting a nullable date and time throws when the value is present but cannot be parsed.</summary>
[Fact]
public void Get_NullableDateTime_PresentButUnparsable_Throws()
{
@@ -174,6 +196,7 @@ public class ScriptParametersTests
Assert.Throws<ScriptParameterException>(() => p.Get<DateTime?>("dt"));
}
/// <summary>Verifies that getting a nullable double returns the value when it is valid.</summary>
[Fact]
public void Get_NullableDouble_ValidValue()
{
@@ -181,6 +204,7 @@ public class ScriptParametersTests
Assert.Equal(3.14, p.Get<double?>("d"));
}
/// <summary>Verifies that getting a nullable bool returns the value when it is valid.</summary>
[Fact]
public void Get_NullableBool_ValidValue()
{
@@ -190,6 +214,7 @@ public class ScriptParametersTests
// ── Array Get<T[]> ──────────────────────────────────────────────
/// <summary>Verifies that getting an int array converts a stored list of longs.</summary>
[Fact]
public void Get_IntArray_FromListOfLongs()
{
@@ -198,6 +223,7 @@ public class ScriptParametersTests
Assert.Equal(new[] { 1, 2, 3 }, p.Get<int[]>("arr"));
}
/// <summary>Verifies that getting an int array from an empty list returns an empty array.</summary>
[Fact]
public void Get_IntArray_EmptyList()
{
@@ -205,6 +231,7 @@ public class ScriptParametersTests
Assert.Empty(p.Get<int[]>("arr"));
}
/// <summary>Verifies that getting an int array throws when the stored value is not a list.</summary>
[Fact]
public void Get_IntArray_NonList_Throws()
{
@@ -213,6 +240,7 @@ public class ScriptParametersTests
Assert.Contains("is not a list or array", ex.Message);
}
/// <summary>Verifies that getting an int array throws with the element index when an element cannot be parsed.</summary>
[Fact]
public void Get_IntArray_UnparsableElement_ThrowsWithIndex()
{
@@ -224,6 +252,7 @@ public class ScriptParametersTests
Assert.Contains("Int32", ex.Message);
}
/// <summary>Verifies that getting an int array throws when an element is null.</summary>
[Fact]
public void Get_IntArray_NullElement_Throws()
{
@@ -233,6 +262,7 @@ public class ScriptParametersTests
Assert.Contains("element at index 1 is null", ex.Message);
}
/// <summary>Verifies that getting a string array converts a stored list of strings.</summary>
[Fact]
public void Get_StringArray_FromListOfStrings()
{
@@ -241,6 +271,7 @@ public class ScriptParametersTests
Assert.Equal(new[] { "a", "b", "c" }, p.Get<string[]>("arr"));
}
/// <summary>Verifies that getting a double array converts a stored list of numbers.</summary>
[Fact]
public void Get_DoubleArray_FromListOfNumbers()
{
@@ -249,6 +280,7 @@ public class ScriptParametersTests
Assert.Equal(new[] { 1.1, 2.2, 3.3 }, p.Get<double[]>("arr"));
}
/// <summary>Verifies that getting an int array throws when the key is missing.</summary>
[Fact]
public void Get_IntArray_MissingKey_Throws()
{
@@ -259,6 +291,7 @@ public class ScriptParametersTests
// ── List<T> Get<List<T>> ────────────────────────────────────────
/// <summary>Verifies that getting an int list converts a stored list of longs.</summary>
[Fact]
public void Get_ListInt_FromListOfLongs()
{
@@ -268,6 +301,7 @@ public class ScriptParametersTests
Assert.Equal(new List<int> { 10, 20, 30 }, result);
}
/// <summary>Verifies that getting a string list converts a stored list of strings.</summary>
[Fact]
public void Get_ListString_FromListOfStrings()
{
@@ -276,6 +310,7 @@ public class ScriptParametersTests
Assert.Equal(new List<string> { "x", "y" }, p.Get<List<string>>("items"));
}
/// <summary>Verifies that getting an int list throws with the element index when an element cannot be parsed.</summary>
[Fact]
public void Get_ListInt_UnparsableElement_ThrowsWithIndex()
{
@@ -285,6 +320,7 @@ public class ScriptParametersTests
Assert.Contains("element at index 1", ex.Message);
}
/// <summary>Verifies that getting a double list converts a stored list of mixed numeric types.</summary>
[Fact]
public void Get_ListDouble_FromMixedNumbers()
{
@@ -299,6 +335,7 @@ public class ScriptParametersTests
// ── Backward compatibility ──────────────────────────────────────
/// <summary>Verifies that the indexer returns the stored value for a key.</summary>
[Fact]
public void Indexer_Works()
{
@@ -306,6 +343,7 @@ public class ScriptParametersTests
Assert.Equal("val", p["key"]);
}
/// <summary>Verifies that <c>ContainsKey</c> reports whether a key is present.</summary>
[Fact]
public void ContainsKey_Works()
{
@@ -314,6 +352,7 @@ public class ScriptParametersTests
Assert.False(p.ContainsKey("missing"));
}
/// <summary>Verifies that <c>TryGetValue</c> returns the stored value for a present key.</summary>
[Fact]
public void TryGetValue_Works()
{
@@ -322,6 +361,7 @@ public class ScriptParametersTests
Assert.Equal(42, val);
}
/// <summary>Verifies that the count reflects the number of stored entries.</summary>
[Fact]
public void Count_Works()
{
@@ -329,6 +369,7 @@ public class ScriptParametersTests
Assert.Equal(2, p.Count);
}
/// <summary>Verifies that enumeration yields every stored entry.</summary>
[Fact]
public void Enumeration_Works()
{
@@ -338,6 +379,7 @@ public class ScriptParametersTests
Assert.Equal(new[] { "a", "b" }, keys);
}
/// <summary>Verifies that the empty constructor produces an empty dictionary.</summary>
[Fact]
public void EmptyConstructor_ProducesEmptyDictionary()
{
@@ -348,6 +390,7 @@ public class ScriptParametersTests
// ── Edge cases ──────────────────────────────────────────────────
/// <summary>Verifies that getting a double converts a stored int.</summary>
[Fact]
public void Get_Double_FromInt()
{
@@ -357,6 +400,7 @@ public class ScriptParametersTests
// ── JsonElement values (from JSON deserialization) ─────────────
/// <summary>Verifies that getting an int reads a stored JSON element.</summary>
[Fact]
public void Get_Int_FromJsonElement()
{
@@ -366,6 +410,7 @@ public class ScriptParametersTests
Assert.Equal(42, p.Get<int>("x"));
}
/// <summary>Verifies that getting an int array reads a stored list of JSON elements.</summary>
[Fact]
public void Get_IntArray_FromJsonElementList()
{
@@ -376,6 +421,7 @@ public class ScriptParametersTests
Assert.Equal(new[] { 10, 20, 30 }, p.Get<int[]>("items"));
}
/// <summary>Verifies that getting an int list reads a stored list of JSON elements.</summary>
[Fact]
public void Get_ListInt_FromJsonElementList()
{
@@ -385,6 +431,7 @@ public class ScriptParametersTests
Assert.Equal(new List<int> { 1, 2, 3 }, p.Get<List<int>>("items"));
}
/// <summary>Verifies that getting a string reads a stored JSON element.</summary>
[Fact]
public void Get_String_FromJsonElement()
{
@@ -394,6 +441,7 @@ public class ScriptParametersTests
Assert.Equal("hello", p.Get<string>("s"));
}
/// <summary>Verifies that getting a double reads a stored JSON element.</summary>
[Fact]
public void Get_Double_FromJsonElement()
{
@@ -403,6 +451,7 @@ public class ScriptParametersTests
Assert.Equal(3.14, p.Get<double>("d"));
}
/// <summary>Verifies that getting a bool reads a stored JSON element.</summary>
[Fact]
public void Get_Bool_FromJsonElement()
{
@@ -412,6 +461,7 @@ public class ScriptParametersTests
Assert.True(p.Get<bool>("b"));
}
/// <summary>Verifies that getting an int throws when the stored long overflows an int.</summary>
[Fact]
public void Get_Int_OverflowFromLong_Throws()
{
@@ -12,6 +12,7 @@ public class SecretReferenceScannerTests
private static DataConnectionArtifact Dc(string name, string? primary, string? backup = null)
=> new(name, "MxGateway", primary, backup, 3);
/// <summary>Verifies that nested secret references are found case-insensitively across the primary and backup configuration.</summary>
[Fact]
public void FindsNestedRefs_CaseInsensitive_AcrossPrimaryAndBackup()
{
@@ -23,6 +24,8 @@ public class SecretReferenceScannerTests
Assert.Equal(["GW1", "GW2"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy);
}
/// <summary>Verifies that values that are not secret references are ignored.</summary>
/// <param name="json">The connection JSON that contains no secret reference.</param>
[Theory]
[InlineData("""{"ApiKey":"${secret:node-local}"}""")]
[InlineData("""{"Note":"see secret:x in docs"}""")]
@@ -34,6 +37,7 @@ public class SecretReferenceScannerTests
public void IgnoresNonReferences(string json)
=> Assert.Empty(SecretReferenceScanner.Scan([Dc("C", json)]));
/// <summary>Verifies that a connection referencing the same secret several times is reported once.</summary>
[Fact]
public void ReferencedBy_DeduplicatesWhenOneConnectionRefsTheSameSecretMultipleTimes()
{
@@ -47,8 +51,11 @@ public class SecretReferenceScannerTests
Assert.Equal(["GW1"], refs.Single(r => r.Name == "mxgw-key").ReferencedBy);
}
// ── Shared parser (final review M3) ──
// ── Shared parser ──
/// <summary>Verifies that the parser accepts trimmed, case-insensitive secret references.</summary>
/// <param name="value">The raw value to parse.</param>
/// <param name="expected">The secret name the parser is expected to return.</param>
[Theory]
[InlineData("secret:mxgw-key", "mxgw-key")]
[InlineData("SECRET:mxgw-key", "mxgw-key")]
@@ -60,6 +67,8 @@ public class SecretReferenceScannerTests
Assert.Equal(expected, name);
}
/// <summary>Verifies that the parser rejects values that are not secret references.</summary>
/// <param name="value">The value that is not a secret reference.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -74,6 +83,7 @@ public class SecretReferenceScannerTests
Assert.Equal(string.Empty, name);
}
/// <summary>Verifies that the scanner and the parser agree on the name of a padded, differently-cased reference.</summary>
[Fact]
public void Scan_AndParser_AgreeOnThePaddedCaseVariantName()
{
@@ -11,12 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// </summary>
public class SiteCallOperationalTests
{
/// <summary>Verifies that a site call operational record carries its source node.</summary>
[Fact]
public void SiteCallOperational_carries_SourceNode()
{
// SourceNode identifies the cluster node that emitted the cached call
// (site node-a/node-b or central-a/central-b). Nullable — callsites
// pass null until INodeIdentityProvider stamping arrives in Task 14.
// pass null until INodeIdentityProvider stamping arrives.
var trackedId = TrackedOperationId.New();
var nowUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc);
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class SiteNotificationKpiSnapshotTests
{
/// <summary>Verifies that the constructor assigns all members.</summary>
[Fact]
public void Constructor_AssignsAllMembers()
{
@@ -23,6 +24,7 @@ public class SiteNotificationKpiSnapshotTests
Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge);
}
/// <summary>Verifies that the oldest pending age is nullable for sites with no backlog.</summary>
[Fact]
public void OldestPendingAge_IsNullableForSitesWithNoBacklog()
{
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Regression tests for Commons-001: the check-then-act race between the timer
/// Regression tests for the check-then-act race between the timer
/// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>.
/// Placed in the same non-parallel collection as <see cref="StaleTagMonitorTests"/> to
/// avoid CPU contention from other timer-based tests running concurrently.
@@ -29,18 +29,22 @@ public class StaleTagMonitorTests
return condition(); // final check
}
/// <summary>Verifies that constructing the monitor with a zero time span throws.</summary>
[Fact]
public void Constructor_ZeroTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.Zero));
}
/// <summary>Verifies that constructing the monitor with a negative time span throws.</summary>
[Fact]
public void Constructor_NegativeTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.FromSeconds(-1)));
}
/// <summary>Verifies that the stale event fires after the maximum silence elapses.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Stale_FiresAfterMaxSilence()
{
@@ -54,6 +58,8 @@ public class StaleTagMonitorTests
Assert.Equal(1, staleCount);
}
/// <summary>Verifies that the stale event fires only once during continued silence.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Stale_FiresOnlyOnce()
{
@@ -72,6 +78,8 @@ public class StaleTagMonitorTests
Assert.Equal(1, staleCount);
}
/// <summary>Verifies that receiving a value resets the silence timer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnValueReceived_ResetsTimer()
{
@@ -93,6 +101,8 @@ public class StaleTagMonitorTests
Assert.Equal(0, staleCount);
}
/// <summary>Verifies that the stale event can still fire after silence following a received value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnValueReceived_AllowsStaleAfterSilence()
{
@@ -110,6 +120,8 @@ public class StaleTagMonitorTests
Assert.Equal(1, staleCount);
}
/// <summary>Verifies that receiving a value resets the stale flag so the event can fire a second time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnValueReceived_ResetsStaleFlag_AllowsSecondFire()
{
@@ -130,6 +142,8 @@ public class StaleTagMonitorTests
Assert.Equal(2, staleCount);
}
/// <summary>Verifies that stopping the monitor prevents the stale event from firing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Stop_PreventsStale()
{
@@ -143,6 +157,8 @@ public class StaleTagMonitorTests
Assert.Equal(0, staleCount);
}
/// <summary>Verifies that disposing the monitor prevents the stale event from firing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_PreventsStale()
{
@@ -156,6 +172,7 @@ public class StaleTagMonitorTests
Assert.Equal(0, staleCount);
}
/// <summary>Verifies that the maximum silence property returns the configured value.</summary>
[Fact]
public void MaxSilence_ReturnsConfiguredValue()
{
@@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier
/// Tests for the strongly-typed cached-operation identifier
/// produced by <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> and
/// surfaced to scripts via <c>Tracking.Status(id)</c>.
/// </summary>
public class TrackedOperationIdTests
{
/// <summary>Verifies that each new identifier is unique and non-empty.</summary>
[Fact]
public void New_ProducesUniqueIds()
{
@@ -20,6 +21,7 @@ public class TrackedOperationIdTests
Assert.NotEqual(Guid.Empty, b.Value);
}
/// <summary>Verifies that parsing the string form of an identifier restores the original value.</summary>
[Fact]
public void Parse_RoundTrip_PreservesValue()
{
@@ -32,6 +34,7 @@ public class TrackedOperationIdTests
Assert.Equal(original.Value, parsed.Value);
}
/// <summary>Verifies that invalid, null and empty input fail to parse and yield a default identifier.</summary>
[Fact]
public void TryParse_InvalidInput_ReturnsFalse()
{
@@ -45,6 +48,7 @@ public class TrackedOperationIdTests
Assert.Equal(default, emptyResult);
}
/// <summary>Verifies that valid input parses successfully and yields the original identifier.</summary>
[Fact]
public void TryParse_ValidInput_ReturnsTrueAndId()
{
@@ -55,6 +59,7 @@ public class TrackedOperationIdTests
Assert.Equal(original, parsed);
}
/// <summary>Verifies that two identifiers wrapping the same value are equal and share a hash code.</summary>
[Fact]
public void Equality_BasedOnValue()
{
@@ -68,6 +73,7 @@ public class TrackedOperationIdTests
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
/// <summary>Verifies that the string form uses the standard hyphenated GUID format.</summary>
[Fact]
public void ToString_StandardGuidFormat()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
/// <summary>
/// M8 A1: focused shape / value-equality / round-trip tests for the Transport (#24)
/// Focused shape / value-equality / round-trip tests for the Transport
/// name-mapping DTOs — <see cref="SiteMapping"/>, <see cref="ConnectionMapping"/>,
/// and <see cref="BundleNameMap"/>. These records cross the Central UI ⇆ ManagementActor
/// boundary (and may be persisted in an import session) via System.Text.Json, so a
@@ -16,6 +16,7 @@ public sealed class BundleNameMapTests
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
/// <summary>Verifies that the empty map has no sites or connections.</summary>
[Fact]
public void Empty_HasNoSitesOrConnections()
{
@@ -25,6 +26,7 @@ public sealed class BundleNameMapTests
Assert.Empty(map.Connections);
}
/// <summary>Verifies that the empty map is a stable shared instance.</summary>
[Fact]
public void Empty_IsStableInstance()
{
@@ -32,6 +34,7 @@ public sealed class BundleNameMapTests
Assert.Same(BundleNameMap.Empty, BundleNameMap.Empty);
}
/// <summary>Verifies that site mappings with identical fields are equal.</summary>
[Fact]
public void SiteMapping_ValueEquality_HoldsForIdenticalFields()
{
@@ -42,6 +45,7 @@ public sealed class BundleNameMapTests
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
/// <summary>Verifies that site mappings with different actions are not equal.</summary>
[Fact]
public void SiteMapping_ValueEquality_DiffersWhenActionDiffers()
{
@@ -51,6 +55,7 @@ public sealed class BundleNameMapTests
Assert.NotEqual(mapTo, createNew);
}
/// <summary>Verifies that connection mappings with identical fields are equal.</summary>
[Fact]
public void ConnectionMapping_ValueEquality_HoldsForIdenticalFields()
{
@@ -61,6 +66,7 @@ public sealed class BundleNameMapTests
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
/// <summary>Verifies that a map-to-existing mapping carries a non-null target.</summary>
[Fact]
public void MapToExisting_CarriesNonNullTarget()
{
@@ -76,6 +82,7 @@ public sealed class BundleNameMapTests
Assert.Equal("OPC-Primary", conn.TargetConnectionName);
}
/// <summary>Verifies that a create-new mapping leaves the target null.</summary>
[Fact]
public void CreateNew_LeavesTargetNull()
{
@@ -86,6 +93,7 @@ public sealed class BundleNameMapTests
Assert.Null(conn.TargetConnectionName);
}
/// <summary>Verifies that a JSON round trip preserves the sites and connections of a name map.</summary>
[Fact]
public void BundleNameMap_JsonRoundTrip_PreservesSitesAndConnections()
{
@@ -114,6 +122,7 @@ public sealed class BundleNameMapTests
Assert.True(map.Connections.SequenceEqual(rt.Connections));
}
/// <summary>Verifies that a JSON round trip of an empty name map stays empty.</summary>
[Fact]
public void BundleNameMap_Empty_JsonRoundTrip_PreservesEmptiness()
{
@@ -3,12 +3,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-015: <see cref="EncryptionMetadata"/> must reject malformed envelopes at
/// <see cref="EncryptionMetadata"/> must reject malformed envelopes at
/// the type boundary (unknown algorithm, unsupported KDF, sub-minimum or over-cap
/// iteration counts, null salt/IV). Valid construction must round-trip the fields.
/// </summary>
public sealed class EncryptionMetadataTests
{
/// <summary>Verifies that the constructor accepts the documented values.</summary>
[Fact]
public void Constructor_WithDocumentedValues_Succeeds()
{
@@ -28,6 +29,8 @@ public sealed class EncryptionMetadataTests
Assert.Equal("def", meta.IvB64);
}
/// <summary>Verifies that an unknown algorithm is rejected.</summary>
/// <param name="algorithm">The unsupported algorithm name.</param>
[Theory]
[InlineData("AES-128-CBC")] // weaker algorithm
[InlineData("AES-256-CBC")] // unauthenticated mode
@@ -47,6 +50,8 @@ public sealed class EncryptionMetadataTests
Assert.Contains("AES-256-GCM", ex.Message);
}
/// <summary>Verifies that an unknown key-derivation function is rejected.</summary>
/// <param name="kdf">The unsupported key-derivation function name.</param>
[Theory]
[InlineData("PBKDF2-SHA1")] // weaker hash
[InlineData("argon2id")] // unsupported KDF
@@ -65,6 +70,8 @@ public sealed class EncryptionMetadataTests
Assert.Contains("PBKDF2-SHA256", ex.Message);
}
/// <summary>Verifies that an out-of-range iteration count is rejected.</summary>
/// <param name="iterations">The out-of-range iteration count.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -84,6 +91,8 @@ public sealed class EncryptionMetadataTests
Assert.Equal("Iterations", ex.ParamName);
}
/// <summary>Verifies that an iteration count at the allowed boundary is accepted.</summary>
/// <param name="iterations">The boundary iteration count.</param>
[Theory]
[InlineData(100_000)] // OWASP minimum (exact)
[InlineData(600_000)] // design-doc production value
@@ -101,6 +110,7 @@ public sealed class EncryptionMetadataTests
Assert.Equal(iterations, meta.Iterations);
}
/// <summary>Verifies that a null salt is rejected.</summary>
[Fact]
public void Constructor_NullSalt_Throws()
{
@@ -113,6 +123,7 @@ public sealed class EncryptionMetadataTests
IvB64: "def"));
}
/// <summary>Verifies that a null initialization vector is rejected.</summary>
[Fact]
public void Constructor_NullIv_Throws()
{
@@ -124,6 +135,7 @@ public sealed class EncryptionMetadataTests
IvB64: null!));
}
/// <summary>Verifies that an empty salt and initialization vector are accepted for the seed pattern.</summary>
[Fact]
public void Constructor_EmptySaltAndIv_Succeeds_ForSeedPattern()
{
@@ -4,18 +4,16 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs
/// Focused shape / round-trip tests for the Transport record DTOs
/// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>,
/// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and
/// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary
/// via System.Text.Json, so a positional/tuple slip would break bundles in the field.
/// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs
/// (Commons-015) and is reused here only to populate manifest fixtures.
/// and is reused here only to populate manifest fixtures.
/// </summary>
public sealed class TransportRecordsTests
{
// STM: TransportRecordsTests-Commons-020 marker — used by grep verification.
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
@@ -25,6 +23,7 @@ public sealed class TransportRecordsTests
// BundleManifest
// --------------------------------------------------------------
/// <summary>Verifies that the <c>BundleManifest</c> constructor preserves all fields.</summary>
[Fact]
public void BundleManifest_Constructor_RoundTripsAllFields()
{
@@ -62,6 +61,7 @@ public sealed class TransportRecordsTests
Assert.Equal("Pump", manifest.Contents[0].Name);
}
/// <summary>Verifies that a <c>BundleManifest</c> survives a JSON round trip with all fields intact.</summary>
[Fact]
public void BundleManifest_JsonRoundTrip_PreservesAllFields()
{
@@ -106,6 +106,7 @@ public sealed class TransportRecordsTests
// ExportSelection
// --------------------------------------------------------------
/// <summary>Verifies that the <c>ExportSelection</c> constructor preserves every id list.</summary>
[Fact]
public void ExportSelection_Constructor_PreservesAllIdLists()
{
@@ -126,6 +127,7 @@ public sealed class TransportRecordsTests
Assert.True(sel.IncludeDependencies);
}
/// <summary>Verifies that an <c>ExportSelection</c> JSON round trip preserves the include-dependencies flag and ids.</summary>
[Fact]
public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds()
{
@@ -152,6 +154,7 @@ public sealed class TransportRecordsTests
// ImportPreview
// --------------------------------------------------------------
/// <summary>Verifies that an <c>ImportPreview</c> can be constructed with every conflict kind.</summary>
[Fact]
public void ImportPreview_Constructor_AllowsAllConflictKinds()
{
@@ -175,6 +178,7 @@ public sealed class TransportRecordsTests
Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason);
}
/// <summary>Verifies that an <c>ImportPreview</c> JSON round trip preserves the conflict kind and optional fields.</summary>
[Fact]
public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields()
{
@@ -201,6 +205,9 @@ public sealed class TransportRecordsTests
// ImportResolution
// --------------------------------------------------------------
/// <summary>Verifies that the <c>ImportResolution</c> constructor preserves each resolution action and rename target.</summary>
/// <param name="action">The resolution action under test.</param>
/// <param name="renameTo">The rename target, or null when the action is not a rename.</param>
[Theory]
[InlineData(ResolutionAction.Add, null)]
[InlineData(ResolutionAction.Overwrite, null)]
@@ -215,6 +222,7 @@ public sealed class TransportRecordsTests
Assert.Equal(renameTo, res.RenameTo);
}
/// <summary>Verifies that an <c>ImportResolution</c> JSON round trip preserves the rename target.</summary>
[Fact]
public void ImportResolution_JsonRoundTrip_PreservesRenameTo()
{
@@ -232,6 +240,7 @@ public sealed class TransportRecordsTests
// ImportResult
// --------------------------------------------------------------
/// <summary>Verifies that the <c>ImportResult</c> constructor preserves all counters and stale instance ids.</summary>
[Fact]
public void ImportResult_Constructor_PreservesAllCountersAndStaleIds()
{
@@ -254,6 +263,7 @@ public sealed class TransportRecordsTests
Assert.Equal("audit-corr-001", result.AuditEventCorrelation);
}
/// <summary>Verifies that an <c>ImportResult</c> JSON round trip preserves the counts and correlation id.</summary>
[Fact]
public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation()
{
@@ -280,6 +290,7 @@ public sealed class TransportRecordsTests
// Record equality sanity (catches positional/tuple slip)
// --------------------------------------------------------------
/// <summary>Verifies that transport records compare equal by value.</summary>
[Fact]
public void TransportRecords_RecordValueEquality()
{
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Tests for <see cref="ValidationResult.SummarizeErrors"/> (followup #8) — the grouped,
/// Tests for <see cref="ValidationResult.SummarizeErrors"/> — the grouped,
/// capped summary that replaces the flat semicolon-joined deploy error dump.
/// </summary>
public class ValidationResultSummaryTests
@@ -14,12 +14,14 @@ public class ValidationResultSummaryTests
$"Attribute '{canonicalName}' has a data source reference but no connection binding.",
canonicalName);
/// <summary>Verifies that a result with no errors summarizes to an empty string.</summary>
[Fact]
public void SummarizeErrors_NoErrors_ReturnsEmpty()
{
Assert.Equal(string.Empty, ValidationResult.Success().SummarizeErrors());
}
/// <summary>Verifies that the summary leads with the total error count.</summary>
[Fact]
public void SummarizeErrors_LeadsWithTotalCount()
{
@@ -33,6 +35,7 @@ public class ValidationResultSummaryTests
Assert.StartsWith("3 errors:", summary);
}
/// <summary>Verifies that a single error uses the singular noun.</summary>
[Fact]
public void SummarizeErrors_SingleError_UsesSingularNoun()
{
@@ -40,6 +43,7 @@ public class ValidationResultSummaryTests
Assert.StartsWith("1 error:", result.SummarizeErrors());
}
/// <summary>Verifies that unbound attributes are rolled up by module with counts.</summary>
[Fact]
public void SummarizeErrors_RollsUpUnboundAttributesByModuleWithCounts()
{
@@ -58,6 +62,7 @@ public class ValidationResultSummaryTests
Assert.DoesNotContain("Attr11' has a data source reference", summary);
}
/// <summary>Verifies that the number of listed modules is capped with an "and N more" suffix.</summary>
[Fact]
public void SummarizeErrors_CapsModuleBreadthWithAndNMore()
{
@@ -70,6 +75,7 @@ public class ValidationResultSummaryTests
Assert.Contains("Module0 (1)", summary);
}
/// <summary>Verifies that a top-level member is grouped under the root.</summary>
[Fact]
public void SummarizeErrors_TopLevelMember_GroupsUnderRoot()
{
@@ -77,6 +83,7 @@ public class ValidationResultSummaryTests
Assert.Contains("(root) (1)", summary);
}
/// <summary>Verifies that entries without an entity name fall back to a capped message list.</summary>
[Fact]
public void SummarizeErrors_EntriesWithoutEntityName_FallBackToCappedMessageList()
{
@@ -91,6 +98,7 @@ public class ValidationResultSummaryTests
Assert.Contains("… and 3 more", summary);
}
/// <summary>Verifies that mixed error categories are listed as separate category groups.</summary>
[Fact]
public void SummarizeErrors_MixedCategories_ListsEachCategoryGroup()
{
@@ -4,32 +4,36 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Tests for <see cref="ValueFormatter"/>. Includes the Commons-012 regression:
/// Tests for <see cref="ValueFormatter"/>. Includes the culture-invariance regression:
/// formatting must be culture-invariant because the formatter feeds non-UI contexts
/// (gRPC stream events, logs) where locale-dependent output would be inconsistent.
/// </summary>
public class ValueFormatterTests
{
/// <summary>Verifies that formatting a null value returns an empty string.</summary>
[Fact]
public void FormatDisplayValue_Null_ReturnsEmptyString()
{
Assert.Equal("", ValueFormatter.FormatDisplayValue(null));
}
/// <summary>Verifies that formatting a string returns the value unchanged.</summary>
[Fact]
public void FormatDisplayValue_String_ReturnsValueUnchanged()
{
Assert.Equal("hello", ValueFormatter.FormatDisplayValue("hello"));
}
/// <summary>Verifies that formatting a collection joins the elements with a comma.</summary>
[Fact]
public void FormatDisplayValue_Collection_JoinsWithComma()
{
Assert.Equal("1,2,3", ValueFormatter.FormatDisplayValue(new[] { 1, 2, 3 }));
}
// ── Commons-012 regression: culture-invariant numeric/date formatting ──
// ── Culture-invariance regression: numeric/date formatting ──
/// <summary>Verifies that formatting a double uses the invariant culture regardless of the thread culture.</summary>
[Fact]
public void FormatDisplayValue_Double_UsesInvariantCulture_RegardlessOfThreadCulture()
{
@@ -46,6 +50,7 @@ public class ValueFormatterTests
}
}
/// <summary>Verifies that formatting a DateTime uses the invariant culture regardless of the thread culture.</summary>
[Fact]
public void FormatDisplayValue_DateTime_UsesInvariantCulture_RegardlessOfThreadCulture()
{
@@ -63,6 +68,7 @@ public class ValueFormatterTests
}
}
/// <summary>Verifies that formatting a collection of doubles uses the invariant culture.</summary>
[Fact]
public void FormatDisplayValue_CollectionOfDoubles_UsesInvariantCulture()
{
@@ -12,6 +12,7 @@ public class MxGatewayEndpointConfigValidatorTests
ApiKey = "key",
};
/// <summary>Verifies that a valid config passes validation.</summary>
[Fact]
public void Validate_ValidConfig_IsValid()
{
@@ -20,6 +21,7 @@ public class MxGatewayEndpointConfigValidatorTests
Assert.Empty(result.Errors);
}
/// <summary>Verifies that a missing endpoint fails validation.</summary>
[Fact]
public void Validate_MissingEndpoint_Fails()
{
@@ -33,6 +35,8 @@ public class MxGatewayEndpointConfigValidatorTests
&& e.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that an endpoint with an unsupported scheme fails validation.</summary>
/// <param name="url">The endpoint URL with the bad scheme.</param>
[Theory]
[InlineData("opc.tcp://x:4840")]
[InlineData("ftp://x")]
@@ -46,6 +50,7 @@ public class MxGatewayEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "Endpoint");
}
/// <summary>Verifies that a missing API key fails validation.</summary>
[Fact]
public void Validate_MissingApiKey_Fails()
{
@@ -56,6 +61,7 @@ public class MxGatewayEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "ApiKey");
}
/// <summary>Verifies that a non-positive read timeout fails validation.</summary>
[Fact]
public void Validate_NonPositiveReadTimeout_Fails()
{
@@ -66,6 +72,7 @@ public class MxGatewayEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "ReadTimeoutMs");
}
/// <summary>Verifies that prefixed field names appear in the validation errors.</summary>
[Fact]
public void Validate_PrefixedFieldNames_AppearInErrors()
{
@@ -12,6 +12,7 @@ public class OpcUaEndpointConfigValidatorTests
// Defaults satisfy the spec: Lifetime(30) >= 3 * KeepAlive(10).
};
/// <summary>Verifies that the default settings with a valid endpoint URL validate successfully.</summary>
[Fact]
public void Validate_DefaultsWithValidUrl_IsValid()
{
@@ -20,6 +21,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Empty(result.Errors);
}
/// <summary>Verifies that a missing endpoint URL fails validation.</summary>
[Fact]
public void Validate_MissingEndpointUrl_Fails()
{
@@ -33,6 +35,8 @@ public class OpcUaEndpointConfigValidatorTests
&& e.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that a malformed or non-OPC UA endpoint URL fails validation.</summary>
/// <param name="url">The invalid endpoint URL to validate.</param>
[Theory]
[InlineData("http://x")]
[InlineData("opc.tcp://")]
@@ -46,6 +50,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl");
}
/// <summary>Verifies that a lifetime count below three times the keep-alive count fails validation.</summary>
[Fact]
public void Validate_LifetimeLessThanThreeTimesKeepAlive_Fails()
{
@@ -57,6 +62,8 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "LifetimeCount");
}
/// <summary>Verifies that a non-positive timing value fails validation.</summary>
/// <param name="field">The name of the timing field set to a non-positive value.</param>
[Theory]
[InlineData(nameof(OpcUaEndpointConfig.SessionTimeoutMs))]
[InlineData(nameof(OpcUaEndpointConfig.OperationTimeoutMs))]
@@ -71,6 +78,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == field);
}
/// <summary>Verifies that a queue size of zero fails validation.</summary>
[Fact]
public void Validate_QueueSizeZero_Fails()
{
@@ -80,6 +88,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "QueueSize");
}
/// <summary>Verifies that enabling the heartbeat without a tag path fails validation.</summary>
[Fact]
public void Validate_HeartbeatEnabledNoTagPath_Fails()
{
@@ -89,6 +98,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.TagPath");
}
/// <summary>Verifies that a non-positive heartbeat silence threshold fails validation.</summary>
[Fact]
public void Validate_HeartbeatNonPositiveSilence_Fails()
{
@@ -98,6 +108,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.MaxSilenceSeconds");
}
/// <summary>Verifies that the field prefix is applied to every validation error.</summary>
[Fact]
public void Validate_FieldPrefix_AppliedToEveryError()
{
@@ -112,6 +123,7 @@ public class OpcUaEndpointConfigValidatorTests
// ── Layer B extensions: auth, deadband, subscription display name ──
/// <summary>Verifies that an empty subscription display name fails validation.</summary>
[Fact]
public void Validate_EmptySubscriptionDisplayName_Fails()
{
@@ -121,6 +133,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "SubscriptionDisplayName");
}
/// <summary>Verifies that an anonymous user identity requires no additional fields.</summary>
[Fact]
public void Validate_UserIdentityAnonymous_NoExtraFieldsRequired()
{
@@ -130,6 +143,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.True(r.IsValid);
}
/// <summary>Verifies that a username/password identity without a username fails validation.</summary>
[Fact]
public void Validate_UsernamePasswordWithoutUsername_Fails()
{
@@ -144,6 +158,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.Username");
}
/// <summary>Verifies that an X.509 identity without a certificate path fails validation.</summary>
[Fact]
public void Validate_X509WithoutCertificatePath_Fails()
{
@@ -157,6 +172,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.CertificatePath");
}
/// <summary>Verifies that a username/password identity with a username passes validation.</summary>
[Fact]
public void Validate_UsernamePasswordWithUsername_Passes()
{
@@ -171,6 +187,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.True(r.IsValid);
}
/// <summary>Verifies that a deadband with a non-positive value fails validation.</summary>
[Fact]
public void Validate_DeadbandWithNonPositiveValue_Fails()
{
@@ -180,6 +197,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.Contains(r.Errors, e => e.EntityName == "Deadband.Value");
}
/// <summary>Verifies that a deadband with a positive value passes validation.</summary>
[Fact]
public void Validate_DeadbandWithPositiveValue_Passes()
{
@@ -189,6 +207,7 @@ public class OpcUaEndpointConfigValidatorTests
Assert.True(r.IsValid);
}
/// <summary>Verifies that user identity errors carry the field prefix.</summary>
[Fact]
public void Validate_UserIdentityErrorsPrefixed_WithFieldPrefix()
{