Expand XML docs across bridge and test code

This commit is contained in:
Joseph Doherty
2026-03-25 11:45:12 -04:00
parent 3f813b3869
commit 4833765606
86 changed files with 2323 additions and 0 deletions

View File

@@ -5,9 +5,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class AppConfiguration
{
/// <summary>
/// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space.
/// </summary>
public OpcUaConfiguration OpcUa { get; set; } = new OpcUaConfiguration();
/// <summary>
/// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes.
/// </summary>
public MxAccessConfiguration MxAccess { get; set; } = new MxAccessConfiguration();
/// <summary>
/// Gets or sets the repository settings used to query Galaxy metadata for address-space construction.
/// </summary>
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new GalaxyRepositoryConfiguration();
/// <summary>
/// Gets or sets the embedded dashboard settings used to surface service health to operators.
/// </summary>
public DashboardConfiguration Dashboard { get; set; } = new DashboardConfiguration();
}
}

View File

@@ -9,6 +9,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
/// <summary>
/// Validates the effective host configuration and writes the resolved values to the startup log before service initialization continues.
/// </summary>
/// <param name="config">The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries, and dashboard behavior.</param>
/// <returns><see langword="true"/> when the required settings are present and within supported bounds; otherwise, <see langword="false"/>.</returns>
public static bool ValidateAndLog(AppConfiguration config)
{
bool valid = true;

View File

@@ -5,8 +5,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class DashboardConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
/// </summary>
public int Port { get; set; } = 8081;
/// <summary>
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
/// </summary>
public int RefreshIntervalSeconds { get; set; } = 10;
}
}

View File

@@ -5,9 +5,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class GalaxyRepositoryConfiguration
{
/// <summary>
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
/// </summary>
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
/// <summary>
/// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space rebuild.
/// </summary>
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
/// </summary>
public bool ExtendedAttributes { get; set; } = false;
}
}

View File

@@ -5,15 +5,54 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class MxAccessConfiguration
{
/// <summary>
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
/// </summary>
public string ClientName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
/// </summary>
public string? NodeName { get; set; }
/// <summary>
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
/// </summary>
public string? GalaxyName { get; set; }
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
/// </summary>
public int ReadTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
/// </summary>
public int WriteTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
/// </summary>
public int MaxConcurrentOperations { get; set; } = 10;
/// <summary>
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
/// </summary>
public int MonitorIntervalSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess session.
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
/// </summary>
public string? ProbeTag { get; set; }
/// <summary>
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
/// </summary>
public int ProbeStaleThresholdSeconds { get; set; } = 60;
}
}

View File

@@ -5,11 +5,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class OpcUaConfiguration
{
/// <summary>
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
/// </summary>
public int Port { get; set; } = 4840;
/// <summary>
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
/// </summary>
public string EndpointPath { get; set; } = "/LmxOpcUa";
/// <summary>
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
/// </summary>
public string ServerName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
/// </summary>
public string GalaxyName { get; set; } = "ZB";
/// <summary>
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// </summary>
public int MaxSessions { get; set; } = 100;
/// <summary>
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
/// </summary>
public int SessionTimeoutMinutes { get; set; } = 30;
}
}

View File

@@ -5,11 +5,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public enum ConnectionState
{
/// <summary>
/// No active session exists to the Galaxy runtime.
/// </summary>
Disconnected,
/// <summary>
/// The bridge is opening a new MXAccess session to the runtime.
/// </summary>
Connecting,
/// <summary>
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
/// </summary>
Connected,
/// <summary>
/// The bridge is closing the current MXAccess session and draining runtime resources.
/// </summary>
Disconnecting,
/// <summary>
/// The bridge detected a connection fault that requires operator attention or recovery logic.
/// </summary>
Error,
/// <summary>
/// The bridge is attempting to restore service after a runtime communication failure.
/// </summary>
Reconnecting
}
}

View File

@@ -7,10 +7,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Gets the previous MXAccess connection state before the transition was raised.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the new MXAccess connection state that the bridge moved into.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets an operator-facing message that explains why the connection state changed.
/// </summary>
public string Message { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs"/> class.
/// </summary>
/// <param name="previous">The connection state being exited.</param>
/// <param name="current">The connection state being entered.</param>
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
{
PreviousState = previous;

View File

@@ -5,15 +5,54 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public class GalaxyAttributeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier that owns the attribute.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
/// </summary>
public string DataTypeName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation, or runtime data.
/// </summary>
public string AttributeSource { get; set; } = "";
}
}

View File

@@ -5,11 +5,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public class GalaxyObjectInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the contained name shown for the object inside its parent area or object.
/// </summary>
public string ContainedName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
/// </summary>
public bool IsArea { get; set; }
}
}

View File

@@ -10,11 +10,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public interface IGalaxyRepository
{
/// <summary>
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of Galaxy objects ordered for address-space construction.</returns>
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
/// <summary>
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of attribute definitions with MXAccess references and type metadata.</returns>
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
/// <summary>
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>The latest deploy timestamp, or <see langword="null"/> when it cannot be determined.</returns>
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
/// <summary>
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true"/> when repository access succeeds; otherwise, <see langword="false"/>.</returns>
Task<bool> TestConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
/// </summary>
event Action? OnGalaxyChanged;
}
}

View File

@@ -10,20 +10,70 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public interface IMxAccessClient : IDisposable
{
/// <summary>
/// Gets the current runtime connectivity state for the bridge.
/// </summary>
ConnectionState State { get; }
/// <summary>
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
/// </summary>
event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Closes the MXAccess session and releases runtime resources.
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="callback">The callback to invoke when the runtime publishes a new value for the attribute.</param>
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
/// <summary>
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
Task UnsubscribeAsync(string fullTagReference);
/// <summary>
/// Reads the current runtime value for a Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The value, timestamp, and quality returned by the runtime.</returns>
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
/// <summary>
/// Writes a new runtime value to a writable Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="value">The value to write to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true"/> when the write is accepted by the runtime; otherwise, <see langword="false"/>.</returns>
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
/// <summary>
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
/// </summary>
int ActiveSubscriptionCount { get; }
/// <summary>
/// Gets the number of reconnect cycles attempted since the client was created.
/// </summary>
int ReconnectCount { get; }
}
}

View File

@@ -6,6 +6,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that raised the change.</param>
/// <param name="phItemHandle">The runtime item handle for the attribute that changed.</param>
/// <param name="pvItemValue">The new raw runtime value for the attribute.</param>
/// <param name="pwItemQuality">The OPC DA quality code supplied by the runtime.</param>
/// <param name="pftItemTimeStamp">The timestamp object supplied by the runtime for the value.</param>
/// <param name="ItemStatus">The MXAccess status payload associated with the callback.</param>
public delegate void MxDataChangeHandler(
int hLMXServerHandle,
int phItemHandle,
@@ -17,6 +23,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that processed the write.</param>
/// <param name="phItemHandle">The runtime item handle that was written.</param>
/// <param name="ItemStatus">The MXAccess status payload describing the write outcome.</param>
public delegate void MxWriteCompleteHandler(
int hLMXServerHandle,
int phItemHandle,
@@ -27,15 +36,65 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public interface IMxProxy
{
/// <summary>
/// Registers the bridge as an MXAccess client with the runtime proxy.
/// </summary>
/// <param name="clientName">The client identity reported to the runtime for diagnostics and session tracking.</param>
/// <returns>The runtime connection handle assigned to the client session.</returns>
int Register(string clientName);
/// <summary>
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
/// </summary>
/// <param name="handle">The connection handle returned by <see cref="Register(string)"/>.</param>
void Unregister(int handle);
/// <summary>
/// Adds a Galaxy attribute reference to the active runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified attribute reference to resolve.</param>
/// <returns>The runtime item handle assigned to the attribute.</returns>
int AddItem(int handle, string address);
/// <summary>
/// Removes a previously registered attribute from the runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)"/>.</param>
void RemoveItem(int handle, int itemHandle);
/// <summary>
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
void AdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Stops supervisory updates for an attribute.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
void UnAdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Writes a new value to a runtime attribute through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The new value to push into the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
void Write(int handle, int itemHandle, object value, int securityClassification);
/// <summary>
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
/// </summary>
event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the runtime acknowledges completion of a write request.
/// </summary>
event MxWriteCompleteHandler? OnWriteComplete;
}
}

View File

@@ -12,6 +12,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
/// Unknown types default to String (i=12).
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA built-in data type node identifier.</returns>
public static uint MapToOpcUaDataType(int mxDataType)
{
return mxDataType switch
@@ -35,6 +37,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Maps mx_data_type to the corresponding CLR type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The CLR type used to represent runtime values for the MX type.</returns>
public static Type MapToClrType(int mxDataType)
{
return mxDataType switch
@@ -58,6 +62,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Returns the OPC UA type name for a given mx_data_type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA type name used in diagnostics.</returns>
public static string GetOpcUaTypeName(int mxDataType)
{
return mxDataType switch

View File

@@ -5,13 +5,41 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public static class MxErrorCodes
{
/// <summary>
/// The requested Galaxy attribute reference does not resolve in the runtime.
/// </summary>
public const int MX_E_InvalidReference = 1008;
/// <summary>
/// The supplied value does not match the attribute's configured data type.
/// </summary>
public const int MX_E_WrongDataType = 1012;
/// <summary>
/// The target attribute cannot be written because it is read-only or protected.
/// </summary>
public const int MX_E_NotWritable = 1013;
/// <summary>
/// The runtime did not complete the operation within the configured timeout.
/// </summary>
public const int MX_E_RequestTimedOut = 1014;
/// <summary>
/// Communication with the MXAccess runtime failed during the operation.
/// </summary>
public const int MX_E_CommFailure = 1015;
/// <summary>
/// The operation was attempted without an active MXAccess session.
/// </summary>
public const int MX_E_NotConnected = 1016;
/// <summary>
/// Converts a numeric MXAccess error code into an operator-facing message.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>A human-readable description of the runtime failure.</returns>
public static string GetMessage(int errorCode)
{
return errorCode switch
@@ -26,6 +54,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
};
}
/// <summary>
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>The quality classification that best represents the runtime failure.</returns>
public static Quality MapToQuality(int errorCode)
{
return errorCode switch

View File

@@ -6,31 +6,96 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
public enum Quality : byte
{
// Bad family (0-63)
/// <summary>
/// No valid process value is available.
/// </summary>
Bad = 0,
/// <summary>
/// The value is invalid because the Galaxy attribute definition or mapping is wrong.
/// </summary>
BadConfigError = 4,
/// <summary>
/// The bridge is not currently connected to the Galaxy runtime.
/// </summary>
BadNotConnected = 8,
/// <summary>
/// The runtime device or adapter failed while obtaining the value.
/// </summary>
BadDeviceFailure = 12,
/// <summary>
/// The underlying field source reported a bad sensor condition.
/// </summary>
BadSensorFailure = 16,
/// <summary>
/// Communication with the runtime failed while retrieving the value.
/// </summary>
BadCommFailure = 20,
/// <summary>
/// The attribute is intentionally unavailable for service, such as a locked or unwritable value.
/// </summary>
BadOutOfService = 24,
/// <summary>
/// The bridge is still waiting for the first usable value after startup or resubscription.
/// </summary>
BadWaitingForInitialData = 32,
// Uncertain family (64-191)
/// <summary>
/// A value is available, but it should be treated cautiously.
/// </summary>
Uncertain = 64,
/// <summary>
/// The last usable value is being repeated because a newer one is unavailable.
/// </summary>
UncertainLastUsable = 68,
/// <summary>
/// The sensor or source is providing a value with reduced accuracy.
/// </summary>
UncertainSensorNotAccurate = 80,
/// <summary>
/// The value exceeds its engineered limits.
/// </summary>
UncertainEuExceeded = 84,
/// <summary>
/// The source is operating in a degraded or subnormal state.
/// </summary>
UncertainSubNormal = 88,
// Good family (192+)
/// <summary>
/// The value is current and suitable for normal client use.
/// </summary>
Good = 192,
/// <summary>
/// The value is good but currently overridden locally rather than flowing from the live source.
/// </summary>
GoodLocalOverride = 216
}
/// <summary>
/// Helper methods for reasoning about OPC quality families used by the bridge.
/// </summary>
public static class QualityExtensions
{
/// <summary>
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true"/> when the value is in the good quality range; otherwise, <see langword="false"/>.</returns>
public static bool IsGood(this Quality q) => (byte)q >= 192;
/// <summary>
/// Determines whether the quality represents an uncertain runtime value that should be treated cautiously.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true"/> when the value is in the uncertain range; otherwise, <see langword="false"/>.</returns>
public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 192;
/// <summary>
/// Determines whether the quality represents a bad runtime value that should not be used as valid process data.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true"/> when the value is in the bad range; otherwise, <see langword="false"/>.</returns>
public static bool IsBad(this Quality q) => (byte)q < 64;
}
}

View File

@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
/// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
/// </summary>
/// <param name="mxQuality">The raw MXAccess quality integer.</param>
/// <returns>The mapped bridge quality value.</returns>
public static Quality MapFromMxAccessQuality(int mxQuality)
{
var b = (byte)(mxQuality & 0xFF);
@@ -26,6 +28,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// <summary>
/// Maps domain Quality to OPC UA StatusCode uint32.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code represented as a 32-bit unsigned integer.</returns>
public static uint MapToOpcUaStatusCode(Quality quality)
{
return quality switch

View File

@@ -7,10 +7,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the runtime value returned for the Galaxy attribute.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp associated with the runtime value.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq"/> struct for a Galaxy attribute value.
/// </summary>
/// <param name="value">The runtime value returned by MXAccess.</param>
/// <param name="timestamp">The timestamp assigned to the runtime value.</param>
/// <param name="quality">The quality classification for the runtime value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
@@ -18,15 +35,42 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
Quality = quality;
}
/// <summary>
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
public static Vtq Good(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Good);
/// <summary>
/// Creates a bad-quality VTQ snapshot when no usable runtime value is available.
/// </summary>
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
public static Vtq Bad(Quality quality = Quality.Bad) => new Vtq(null, DateTime.UtcNow, quality);
/// <summary>
/// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
public static Vtq Uncertain(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
/// <summary>
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
/// </summary>
/// <param name="other">The other VTQ snapshot to compare.</param>
/// <returns><see langword="true"/> when all fields match; otherwise, <see langword="false"/>.</returns>
public bool Equals(Vtq other) =>
Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
/// <inheritdoc />
public override bool Equals(object? obj) => obj is Vtq other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(Value, Timestamp, Quality);
/// <inheritdoc />
public override string ToString() => $"Vtq({Value}, {Timestamp:O}, {Quality})";
}
}

View File

@@ -19,9 +19,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
private CancellationTokenSource? _cts;
private DateTime? _lastKnownDeployTime;
/// <summary>
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Gets the last deploy timestamp observed by the polling loop.
/// </summary>
public DateTime? LastKnownDeployTime => _lastKnownDeployTime;
/// <summary>
/// Initializes a new change detector for Galaxy deploy timestamps.
/// </summary>
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds, DateTime? initialDeployTime = null)
{
_repository = repository;
@@ -29,6 +42,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
_lastKnownDeployTime = initialDeployTime;
}
/// <summary>
/// Starts the background polling loop that watches for Galaxy deploy changes.
/// </summary>
public void Start()
{
_cts = new CancellationTokenSource();
@@ -36,6 +52,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds);
}
/// <summary>
/// Stops the background polling loop.
/// </summary>
public void Stop()
{
_cts?.Cancel();
@@ -88,6 +107,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
}
}
/// <summary>
/// Stops the polling loop and disposes the underlying cancellation resources.
/// </summary>
public void Dispose()
{
Stop();

View File

@@ -18,6 +18,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
private readonly GalaxyRepositoryConfiguration _config;
/// <summary>
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
/// </summary>
public event Action? OnGalaxyChanged;
#region SQL Queries (GR-006: const string, no dynamic SQL)
@@ -204,11 +207,20 @@ ORDER BY tag_name, primitive_name, attribute_name";
#endregion
/// <summary>
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
/// </summary>
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
{
_config = config;
}
/// <summary>
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
{
var results = new List<GalaxyObjectInfo>();
@@ -240,6 +252,11 @@ ORDER BY tag_name, primitive_name, attribute_name";
return results;
}
/// <summary>
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
{
var results = new List<GalaxyAttributeInfo>();
@@ -304,6 +321,11 @@ ORDER BY tag_name, primitive_name, attribute_name";
};
}
/// <summary>
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The most recent deploy timestamp, or <see langword="null"/> when none is available.</returns>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
using var conn = new SqlConnection(_config.ConnectionString);
@@ -315,6 +337,11 @@ ORDER BY tag_name, primitive_name, attribute_name";
return result is DateTime dt ? dt : null;
}
/// <summary>
/// Executes a lightweight query to confirm that the repository database is reachable.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true"/> when the query succeeds; otherwise, <see langword="false"/>.</returns>
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
try
@@ -335,6 +362,9 @@ ORDER BY tag_name, primitive_name, attribute_name";
}
}
/// <summary>
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
/// </summary>
public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke();
}
}

View File

@@ -7,11 +7,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
/// </summary>
public class GalaxyRepositoryStats
{
/// <summary>
/// Gets or sets the Galaxy name currently being represented by the bridge.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}
}

View File

@@ -13,6 +13,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
/// </summary>
public interface ITimingScope : IDisposable
{
/// <summary>
/// Marks whether the timed bridge operation completed successfully.
/// </summary>
/// <param name="success">A value indicating whether the measured operation succeeded.</param>
void SetSuccess(bool success);
}
@@ -21,12 +25,39 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
/// </summary>
public class MetricsStatistics
{
/// <summary>
/// Gets or sets the total number of recorded executions for the operation.
/// </summary>
public long TotalCount { get; set; }
/// <summary>
/// Gets or sets the number of recorded executions that completed successfully.
/// </summary>
public long SuccessCount { get; set; }
/// <summary>
/// Gets or sets the ratio of successful executions to total executions.
/// </summary>
public double SuccessRate { get; set; }
/// <summary>
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
/// </summary>
public double AverageMilliseconds { get; set; }
/// <summary>
/// Gets or sets the fastest recorded execution time in milliseconds.
/// </summary>
public double MinMilliseconds { get; set; }
/// <summary>
/// Gets or sets the slowest recorded execution time in milliseconds.
/// </summary>
public double MaxMilliseconds { get; set; }
/// <summary>
/// Gets or sets the 95th percentile execution time in milliseconds.
/// </summary>
public double Percentile95Milliseconds { get; set; }
}
@@ -43,6 +74,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
private double _minMilliseconds = double.MaxValue;
private double _maxMilliseconds;
/// <summary>
/// Records the outcome and duration of a single bridge operation invocation.
/// </summary>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
public void Record(TimeSpan duration, bool success)
{
lock (_lock)
@@ -61,6 +97,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
}
/// <summary>
/// Creates a snapshot of the current statistics for this operation type.
/// </summary>
/// <returns>A statistics snapshot suitable for logs, status reporting, and tests.</returns>
public MetricsStatistics GetStatistics()
{
lock (_lock)
@@ -98,28 +138,51 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new metrics collector and starts periodic performance reporting.
/// </summary>
public PerformanceMetrics()
{
_reportingTimer = new Timer(ReportMetrics, null,
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
/// <summary>
/// Records a completed bridge operation under the specified metrics bucket.
/// </summary>
/// <param name="operationName">The logical operation name, such as read, write, or subscribe.</param>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
/// <summary>
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
/// </summary>
/// <param name="operationName">The logical operation name to record.</param>
/// <returns>A timing scope that reports elapsed time back into this collector.</returns>
public ITimingScope BeginOperation(string operationName)
{
return new TimingScope(this, operationName);
}
/// <summary>
/// Retrieves the raw metrics bucket for a named operation.
/// </summary>
/// <param name="operationName">The logical operation name to look up.</param>
/// <returns>The metrics bucket when present; otherwise, <see langword="null"/>.</returns>
public OperationMetrics? GetMetrics(string operationName)
{
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
}
/// <summary>
/// Produces a statistics snapshot for all recorded bridge operations.
/// </summary>
/// <returns>A dictionary keyed by operation name containing current metrics statistics.</returns>
public Dictionary<string, MetricsStatistics> GetStatistics()
{
var result = new Dictionary<string, MetricsStatistics>(StringComparer.OrdinalIgnoreCase);
@@ -144,6 +207,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
}
/// <summary>
/// Stops periodic reporting and emits a final metrics snapshot.
/// </summary>
public void Dispose()
{
if (_disposed) return;
@@ -152,6 +218,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
ReportMetrics(null);
}
/// <summary>
/// Timing scope that records one operation result into the owning metrics collector.
/// </summary>
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
@@ -160,6 +229,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
private bool _success = true;
private bool _disposed;
/// <summary>
/// Initializes a timing scope for a named bridge operation.
/// </summary>
/// <param name="metrics">The metrics collector that should receive the result.</param>
/// <param name="operationName">The logical operation name being timed.</param>
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
@@ -167,8 +241,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
_stopwatch = Stopwatch.StartNew();
}
/// <summary>
/// Marks whether the timed operation should be recorded as successful.
/// </summary>
/// <param name="success">A value indicating whether the operation succeeded.</param>
public void SetSuccess(bool success) => _success = success;
/// <summary>
/// Stops timing and records the operation result once.
/// </summary>
public void Dispose()
{
if (_disposed) return;

View File

@@ -9,6 +9,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
public async Task ConnectAsync(CancellationToken ct = default)
{
if (_state == ConnectionState.Connected) return;
@@ -54,6 +58,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
/// </summary>
public async Task DisconnectAsync()
{
if (_state == ConnectionState.Disconnected) return;
@@ -100,6 +107,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
/// </summary>
public async Task ReconnectAsync()
{
SetState(ConnectionState.Reconnecting);

View File

@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
/// </summary>
public void StartMonitor()
{
_monitorCts = new CancellationTokenSource();
@@ -15,6 +18,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds);
}
/// <summary>
/// Stops the background monitor loop.
/// </summary>
public void StopMonitor()
{
_monitorCts?.Cancel();

View File

@@ -8,6 +8,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to read.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The resulting VTQ value or a bad-quality fallback on timeout or failure.</returns>
public async Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
{
if (_state != ConnectionState.Connected)
@@ -79,6 +85,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true"/> when the runtime acknowledges success; otherwise, <see langword="false"/>.</returns>
public async Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
if (_state != ConnectionState.Connected) return false;

View File

@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to monitor.</param>
/// <param name="callback">The callback that should receive runtime value changes.</param>
public async Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
_storedSubscriptions[fullTagReference] = callback;
@@ -15,6 +20,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
await SubscribeInternalAsync(fullTagReference);
}
/// <summary>
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to stop monitoring.</param>
public async Task UnsubscribeAsync(string fullTagReference)
{
_storedSubscriptions.TryRemove(fullTagReference, out _);

View File

@@ -49,13 +49,38 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
private DateTime _lastProbeValueTime = DateTime.UtcNow;
private int _reconnectCount;
/// <summary>
/// Gets the current runtime connection state for the MXAccess client.
/// </summary>
public ConnectionState State => _state;
/// <summary>
/// Gets the number of active tag subscriptions currently maintained against the runtime.
/// </summary>
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
/// <summary>
/// Gets the number of reconnect attempts performed since the client was created.
/// </summary>
public int ReconnectCount => _reconnectCount;
/// <summary>
/// Occurs when the MXAccess connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed runtime tag publishes a new value.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
/// </summary>
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config, PerformanceMetrics metrics)
{
_staThread = staThread;
@@ -74,6 +99,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
}
/// <summary>
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
/// </summary>
public void Dispose()
{
try

View File

@@ -13,9 +13,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
private LMXProxyServer? _lmxProxy;
/// <summary>
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
/// </summary>
public event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the COM proxy confirms completion of a write request.
/// </summary>
public event MxWriteCompleteHandler? OnWriteComplete;
/// <summary>
/// Creates and registers the COM proxy session that backs live MXAccess operations.
/// </summary>
/// <param name="clientName">The client name reported to the Wonderware runtime.</param>
/// <returns>The runtime connection handle assigned by the COM server.</returns>
public int Register(string clientName)
{
_lmxProxy = new LMXProxyServer();
@@ -30,6 +42,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return handle;
}
/// <summary>
/// Unregisters the COM proxy session and releases the underlying COM object.
/// </summary>
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)"/>.</param>
public void Unregister(int handle)
{
if (_lmxProxy != null)
@@ -48,10 +64,42 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
}
/// <summary>
/// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
/// <returns>The item handle assigned by the COM proxy.</returns>
public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address);
/// <summary>
/// Removes an item handle from the active COM proxy session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to remove.</param>
public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle);
/// <summary>
/// Enables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle);
/// <summary>
/// Disables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle);
/// <summary>
/// Writes a value to the specified runtime item through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
public void Write(int handle, int itemHandle, object value, int securityClassification)
=> _lmxProxy!.Write(handle, itemHandle, value, securityClassification);

View File

@@ -31,6 +31,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
private long _workItemsExecuted;
private DateTime _lastLogTime;
/// <summary>
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
/// </summary>
public StaComThread()
{
_thread = new Thread(ThreadEntry)
@@ -41,8 +44,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
_thread.SetApartmentState(ApartmentState.STA);
}
/// <summary>
/// Gets a value indicating whether the STA thread is running and able to accept work.
/// </summary>
public bool IsRunning => _nativeThreadId != 0 && !_disposed;
/// <summary>
/// Starts the STA thread and waits until its message pump is ready for COM work.
/// </summary>
public void Start()
{
_thread.Start();
@@ -50,6 +59,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
}
/// <summary>
/// Queues an action to execute on the STA thread.
/// </summary>
/// <param name="action">The work item to execute on the STA thread.</param>
/// <returns>A task that completes when the action has finished executing.</returns>
public Task RunAsync(Action action)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
@@ -71,6 +85,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return tcs.Task;
}
/// <summary>
/// Queues a function to execute on the STA thread and returns its result.
/// </summary>
/// <typeparam name="T">The result type produced by the function.</typeparam>
/// <param name="func">The work item to execute on the STA thread.</param>
/// <returns>A task that completes with the function result.</returns>
public Task<T> RunAsync<T>(Func<T> func)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
@@ -91,6 +111,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return tcs.Task;
}
/// <summary>
/// Stops the STA thread and releases the message-pump resources used for COM interop.
/// </summary>
public void Dispose()
{
if (_disposed) return;

View File

@@ -19,21 +19,70 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public class NodeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier represented by this address-space node.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// Gets or sets the attribute nodes published beneath this object.
/// </summary>
public List<AttributeNodeInfo> Attributes { get; set; } = new();
/// <summary>
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
/// </summary>
public List<NodeInfo> Children { get; set; } = new();
}
/// <summary>
/// Lightweight description of an attribute node that will become an OPC UA variable.
/// </summary>
public class AttributeNodeInfo
{
/// <summary>
/// Gets or sets the Galaxy attribute name published under the object.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is modeled as an array.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length when the attribute is a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
}
@@ -42,12 +91,33 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public class AddressSpaceModel
{
/// <summary>
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
/// </summary>
public List<NodeInfo> RootNodes { get; set; } = new();
/// <summary>
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
/// </summary>
public Dictionary<string, string> NodeIdToTagReference { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets the number of non-area Galaxy objects included in the model.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of variable nodes created from Galaxy attributes.
/// </summary>
public int VariableCount { get; set; }
}
/// <summary>
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes nodes.
/// </summary>
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
var model = new AddressSpaceModel();

View File

@@ -9,6 +9,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public static class DataValueConverter
{
/// <summary>
/// Converts a bridge VTQ snapshot into an OPC UA data value.
/// </summary>
/// <param name="vtq">The VTQ snapshot to convert.</param>
/// <returns>An OPC UA data value suitable for reads and subscriptions.</returns>
public static DataValue FromVtq(Vtq vtq)
{
var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality));
@@ -24,6 +29,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return dataValue;
}
/// <summary>
/// Converts an OPC UA data value back into a bridge VTQ snapshot.
/// </summary>
/// <param name="dataValue">The OPC UA data value to convert.</param>
/// <returns>A VTQ snapshot containing the converted value, timestamp, and derived quality.</returns>
public static Vtq ToVtq(DataValue dataValue)
{
var quality = MapStatusCodeToQuality(dataValue.StatusCode);

View File

@@ -31,10 +31,29 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly object _lock = new object();
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
/// <summary>
/// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O.
/// </summary>
public IReadOnlyDictionary<string, string> NodeIdToTagReference => _nodeIdToTagReference;
/// <summary>
/// Gets the number of variable nodes currently published from Galaxy attributes.
/// </summary>
public int VariableNodeCount { get; private set; }
/// <summary>
/// Gets the number of non-area object nodes currently published from the Galaxy hierarchy.
/// </summary>
public int ObjectNodeCount { get; private set; }
/// <summary>
/// Initializes a new node manager for the Galaxy-backed OPC UA namespace.
/// </summary>
/// <param name="server">The hosting OPC UA server internals.</param>
/// <param name="configuration">The OPC UA application configuration for the host.</param>
/// <param name="namespaceUri">The namespace URI that identifies the Galaxy model to clients.</param>
/// <param name="mxAccessClient">The runtime client used to service reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector used to track node manager activity.</param>
public LmxNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
@@ -51,6 +70,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
}
/// <inheritdoc />
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
@@ -63,6 +83,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <summary>
/// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003)
/// </summary>
/// <param name="hierarchy">The Galaxy object hierarchy that defines folders and objects in the namespace.</param>
/// <param name="attributes">The Galaxy attributes that become OPC UA variable nodes.</param>
public void BuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
lock (Lock)
@@ -145,6 +167,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <summary>
/// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010)
/// </summary>
/// <param name="hierarchy">The latest Galaxy object hierarchy to publish.</param>
/// <param name="attributes">The latest Galaxy attributes to publish.</param>
public void RebuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
lock (Lock)
@@ -316,6 +340,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
#region Read/Write Handlers
/// <inheritdoc />
public override void Read(OperationContext context, double maxAge, IList<ReadValueId> nodesToRead,
IList<DataValue> results, IList<ServiceResult> errors)
{
@@ -346,6 +371,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <inheritdoc />
public override void Write(OperationContext context, IList<WriteValue> nodesToWrite,
IList<ServiceResult> errors)
{
@@ -444,6 +470,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Called by the OPC UA framework after monitored items are created on nodes in our namespace.
/// Triggers ref-counted MXAccess subscriptions for the underlying tags.
/// </summary>
/// <inheritdoc />
protected override void OnCreateMonitoredItemsComplete(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
{
foreach (var item in monitoredItems)
@@ -458,6 +485,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Called by the OPC UA framework after monitored items are deleted.
/// Decrements ref-counted MXAccess subscriptions.
/// </summary>
/// <inheritdoc />
protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
{
foreach (var item in monitoredItems)
@@ -475,6 +503,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return null;
}
/// <summary>
/// Increments the subscription reference count for a Galaxy tag and opens the runtime subscription when the first OPC UA monitored item appears.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to subscribe.</param>
internal void SubscribeTag(string fullTagReference)
{
lock (_lock)
@@ -491,6 +523,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Decrements the subscription reference count for a Galaxy tag and closes the runtime subscription when no OPC UA monitored items remain.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to unsubscribe.</param>
internal void UnsubscribeTag(string fullTagReference)
{
lock (_lock)

View File

@@ -16,7 +16,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly PerformanceMetrics _metrics;
private LmxNodeManager? _nodeManager;
/// <summary>
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
/// </summary>
public LmxNodeManager? NodeManager => _nodeManager;
/// <summary>
/// Gets the number of active OPC UA sessions currently connected to the server.
/// </summary>
public int ActiveSessionCount
{
get
@@ -26,6 +33,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Initializes a custom OPC UA server for the specified Galaxy namespace.
/// </summary>
/// <param name="galaxyName">The Galaxy name used to construct the namespace URI and product URI.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live data access.</param>
/// <param name="metrics">The metrics collector shared with the node manager.</param>
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics)
{
_galaxyName = galaxyName;
@@ -33,6 +46,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_metrics = metrics;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
{
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
@@ -42,6 +56,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
}
/// <inheritdoc />
protected override ServerProperties LoadServerProperties()
{
var properties = new ServerProperties

View File

@@ -8,11 +8,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public static class OpcUaQualityMapper
{
/// <summary>
/// Converts bridge quality values into OPC UA status codes.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code to publish.</returns>
public static StatusCode ToStatusCode(Quality quality)
{
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
}
/// <summary>
/// Converts an OPC UA status code back into a bridge quality category.
/// </summary>
/// <param name="statusCode">The OPC UA status code to interpret.</param>
/// <returns>The bridge quality category represented by the status code.</returns>
public static Quality FromStatusCode(StatusCode statusCode)
{
if (StatusCode.IsGood(statusCode)) return Quality.Good;

View File

@@ -23,10 +23,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private ApplicationInstance? _application;
private LmxOpcUaServer? _server;
/// <summary>
/// Gets the active node manager that holds the published Galaxy namespace.
/// </summary>
public LmxNodeManager? NodeManager => _server?.NodeManager;
/// <summary>
/// Gets the number of currently connected OPC UA client sessions.
/// </summary>
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
/// <summary>
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
/// </summary>
public bool IsRunning => _server != null;
/// <summary>
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
/// </summary>
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics)
{
_config = config;
@@ -34,6 +51,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_metrics = metrics;
}
/// <summary>
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured endpoint.
/// </summary>
public async Task StartAsync()
{
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
@@ -142,6 +162,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_config.Port, _config.EndpointPath, namespaceUri);
}
/// <summary>
/// Stops the OPC UA application instance and releases its in-memory server objects.
/// </summary>
public void Stop()
{
try
@@ -160,6 +183,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Stops the host and releases server resources.
/// </summary>
public void Dispose() => Stop();
}
}

View File

@@ -62,6 +62,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// <summary>
/// Test constructor. Accepts injected dependencies.
/// </summary>
/// <param name="config">The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard behavior during the test run.</param>
/// <param name="mxProxy">The MXAccess proxy substitute used when a test wants to exercise COM-style wiring.</param>
/// <param name="galaxyRepository">The repository substitute that supplies Galaxy hierarchy and deploy metadata for address-space builds.</param>
/// <param name="mxAccessClientOverride">An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop.</param>
/// <param name="hasMxAccessClientOverride">A value indicating whether the override client should be used instead of creating a client from <paramref name="mxProxy"/>.</param>
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false)
{
@@ -72,6 +77,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
_hasMxAccessClientOverride = hasMxAccessClientOverride;
}
/// <summary>
/// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA address space, and optionally hosting the status dashboard.
/// </summary>
public void Start()
{
Log.Information("LmxOpcUa service starting");
@@ -212,6 +220,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
}
}
/// <summary>
/// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in shutdown order.
/// </summary>
public void Stop()
{
Log.Information("LmxOpcUa service stopping");
@@ -282,13 +293,44 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
internal void TriggerRebuild() => OnGalaxyChanged();
// Accessors for testing
/// <summary>
/// Gets the MXAccess client instance currently wired into the service for test inspection.
/// </summary>
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
/// <summary>
/// Gets the metrics collector that tracks bridge operation timings during the service lifetime.
/// </summary>
internal PerformanceMetrics? Metrics => _metrics;
/// <summary>
/// Gets the OPC UA server host that owns the runtime endpoint.
/// </summary>
internal OpcUaServerHost? ServerHost => _serverHost;
/// <summary>
/// Gets the node manager instance that holds the current Galaxy-derived address space.
/// </summary>
internal LmxNodeManager? NodeManagerInstance => _nodeManager;
/// <summary>
/// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild.
/// </summary>
internal ChangeDetectionService? ChangeDetectionInstance => _changeDetection;
/// <summary>
/// Gets the hosted status web server when the dashboard is enabled.
/// </summary>
internal StatusWebServer? StatusWeb => _statusWebServer;
/// <summary>
/// Gets the dashboard report generator used to assemble operator-facing status snapshots.
/// </summary>
internal StatusReportService? StatusReportInstance => _statusReport;
/// <summary>
/// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds.
/// </summary>
internal GalaxyRepositoryStats? GalaxyStatsInstance => _galaxyStats;
}
@@ -297,18 +339,75 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// </summary>
internal sealed class NullMxAccessClient : IMxAccessClient
{
/// <summary>
/// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity.
/// </summary>
public ConnectionState State => ConnectionState.Disconnected;
/// <summary>
/// Gets the active subscription count, which is always zero for the null runtime client.
/// </summary>
public int ActiveSubscriptionCount => 0;
/// <summary>
/// Gets the reconnect count, which is always zero because the null client never establishes a session.
/// </summary>
public int ReconnectCount => 0;
/// <summary>
/// Occurs when the runtime connection state changes. The null client never raises this event.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed tag value changes. The null client never raises this event.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Completes immediately because no live runtime connection is available or required.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
public System.Threading.Tasks.Task ConnectAsync(CancellationToken ct = default) => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Completes immediately because there is no live runtime session to close.
/// </summary>
public System.Threading.Tasks.Task DisconnectAsync() => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Completes immediately because the null client does not subscribe to live Galaxy attributes.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been subscribed.</param>
/// <param name="callback">The callback that would have received runtime value changes.</param>
public System.Threading.Tasks.Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback) => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Completes immediately because the null client does not maintain runtime subscriptions.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been unsubscribed.</param>
public System.Threading.Tasks.Task UnsubscribeAsync(string fullTagReference) => System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Returns a bad-quality value because no live runtime source exists.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been read from the runtime.</param>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
/// <returns>A bad-quality VTQ indicating that runtime data is unavailable.</returns>
public System.Threading.Tasks.Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(Vtq.Bad());
/// <summary>
/// Rejects writes because there is no live runtime endpoint behind the null client.
/// </summary>
/// <param name="fullTagReference">The tag reference that would have been written.</param>
/// <param name="value">The value that would have been sent to the runtime.</param>
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
/// <returns>A completed task returning <see langword="false"/>.</returns>
public System.Threading.Tasks.Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(false);
/// <summary>
/// Releases the null client. No unmanaged runtime resources exist.
/// </summary>
public void Dispose() { }
}
}

View File

@@ -18,24 +18,44 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
private bool _galaxyRepositorySet;
private bool _mxAccessClientSet;
/// <summary>
/// Replaces the default service configuration used by the test host.
/// </summary>
/// <param name="config">The full configuration snapshot to inject into the service under test.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithConfig(AppConfiguration config)
{
_config = config;
return this;
}
/// <summary>
/// Sets the OPC UA port used by the test host so multiple integration runs can coexist.
/// </summary>
/// <param name="port">The TCP port to expose for the test server.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithOpcUaPort(int port)
{
_config.OpcUa.Port = port;
return this;
}
/// <summary>
/// Sets the Galaxy name represented by the test address space.
/// </summary>
/// <param name="name">The Galaxy name to expose through OPC UA and diagnostics.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithGalaxyName(string name)
{
_config.OpcUa.GalaxyName = name;
return this;
}
/// <summary>
/// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path.
/// </summary>
/// <param name="proxy">The proxy fake or stub to supply to the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy)
{
_mxProxy = proxy;
@@ -43,6 +63,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata.
/// </summary>
/// <param name="repository">The repository fake or stub to supply to the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository)
{
_galaxyRepository = repository;
@@ -54,6 +79,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// Override the MxAccessClient directly, skipping STA thread and COM interop entirely.
/// When set, the service will use this client instead of creating one from IMxProxy.
/// </summary>
/// <param name="client">The direct MXAccess client substitute to inject into the service.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client)
{
_mxAccessClient = client;
@@ -61,6 +88,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests.
/// </summary>
/// <param name="hierarchy">The object hierarchy to expose through the test OPC UA namespace.</param>
/// <param name="attributes">The attribute rows to attach to the hierarchy.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithHierarchy(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
if (!_galaxyRepositorySet)
@@ -79,18 +112,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener.
/// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder DisableDashboard()
{
_config.Dashboard.Enabled = false;
return this;
}
/// <summary>
/// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations.
/// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder DisableChangeDetection()
{
_config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue;
return this;
}
/// <summary>
/// Creates an <see cref="OpcUaService"/> using the accumulated test doubles and configuration overrides.
/// </summary>
/// <returns>A service instance ready for integration-style testing.</returns>
public OpcUaService Build()
{
return new OpcUaService(
@@ -106,16 +151,50 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// </summary>
private class FakeBuilderGalaxyRepository : IGalaxyRepository
{
/// <summary>
/// Occurs when the fake repository wants to simulate a Galaxy deploy change.
/// </summary>
public event System.Action? OnGalaxyChanged;
/// <summary>
/// Gets or sets the hierarchy rows that the fake repository returns to the service.
/// </summary>
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new();
/// <summary>
/// Gets or sets the attribute rows that the fake repository returns to the service.
/// </summary>
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
/// <summary>
/// Returns the seeded hierarchy rows for address-space construction.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The configured hierarchy rows.</returns>
public System.Threading.Tasks.Task<List<GalaxyObjectInfo>> GetHierarchyAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult(Hierarchy);
/// <summary>
/// Returns the seeded attribute rows for address-space construction.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The configured attribute rows.</returns>
public System.Threading.Tasks.Task<List<GalaxyAttributeInfo>> GetAttributesAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult(Attributes);
/// <summary>
/// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The current UTC time.</returns>
public System.Threading.Tasks.Task<System.DateTime?> GetLastDeployTimeAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult<System.DateTime?>(System.DateTime.UtcNow);
/// <summary>
/// Reports a healthy repository connection for builder-based test setups.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>A completed task returning <see langword="true"/>.</returns>
public System.Threading.Tasks.Task<bool> TestConnectionAsync(System.Threading.CancellationToken ct = default)
=> System.Threading.Tasks.Task.FromResult(true);
}

View File

@@ -8,6 +8,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
/// </summary>
public class HealthCheckService
{
/// <summary>
/// Evaluates bridge health from runtime connectivity and recorded performance metrics.
/// </summary>
/// <param name="connectionState">The current MXAccess connection state.</param>
/// <param name="metrics">The recorded performance metrics, if available.</param>
/// <returns>A dashboard health snapshot describing the current service condition.</returns>
public HealthInfo CheckHealth(ConnectionState connectionState, PerformanceMetrics? metrics)
{
// Rule 1: Not connected → Unhealthy
@@ -48,6 +54,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
};
}
/// <summary>
/// Determines whether the bridge should currently be treated as healthy.
/// </summary>
/// <param name="connectionState">The current MXAccess connection state.</param>
/// <param name="metrics">The recorded performance metrics, if available.</param>
/// <returns><see langword="true"/> when the bridge is not unhealthy; otherwise, <see langword="false"/>.</returns>
public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics)
{
var health = CheckHealth(connectionState, metrics);

View File

@@ -9,46 +9,139 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
/// </summary>
public class StatusData
{
/// <summary>
/// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard.
/// </summary>
public ConnectionInfo Connection { get; set; } = new();
/// <summary>
/// Gets or sets the overall health state communicated to operators.
/// </summary>
public HealthInfo Health { get; set; } = new();
/// <summary>
/// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining.
/// </summary>
public SubscriptionInfo Subscriptions { get; set; } = new();
/// <summary>
/// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts.
/// </summary>
public GalaxyInfo Galaxy { get; set; } = new();
/// <summary>
/// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency.
/// </summary>
public Dictionary<string, MetricsStatistics> Operations { get; set; } = new();
/// <summary>
/// Gets or sets footer details such as the snapshot timestamp and service version.
/// </summary>
public FooterInfo Footer { get; set; } = new();
}
/// <summary>
/// Dashboard model for current runtime connection details.
/// </summary>
public class ConnectionInfo
{
/// <summary>
/// Gets or sets the current MXAccess connection state shown to operators.
/// </summary>
public string State { get; set; } = "Disconnected";
/// <summary>
/// Gets or sets how many reconnect attempts have occurred since the service started.
/// </summary>
public int ReconnectCount { get; set; }
/// <summary>
/// Gets or sets the number of active OPC UA sessions connected to the bridge.
/// </summary>
public int ActiveSessions { get; set; }
}
/// <summary>
/// Dashboard model for the overall health banner.
/// </summary>
public class HealthInfo
{
/// <summary>
/// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy.
/// </summary>
public string Status { get; set; } = "Unknown";
/// <summary>
/// Gets or sets the operator-facing explanation for the current health state.
/// </summary>
public string Message { get; set; } = "";
/// <summary>
/// Gets or sets the color token used by the dashboard UI to render the health banner.
/// </summary>
public string Color { get; set; } = "gray";
}
/// <summary>
/// Dashboard model for subscription load.
/// </summary>
public class SubscriptionInfo
{
/// <summary>
/// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA.
/// </summary>
public int ActiveCount { get; set; }
}
/// <summary>
/// Dashboard model for Galaxy metadata and rebuild status.
/// </summary>
public class GalaxyInfo
{
/// <summary>
/// Gets or sets the Galaxy name currently being bridged into OPC UA.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the repository database is currently reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the most recent deploy timestamp observed in the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently represented in the address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last completed address-space rebuild.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}
/// <summary>
/// Dashboard model for the status page footer.
/// </summary>
public class FooterInfo
{
/// <summary>
/// Gets or sets the UTC time when the status snapshot was generated.
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the service version displayed to operators for support and traceability.
/// </summary>
public string Version { get; set; } = "";
}
}

View File

@@ -21,12 +21,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
private GalaxyRepositoryStats? _galaxyStats;
private OpcUaServerHost? _serverHost;
/// <summary>
/// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh interval.
/// </summary>
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds)
{
_healthCheck = healthCheck;
_refreshIntervalSeconds = refreshIntervalSeconds;
}
/// <summary>
/// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots.
/// </summary>
/// <param name="mxAccessClient">The runtime client whose connection and subscription state should be reported.</param>
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost)
{
@@ -36,6 +48,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
_serverHost = serverHost;
}
/// <summary>
/// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers.
/// </summary>
/// <returns>The current dashboard status data for the bridge.</returns>
public StatusData GetStatusData()
{
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
@@ -71,6 +87,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
};
}
/// <summary>
/// Generates the operator-facing HTML dashboard for the current bridge status.
/// </summary>
/// <returns>An HTML document containing the latest dashboard snapshot.</returns>
public string GenerateHtml()
{
var data = GetStatusData();
@@ -131,12 +151,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
return sb.ToString();
}
/// <summary>
/// Generates an indented JSON status payload for API consumers.
/// </summary>
/// <returns>A JSON representation of the current dashboard snapshot.</returns>
public string GenerateJson()
{
var data = GetStatusData();
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>
/// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint.
/// </summary>
/// <returns><see langword="true"/> when the bridge meets the health policy; otherwise, <see langword="false"/>.</returns>
public bool IsHealthy()
{
var state = _mxAccessClient?.State ?? ConnectionState.Disconnected;

View File

@@ -19,14 +19,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
private HttpListener? _listener;
private CancellationTokenSource? _cts;
/// <summary>
/// Gets a value indicating whether the dashboard listener is currently accepting requests.
/// </summary>
public bool IsRunning => _listener?.IsListening ?? false;
/// <summary>
/// Initializes a new dashboard web server bound to the supplied report service and HTTP port.
/// </summary>
/// <param name="reportService">The report service used to generate dashboard responses.</param>
/// <param name="port">The HTTP port to listen on.</param>
public StatusWebServer(StatusReportService reportService, int port)
{
_reportService = reportService;
_port = port;
}
/// <summary>
/// Starts the HTTP listener and background request loop for the status dashboard.
/// </summary>
public void Start()
{
try
@@ -47,6 +58,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
}
}
/// <summary>
/// Stops the dashboard listener and releases its HTTP resources.
/// </summary>
public void Stop()
{
_cts?.Cancel();
@@ -139,6 +153,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
response.Close();
}
/// <summary>
/// Stops the dashboard listener and releases its resources.
/// </summary>
public void Dispose() => Stop();
}
}

View File

@@ -8,8 +8,16 @@ using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository;
namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
{
/// <summary>
/// Integration tests that exercise the real Galaxy repository queries against the test database configuration.
/// </summary>
public class GalaxyRepositoryServiceTests
{
/// <summary>
/// Loads repository configuration from the integration test settings and controls whether extended attributes are enabled.
/// </summary>
/// <param name="extendedAttributes">A value indicating whether the extended attribute query path should be enabled.</param>
/// <returns>The repository configuration used by the integration test.</returns>
private static GalaxyRepositoryConfiguration LoadConfig(bool extendedAttributes = false)
{
var configuration = new ConfigurationBuilder()
@@ -22,6 +30,9 @@ namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
return config;
}
/// <summary>
/// Confirms that the standard attribute query returns rows from the repository.
/// </summary>
[Fact]
public async Task GetAttributesAsync_StandardMode_ReturnsRows()
{
@@ -35,6 +46,9 @@ namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
results.ShouldAllBe(r => r.PrimitiveName == "" && r.AttributeSource == "");
}
/// <summary>
/// Confirms that the extended attribute query returns more rows than the standard query path.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_ReturnsMoreRows()
{
@@ -49,6 +63,9 @@ namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
extendedResults.Count.ShouldBeGreaterThan(standardResults.Count);
}
/// <summary>
/// Confirms that the extended attribute query includes both primitive and dynamic attribute sources.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_IncludesPrimitiveAttributes()
{
@@ -61,6 +78,9 @@ namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
results.ShouldContain(r => r.AttributeSource == "dynamic");
}
/// <summary>
/// Confirms that extended mode populates attribute-source metadata across the result set.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_PrimitiveNamePopulated()
{
@@ -76,6 +96,9 @@ namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
results.ShouldAllBe(r => r.AttributeSource == "primitive" || r.AttributeSource == "dynamic");
}
/// <summary>
/// Confirms that standard-mode results always include fully qualified tag references.
/// </summary>
[Fact]
public async Task GetAttributesAsync_StandardMode_AllHaveFullTagReference()
{
@@ -88,6 +111,9 @@ namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
results.ShouldAllBe(r => r.FullTagReference.Contains("."));
}
/// <summary>
/// Confirms that extended-mode results always include fully qualified tag references.
/// </summary>
[Fact]
public async Task GetAttributesAsync_ExtendedMode_AllHaveFullTagReference()
{

View File

@@ -3,8 +3,14 @@ using Xunit;
namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
{
/// <summary>
/// Placeholder integration test that keeps the integration test project wired into the solution.
/// </summary>
public class SampleIntegrationTest
{
/// <summary>
/// Confirms that the integration test assembly is executing.
/// </summary>
[Fact]
public void Placeholder_ShouldPass()
{

View File

@@ -5,8 +5,15 @@ using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
{
/// <summary>
/// Verifies that application configuration binds correctly from appsettings and that validation catches invalid bridge settings.
/// </summary>
public class ConfigurationLoadingTests
{
/// <summary>
/// Loads the application configuration from the repository appsettings file for binding tests.
/// </summary>
/// <returns>The bound application configuration snapshot.</returns>
private static AppConfiguration LoadFromJson()
{
var configuration = new ConfigurationBuilder()
@@ -21,6 +28,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
return config;
}
/// <summary>
/// Confirms that the OPC UA section binds the endpoint and session settings expected by the bridge.
/// </summary>
[Fact]
public void OpcUa_Section_BindsCorrectly()
{
@@ -33,6 +43,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.OpcUa.SessionTimeoutMinutes.ShouldBe(30);
}
/// <summary>
/// Confirms that the MXAccess section binds runtime timeout and reconnect settings correctly.
/// </summary>
[Fact]
public void MxAccess_Section_BindsCorrectly()
{
@@ -46,6 +59,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.MxAccess.ProbeStaleThresholdSeconds.ShouldBe(60);
}
/// <summary>
/// Confirms that the Galaxy repository section binds connection and polling settings correctly.
/// </summary>
[Fact]
public void GalaxyRepository_Section_BindsCorrectly()
{
@@ -56,6 +72,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.GalaxyRepository.ExtendedAttributes.ShouldBe(false);
}
/// <summary>
/// Confirms that extended-attribute loading defaults to disabled when not configured.
/// </summary>
[Fact]
public void GalaxyRepository_ExtendedAttributes_DefaultsFalse()
{
@@ -63,6 +82,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.ExtendedAttributes.ShouldBe(false);
}
/// <summary>
/// Confirms that the extended-attribute flag can be enabled through configuration binding.
/// </summary>
[Fact]
public void GalaxyRepository_ExtendedAttributes_BindsFromJson()
{
@@ -76,6 +98,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.ExtendedAttributes.ShouldBe(true);
}
/// <summary>
/// Confirms that the dashboard section binds operator-dashboard settings correctly.
/// </summary>
[Fact]
public void Dashboard_Section_BindsCorrectly()
{
@@ -85,6 +110,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.Dashboard.RefreshIntervalSeconds.ShouldBe(10);
}
/// <summary>
/// Confirms that the default configuration objects start with the expected bridge defaults.
/// </summary>
[Fact]
public void DefaultValues_AreCorrect()
{
@@ -95,6 +123,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.Dashboard.Enabled.ShouldBe(true);
}
/// <summary>
/// Confirms that a valid configuration passes startup validation.
/// </summary>
[Fact]
public void Validator_ValidConfig_ReturnsTrue()
{
@@ -102,6 +133,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
}
/// <summary>
/// Confirms that an invalid OPC UA port is rejected by startup validation.
/// </summary>
[Fact]
public void Validator_InvalidPort_ReturnsFalse()
{
@@ -110,6 +144,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Confirms that an empty Galaxy name is rejected because the bridge requires a namespace target.
/// </summary>
[Fact]
public void Validator_EmptyGalaxyName_ReturnsFalse()
{

View File

@@ -4,8 +4,14 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
{
/// <summary>
/// Verifies default and extended-field behavior for Galaxy attribute metadata objects.
/// </summary>
public class GalaxyAttributeInfoTests
{
/// <summary>
/// Confirms that a default attribute metadata object starts with empty strings for its text fields.
/// </summary>
[Fact]
public void DefaultValues_AreEmpty()
{
@@ -18,6 +24,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
info.DataTypeName.ShouldBe("");
}
/// <summary>
/// Confirms that primitive-name and attribute-source fields can be populated for extended metadata rows.
/// </summary>
[Fact]
public void ExtendedFields_CanBeSet()
{
@@ -30,6 +39,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
info.AttributeSource.ShouldBe("primitive");
}
/// <summary>
/// Confirms that standard attribute rows leave the extended metadata fields empty.
/// </summary>
[Fact]
public void StandardAttributes_HaveEmptyExtendedFields()
{

View File

@@ -5,8 +5,16 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
{
/// <summary>
/// Verifies how Galaxy MX data types are mapped into OPC UA and CLR types by the bridge.
/// </summary>
public class MxDataTypeMapperTests
{
/// <summary>
/// Confirms that known Galaxy MX data types map to the expected OPC UA data type node identifiers.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <param name="expectedNodeId">The expected OPC UA data type node identifier.</param>
[Theory]
[InlineData(1, 1u)] // Boolean
[InlineData(2, 6u)] // Integer → Int32
@@ -25,6 +33,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(expectedNodeId);
}
/// <summary>
/// Confirms that unknown MX data types default to the OPC UA string data type.
/// </summary>
/// <param name="mxDataType">The unsupported MX data type code.</param>
[Theory]
[InlineData(0)]
[InlineData(99)]
@@ -34,6 +46,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(12u); // String
}
/// <summary>
/// Confirms that known MX data types map to the expected CLR runtime types.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <param name="expectedType">The expected CLR type used by the bridge.</param>
[Theory]
[InlineData(1, typeof(bool))]
[InlineData(2, typeof(int))]
@@ -50,18 +67,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
MxDataTypeMapper.MapToClrType(mxDataType).ShouldBe(expectedType);
}
/// <summary>
/// Confirms that unknown MX data types default to the CLR string type.
/// </summary>
[Fact]
public void MapToClrType_UnknownDefaultsToString()
{
MxDataTypeMapper.MapToClrType(999).ShouldBe(typeof(string));
}
/// <summary>
/// Confirms that the boolean MX type reports the expected OPC UA type name.
/// </summary>
[Fact]
public void GetOpcUaTypeName_Boolean()
{
MxDataTypeMapper.GetOpcUaTypeName(1).ShouldBe("Boolean");
}
/// <summary>
/// Confirms that unknown MX types report the fallback OPC UA type name of string.
/// </summary>
[Fact]
public void GetOpcUaTypeName_Unknown_ReturnsString()
{

View File

@@ -4,8 +4,16 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
{
/// <summary>
/// Verifies the operator-facing error messages and quality mappings derived from MXAccess error codes.
/// </summary>
public class MxErrorCodesTests
{
/// <summary>
/// Confirms that known MXAccess error codes produce readable operator-facing descriptions.
/// </summary>
/// <param name="code">The MXAccess error code.</param>
/// <param name="expectedSubstring">A substring expected in the returned description.</param>
[Theory]
[InlineData(1008, "Invalid reference")]
[InlineData(1012, "Wrong data type")]
@@ -18,6 +26,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
MxErrorCodes.GetMessage(code).ShouldContain(expectedSubstring);
}
/// <summary>
/// Confirms that unknown MXAccess error codes are reported as unknown while preserving the numeric code.
/// </summary>
[Fact]
public void GetMessage_UnknownCode_ReturnsUnknown()
{
@@ -25,6 +36,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
MxErrorCodes.GetMessage(9999).ShouldContain("9999");
}
/// <summary>
/// Confirms that known MXAccess error codes map to the expected bridge quality values.
/// </summary>
/// <param name="code">The MXAccess error code.</param>
/// <param name="expected">The expected bridge quality value.</param>
[Theory]
[InlineData(1008, Quality.BadConfigError)]
[InlineData(1012, Quality.BadConfigError)]
@@ -37,6 +53,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
MxErrorCodes.MapToQuality(code).ShouldBe(expected);
}
/// <summary>
/// Confirms that unknown MXAccess error codes map to the generic bad quality bucket.
/// </summary>
[Fact]
public void MapToQuality_UnknownCode_ReturnsBad()
{

View File

@@ -4,8 +4,16 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
{
/// <summary>
/// Verifies the mapping between MXAccess quality codes, bridge quality values, and OPC UA status codes.
/// </summary>
public class QualityMapperTests
{
/// <summary>
/// Confirms that bad-family MXAccess quality values map to the expected bridge quality values.
/// </summary>
/// <param name="input">The raw MXAccess quality code.</param>
/// <param name="expected">The bridge quality value expected for the code.</param>
[Theory]
[InlineData(0, Quality.Bad)]
[InlineData(4, Quality.BadConfigError)]
@@ -16,6 +24,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
}
/// <summary>
/// Confirms that uncertain-family MXAccess quality values map to the expected bridge quality values.
/// </summary>
/// <param name="input">The raw MXAccess quality code.</param>
/// <param name="expected">The bridge quality value expected for the code.</param>
[Theory]
[InlineData(64, Quality.Uncertain)]
[InlineData(68, Quality.UncertainLastUsable)]
@@ -25,6 +38,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
}
/// <summary>
/// Confirms that good-family MXAccess quality values map to the expected bridge quality values.
/// </summary>
/// <param name="input">The raw MXAccess quality code.</param>
/// <param name="expected">The bridge quality value expected for the code.</param>
[Theory]
[InlineData(192, Quality.Good)]
[InlineData(216, Quality.GoodLocalOverride)]
@@ -33,48 +51,72 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
}
/// <summary>
/// Confirms that unknown bad-family values collapse to the generic bad quality bucket.
/// </summary>
[Fact]
public void MapFromMxAccess_UnknownBadValue_ReturnsBad()
{
QualityMapper.MapFromMxAccessQuality(63).ShouldBe(Quality.Bad);
}
/// <summary>
/// Confirms that unknown uncertain-family values collapse to the generic uncertain quality bucket.
/// </summary>
[Fact]
public void MapFromMxAccess_UnknownUncertainValue_ReturnsUncertain()
{
QualityMapper.MapFromMxAccessQuality(100).ShouldBe(Quality.Uncertain);
}
/// <summary>
/// Confirms that unknown good-family values collapse to the generic good quality bucket.
/// </summary>
[Fact]
public void MapFromMxAccess_UnknownGoodValue_ReturnsGood()
{
QualityMapper.MapFromMxAccessQuality(200).ShouldBe(Quality.Good);
}
/// <summary>
/// Confirms that the generic good quality maps to the OPC UA good status code.
/// </summary>
[Fact]
public void MapToOpcUa_Good_Returns0()
{
QualityMapper.MapToOpcUaStatusCode(Quality.Good).ShouldBe(0x00000000u);
}
/// <summary>
/// Confirms that the generic bad quality maps to the OPC UA bad status code.
/// </summary>
[Fact]
public void MapToOpcUa_Bad_Returns80000000()
{
QualityMapper.MapToOpcUaStatusCode(Quality.Bad).ShouldBe(0x80000000u);
}
/// <summary>
/// Confirms that communication failures map to the OPC UA bad communication-failure status code.
/// </summary>
[Fact]
public void MapToOpcUa_BadCommFailure()
{
QualityMapper.MapToOpcUaStatusCode(Quality.BadCommFailure).ShouldBe(0x80050000u);
}
/// <summary>
/// Confirms that the generic uncertain quality maps to the OPC UA uncertain status code.
/// </summary>
[Fact]
public void MapToOpcUa_Uncertain()
{
QualityMapper.MapToOpcUaStatusCode(Quality.Uncertain).ShouldBe(0x40000000u);
}
/// <summary>
/// Confirms that good quality values are classified correctly by the quality extension helpers.
/// </summary>
[Fact]
public void QualityExtensions_IsGood()
{
@@ -83,6 +125,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
Quality.Good.IsUncertain().ShouldBe(false);
}
/// <summary>
/// Confirms that bad quality values are classified correctly by the quality extension helpers.
/// </summary>
[Fact]
public void QualityExtensions_IsBad()
{
@@ -90,6 +135,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
Quality.Bad.IsGood().ShouldBe(false);
}
/// <summary>
/// Confirms that uncertain quality values are classified correctly by the quality extension helpers.
/// </summary>
[Fact]
public void QualityExtensions_IsUncertain()
{

View File

@@ -17,6 +17,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.EndToEnd
/// </summary>
public class FullDataFlowTest
{
/// <summary>
/// Confirms that the fake-backed bridge can start, build the address space, and expose coherent status data end to end.
/// </summary>
[Fact]
public void FullDataFlow_EndToEnd()
{

View File

@@ -8,8 +8,14 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.GalaxyRepository
{
/// <summary>
/// Verifies the polling service that detects Galaxy deploy changes and triggers address-space rebuilds.
/// </summary>
public class ChangeDetectionServiceTests
{
/// <summary>
/// Confirms that the first poll always triggers an initial rebuild notification.
/// </summary>
[Fact]
public async Task FirstPoll_AlwaysTriggers()
{
@@ -26,6 +32,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.GalaxyRepository
service.Dispose();
}
/// <summary>
/// Confirms that repeated polls with the same deploy timestamp do not retrigger rebuilds.
/// </summary>
[Fact]
public async Task SameTimestamp_DoesNotTriggerAgain()
{
@@ -42,6 +51,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.GalaxyRepository
service.Dispose();
}
/// <summary>
/// Confirms that a changed deploy timestamp triggers another rebuild notification.
/// </summary>
[Fact]
public async Task ChangedTimestamp_TriggersAgain()
{
@@ -62,6 +74,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.GalaxyRepository
service.Dispose();
}
/// <summary>
/// Confirms that transient polling failures do not crash the service and allow later recovery.
/// </summary>
[Fact]
public async Task FailedPoll_DoesNotCrash_RetriesNext()
{
@@ -88,6 +103,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.GalaxyRepository
service.Dispose();
}
/// <summary>
/// Confirms that stopping the service before it starts is a harmless no-op.
/// </summary>
[Fact]
public void Stop_BeforeStart_DoesNotThrow()
{

View File

@@ -6,40 +6,88 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
/// <summary>
/// In-memory Galaxy repository used by tests to control hierarchy rows, attribute rows, and deploy metadata without SQL access.
/// </summary>
public class FakeGalaxyRepository : IGalaxyRepository
{
/// <summary>
/// Occurs when the fake repository simulates a Galaxy deploy change.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Gets or sets the hierarchy rows returned to address-space construction logic.
/// </summary>
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new List<GalaxyObjectInfo>();
/// <summary>
/// Gets or sets the attribute rows returned to address-space construction logic.
/// </summary>
public List<GalaxyAttributeInfo> Attributes { get; set; } = new List<GalaxyAttributeInfo>();
/// <summary>
/// Gets or sets the deploy timestamp returned to change-detection logic.
/// </summary>
public DateTime? LastDeployTime { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets a value indicating whether connection checks should report success.
/// </summary>
public bool ConnectionSucceeds { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether repository calls should throw to simulate database failures.
/// </summary>
public bool ShouldThrow { get; set; }
/// <summary>
/// Returns the configured hierarchy rows or throws to simulate a repository failure.
/// </summary>
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
/// <returns>The configured hierarchy rows.</returns>
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(Hierarchy);
}
/// <summary>
/// Returns the configured attribute rows or throws to simulate a repository failure.
/// </summary>
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
/// <returns>The configured attribute rows.</returns>
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(Attributes);
}
/// <summary>
/// Returns the configured deploy timestamp or throws to simulate a repository failure.
/// </summary>
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
/// <returns>The configured deploy timestamp.</returns>
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(LastDeployTime);
}
/// <summary>
/// Returns the configured connection result or throws to simulate a repository failure.
/// </summary>
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
/// <returns>The configured connection result.</returns>
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(ConnectionSucceeds);
}
/// <summary>
/// Raises the deploy-change event so tests can trigger rebuild logic.
/// </summary>
public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke();
}
}

View File

@@ -7,44 +7,99 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
/// <summary>
/// In-memory IMxAccessClient used by tests to drive connection, read, write, and subscription scenarios without COM runtime dependencies.
/// </summary>
public class FakeMxAccessClient : IMxAccessClient
{
/// <summary>
/// Gets or sets the connection state returned to the system under test.
/// </summary>
public ConnectionState State { get; set; } = ConnectionState.Connected;
/// <summary>
/// Gets the number of active subscriptions currently stored by the fake client.
/// </summary>
public int ActiveSubscriptionCount => _subscriptions.Count;
/// <summary>
/// Gets or sets the reconnect count exposed to health and dashboard tests.
/// </summary>
public int ReconnectCount { get; set; }
/// <summary>
/// Occurs when tests explicitly simulate a connection-state transition.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when tests publish a simulated runtime value change.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _subscriptions = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the in-memory tag-value table returned by fake reads.
/// </summary>
public ConcurrentDictionary<string, Vtq> TagValues { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the values written through the fake client so tests can assert write behavior.
/// </summary>
public List<(string Tag, object Value)> WrittenValues { get; } = new();
/// <summary>
/// Gets or sets the result returned by fake writes to simulate success or failure.
/// </summary>
public bool WriteResult { get; set; } = true;
/// <summary>
/// Simulates establishing a healthy runtime connection.
/// </summary>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
public Task ConnectAsync(CancellationToken ct = default)
{
State = ConnectionState.Connected;
return Task.CompletedTask;
}
/// <summary>
/// Simulates disconnecting from the runtime.
/// </summary>
public Task DisconnectAsync()
{
State = ConnectionState.Disconnected;
return Task.CompletedTask;
}
/// <summary>
/// Stores a subscription callback so later simulated data changes can target it.
/// </summary>
/// <param name="fullTagReference">The Galaxy attribute reference to monitor.</param>
/// <param name="callback">The callback that should receive simulated value changes.</param>
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
_subscriptions[fullTagReference] = callback;
return Task.CompletedTask;
}
/// <summary>
/// Removes a stored subscription callback for the specified tag reference.
/// </summary>
/// <param name="fullTagReference">The Galaxy attribute reference to stop monitoring.</param>
public Task UnsubscribeAsync(string fullTagReference)
{
_subscriptions.TryRemove(fullTagReference, out _);
return Task.CompletedTask;
}
/// <summary>
/// Returns the current in-memory VTQ for a tag reference or a bad-quality placeholder when none has been seeded.
/// </summary>
/// <param name="fullTagReference">The Galaxy attribute reference to read.</param>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>The seeded VTQ value or a bad not-connected VTQ when the tag was not populated.</returns>
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
{
if (TagValues.TryGetValue(fullTagReference, out var vtq))
@@ -52,6 +107,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return Task.FromResult(Vtq.Bad(Quality.BadNotConnected));
}
/// <summary>
/// Records a write request, optionally updates the in-memory tag table, and returns the configured write result.
/// </summary>
/// <param name="fullTagReference">The Galaxy attribute reference being written.</param>
/// <param name="value">The value supplied by the code under test.</param>
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
/// <returns>A completed task returning the configured write outcome.</returns>
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
WrittenValues.Add((fullTagReference, value));
@@ -60,6 +122,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return Task.FromResult(WriteResult);
}
/// <summary>
/// Publishes a simulated tag-value change to both the event stream and any stored subscription callback.
/// </summary>
/// <param name="address">The Galaxy attribute reference whose value changed.</param>
/// <param name="vtq">The value, timestamp, and quality payload to publish.</param>
public void SimulateDataChange(string address, Vtq vtq)
{
OnTagValueChanged?.Invoke(address, vtq);
@@ -67,12 +134,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
callback(address, vtq);
}
/// <summary>
/// Raises a simulated connection-state transition for health and reconnect tests.
/// </summary>
/// <param name="prev">The previous connection state.</param>
/// <param name="curr">The new connection state.</param>
public void RaiseConnectionStateChanged(ConnectionState prev, ConnectionState curr)
{
State = curr;
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(prev, curr));
}
/// <summary>
/// Releases the fake client. No unmanaged resources are held.
/// </summary>
public void Dispose() { }
}
}

View File

@@ -17,21 +17,71 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
private int _connectionHandle;
private bool _registered;
/// <summary>
/// Occurs when the fake proxy publishes a simulated runtime data-change callback to the system under test.
/// </summary>
public event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the fake proxy publishes a simulated write-complete callback to the system under test.
/// </summary>
public event MxWriteCompleteHandler? OnWriteComplete;
/// <summary>
/// Gets the item-handle to tag-reference map built by the test as attributes are registered with the fake runtime.
/// </summary>
public ConcurrentDictionary<int, string> Items { get; } = new ConcurrentDictionary<int, string>();
/// <summary>
/// Gets the item handles currently marked as advised so tests can assert subscription behavior.
/// </summary>
public ConcurrentDictionary<int, bool> AdvisedItems { get; } = new ConcurrentDictionary<int, bool>();
/// <summary>
/// Gets the values written through the fake runtime so write scenarios can assert the final payload.
/// </summary>
public List<(string Address, object Value)> WrittenValues { get; } = new List<(string, object)>();
/// <summary>
/// Gets a value indicating whether the fake runtime is currently considered registered.
/// </summary>
public bool IsRegistered => _registered;
/// <summary>
/// Gets the number of times the system under test attempted to register with the fake runtime.
/// </summary>
public int RegisterCallCount { get; private set; }
/// <summary>
/// Gets the number of times the system under test attempted to unregister from the fake runtime.
/// </summary>
public int UnregisterCallCount { get; private set; }
/// <summary>
/// Gets or sets a value indicating whether registration should fail to exercise connection-error paths.
/// </summary>
public bool ShouldFailRegister { get; set; }
/// <summary>
/// Gets or sets a value indicating whether writes should fail to exercise runtime write-error paths.
/// </summary>
public bool ShouldFailWrite { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the fake should suppress the write-complete callback for timeout scenarios.
/// </summary>
public bool SkipWriteCompleteCallback { get; set; }
/// <summary>
/// Gets or sets the status code returned in the simulated write-complete callback.
/// </summary>
public int WriteCompleteStatus { get; set; } = 0; // 0 = success
/// <summary>
/// Simulates the MXAccess registration handshake and returns a synthetic connection handle.
/// </summary>
/// <param name="clientName">The client name supplied by the code under test.</param>
/// <returns>A synthetic connection handle for subsequent fake operations.</returns>
public int Register(string clientName)
{
RegisterCallCount++;
@@ -41,6 +91,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return _connectionHandle;
}
/// <summary>
/// Simulates tearing down the fake MXAccess connection.
/// </summary>
/// <param name="handle">The connection handle supplied by the code under test.</param>
public void Unregister(int handle)
{
UnregisterCallCount++;
@@ -48,6 +102,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
_connectionHandle = 0;
}
/// <summary>
/// Simulates resolving a tag reference into a fake runtime item handle.
/// </summary>
/// <param name="handle">The synthetic connection handle.</param>
/// <param name="address">The Galaxy attribute reference being registered.</param>
/// <returns>A synthetic item handle.</returns>
public int AddItem(int handle, string address)
{
var itemHandle = Interlocked.Increment(ref _nextHandle);
@@ -55,21 +115,43 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return itemHandle;
}
/// <summary>
/// Simulates removing an item from the fake runtime session.
/// </summary>
/// <param name="handle">The synthetic connection handle.</param>
/// <param name="itemHandle">The synthetic item handle to remove.</param>
public void RemoveItem(int handle, int itemHandle)
{
Items.TryRemove(itemHandle, out _);
}
/// <summary>
/// Marks an item as actively advised so tests can assert subscription activation.
/// </summary>
/// <param name="handle">The synthetic connection handle.</param>
/// <param name="itemHandle">The synthetic item handle being monitored.</param>
public void AdviseSupervisory(int handle, int itemHandle)
{
AdvisedItems[itemHandle] = true;
}
/// <summary>
/// Marks an item as no longer advised so tests can assert subscription teardown.
/// </summary>
/// <param name="handle">The synthetic connection handle.</param>
/// <param name="itemHandle">The synthetic item handle no longer being monitored.</param>
public void UnAdviseSupervisory(int handle, int itemHandle)
{
AdvisedItems.TryRemove(itemHandle, out _);
}
/// <summary>
/// Simulates a runtime write, records the written value, and optionally raises the write-complete callback.
/// </summary>
/// <param name="handle">The synthetic connection handle.</param>
/// <param name="itemHandle">The synthetic item handle to write.</param>
/// <param name="value">The value supplied by the system under test.</param>
/// <param name="securityClassification">The security classification supplied with the write request.</param>
public void Write(int handle, int itemHandle, object value, int securityClassification)
{
if (ShouldFailWrite) throw new InvalidOperationException("Write failed (simulated)");
@@ -95,6 +177,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <summary>
/// Simulates an MXAccess data change event for a specific item handle.
/// </summary>
/// <param name="itemHandle">The synthetic item handle that should receive the new value.</param>
/// <param name="value">The value to publish to the system under test.</param>
/// <param name="quality">The runtime quality code to send with the value.</param>
/// <param name="timestamp">The optional timestamp to send with the value; defaults to the current UTC time.</param>
public void SimulateDataChange(int itemHandle, object value, int quality = 192, DateTime? timestamp = null)
{
var status = new MXSTATUS_PROXY[1];
@@ -106,6 +192,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <summary>
/// Simulates data change for a specific address (finds handle by address).
/// </summary>
/// <param name="address">The Galaxy attribute reference whose registered handle should receive the new value.</param>
/// <param name="value">The value to publish to the system under test.</param>
/// <param name="quality">The runtime quality code to send with the value.</param>
/// <param name="timestamp">The optional timestamp to send with the value; defaults to the current UTC time.</param>
public void SimulateDataChangeByAddress(string address, object value, int quality = 192, DateTime? timestamp = null)
{
foreach (var kvp in Items)

View File

@@ -21,8 +21,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
private static int _nextPort = 16000;
/// <summary>
/// Gets the started service instance managed by the fixture.
/// </summary>
public OpcUaService Service { get; private set; } = null!;
/// <summary>
/// Gets the OPC UA port assigned to this fixture instance.
/// </summary>
public int OpcUaPort { get; }
/// <summary>
/// Gets the OPC UA endpoint URL exposed by the fixture.
/// </summary>
public string EndpointUrl => $"opc.tcp://localhost:{OpcUaPort}/LmxOpcUa";
/// <summary>
@@ -44,6 +55,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
private readonly OpcUaServiceBuilder _builder;
private bool _started;
/// <summary>
/// Initializes a fixture around a prepared service builder and optional fake dependencies.
/// </summary>
/// <param name="builder">The builder used to construct the service under test.</param>
/// <param name="repo">The optional fake Galaxy repository exposed to tests.</param>
/// <param name="mxClient">The optional fake MXAccess client exposed to tests.</param>
/// <param name="mxProxy">The optional fake MXAccess proxy exposed to tests.</param>
private OpcUaServerFixture(OpcUaServiceBuilder builder,
FakeGalaxyRepository? repo = null,
FakeMxAccessClient? mxClient = null,
@@ -62,6 +80,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// Creates fixture with FakeMxProxy + FakeGalaxyRepository (standard test data).
/// The STA thread and COM interop run against FakeMxProxy.
/// </summary>
/// <param name="proxy">An optional fake proxy to inject; otherwise a default fake is created.</param>
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
/// <returns>A fixture configured to exercise the COM-style runtime path.</returns>
public static OpcUaServerFixture WithFakes(
FakeMxProxy? proxy = null,
FakeGalaxyRepository? repo = null)
@@ -85,6 +106,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// Creates fixture using FakeMxAccessClient directly — skips STA thread + COM entirely.
/// Fastest option for tests that don't need real COM interop.
/// </summary>
/// <param name="mxClient">An optional fake MXAccess client to inject; otherwise a default fake is created.</param>
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
/// <returns>A fixture configured to exercise the direct fake-client path.</returns>
public static OpcUaServerFixture WithFakeMxAccessClient(
FakeMxAccessClient? mxClient = null,
FakeGalaxyRepository? repo = null)
@@ -104,6 +128,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return new OpcUaServerFixture(builder, repo: r, mxClient: client);
}
/// <summary>
/// Builds and starts the OPC UA service for the current fixture.
/// </summary>
public Task InitializeAsync()
{
Service = _builder.Build();
@@ -112,6 +139,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return Task.CompletedTask;
}
/// <summary>
/// Stops the OPC UA service when the fixture had previously been started.
/// </summary>
public Task DisposeAsync()
{
if (_started)

View File

@@ -6,8 +6,14 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
/// <summary>
/// Verifies the reusable OPC UA server fixture used by integration and wiring tests.
/// </summary>
public class OpcUaServerFixtureTests
{
/// <summary>
/// Confirms that the standard fake-backed fixture starts the bridge and tears it down cleanly.
/// </summary>
[Fact]
public async Task WithFakes_StartsAndStops()
{
@@ -25,6 +31,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
await fixture.DisposeAsync();
}
/// <summary>
/// Confirms that the fake-client fixture bypasses COM wiring and uses the provided fake runtime client.
/// </summary>
[Fact]
public async Task WithFakeMxAccessClient_SkipsCom()
{
@@ -38,6 +47,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
await fixture.DisposeAsync();
}
/// <summary>
/// Confirms that separate fixture instances automatically allocate unique OPC UA ports.
/// </summary>
[Fact]
public async Task MultipleFixtures_GetUniquePortsAutomatically()
{
@@ -57,6 +69,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
await fixture2.DisposeAsync();
}
/// <summary>
/// Confirms that fixture shutdown completes quickly enough for the integration test suite.
/// </summary>
[Fact]
public async Task Shutdown_CompletesWithin30Seconds()
{
@@ -70,6 +85,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
sw.Elapsed.TotalSeconds.ShouldBeLessThan(30);
}
/// <summary>
/// Confirms that the fake-backed fixture builds the seeded address space and Galaxy statistics.
/// </summary>
[Fact]
public async Task WithFakes_BuildsAddressSpace()
{

View File

@@ -16,11 +16,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
private Session? _session;
/// <summary>
/// Gets the active OPC UA session used by integration tests once the helper has connected to the bridge.
/// </summary>
public Session Session => _session ?? throw new InvalidOperationException("Not connected");
/// <summary>
/// Resolves the namespace index for a given namespace URI (e.g., "urn:TestGalaxy:LmxOpcUa").
/// </summary>
/// <param name="galaxyName">The Galaxy name whose OPC UA namespace should be resolved on the test server.</param>
/// <returns>The namespace index assigned by the server for the requested Galaxy namespace.</returns>
public ushort GetNamespaceIndex(string galaxyName = "TestGalaxy")
{
var nsUri = $"urn:{galaxyName}:LmxOpcUa";
@@ -32,11 +37,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <summary>
/// Creates a NodeId in the LmxOpcUa namespace using the server's actual namespace index.
/// </summary>
/// <param name="identifier">The string identifier for the node inside the Galaxy namespace.</param>
/// <param name="galaxyName">The Galaxy name whose namespace should be used for the node identifier.</param>
/// <returns>A node identifier that targets the requested node on the test server.</returns>
public NodeId MakeNodeId(string identifier, string galaxyName = "TestGalaxy")
{
return new NodeId(identifier, GetNamespaceIndex(galaxyName));
}
/// <summary>
/// Connects the helper to an OPC UA endpoint exposed by the test bridge.
/// </summary>
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
public async Task ConnectAsync(string endpointUrl)
{
var config = new ApplicationConfiguration
@@ -87,6 +99,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <summary>
/// Browse children of a node. Returns list of (DisplayName, NodeId, NodeClass).
/// </summary>
/// <param name="nodeId">The node whose hierarchical children should be browsed.</param>
/// <returns>The child nodes exposed beneath the requested node.</returns>
public async Task<List<(string Name, NodeId NodeId, NodeClass NodeClass)>> BrowseAsync(NodeId nodeId)
{
var results = new List<(string, NodeId, NodeClass)>();
@@ -109,6 +123,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <summary>
/// Read a node's value.
/// </summary>
/// <param name="nodeId">The node whose current value should be read from the server.</param>
/// <returns>The OPC UA data value returned by the server.</returns>
public DataValue Read(NodeId nodeId)
{
return Session.ReadValue(nodeId);
@@ -118,6 +134,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// Write a node's value, optionally using an OPC UA index range for array element writes.
/// Returns the server status code for the write.
/// </summary>
/// <param name="nodeId">The node whose value should be written.</param>
/// <param name="value">The value to send to the server.</param>
/// <param name="indexRange">An optional OPC UA index range used for array element writes.</param>
/// <returns>The server status code returned for the write request.</returns>
public StatusCode Write(NodeId nodeId, object value, string? indexRange = null)
{
var nodesToWrite = new WriteValueCollection
@@ -139,6 +159,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// Create a subscription with a monitored item on the given node.
/// Returns the subscription and monitored item for inspection.
/// </summary>
/// <param name="nodeId">The node whose value changes should be monitored.</param>
/// <param name="intervalMs">The publishing and sampling interval, in milliseconds, for the test subscription.</param>
/// <returns>The created subscription and monitored item pair for later assertions and cleanup.</returns>
public async Task<(Subscription Sub, MonitoredItem Item)> SubscribeAsync(
NodeId nodeId, int intervalMs = 250)
{
@@ -162,6 +185,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return (subscription, item);
}
/// <summary>
/// Closes the test session and releases OPC UA client resources.
/// </summary>
public void Dispose()
{
if (_session != null)

View File

@@ -8,6 +8,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// </summary>
public static class TestData
{
/// <summary>
/// Creates the standard Galaxy hierarchy used by integration and wiring tests.
/// </summary>
/// <returns>The standard hierarchy rows for the fake repository.</returns>
public static List<GalaxyObjectInfo> CreateStandardHierarchy()
{
return new List<GalaxyObjectInfo>
@@ -20,6 +24,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
};
}
/// <summary>
/// Creates the standard attribute set used by integration and wiring tests.
/// </summary>
/// <returns>The standard attribute rows for the fake repository.</returns>
public static List<GalaxyAttributeInfo> CreateStandardAttributes()
{
return new List<GalaxyAttributeInfo>
@@ -33,6 +41,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
};
}
/// <summary>
/// Creates a minimal hierarchy containing a single object for focused unit tests.
/// </summary>
/// <returns>A minimal hierarchy row set.</returns>
public static List<GalaxyObjectInfo> CreateMinimalHierarchy()
{
return new List<GalaxyObjectInfo>
@@ -41,6 +53,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
};
}
/// <summary>
/// Creates a minimal attribute set containing a single scalar attribute for focused unit tests.
/// </summary>
/// <returns>A minimal attribute row set.</returns>
public static List<GalaxyAttributeInfo> CreateMinimalAttributes()
{
return new List<GalaxyAttributeInfo>

View File

@@ -16,6 +16,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
/// </summary>
public class AddressSpaceRebuildTests
{
/// <summary>
/// Confirms that the initial browsed hierarchy matches the seeded Galaxy model.
/// </summary>
[Fact]
public async Task Browse_ReturnsInitialHierarchy()
{
@@ -38,6 +41,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that adding a Galaxy object and rebuilding exposes the new node to OPC UA clients.
/// </summary>
[Fact]
public async Task Browse_AfterAddingObject_NewNodeAppears()
{
@@ -81,6 +87,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that removing a Galaxy object and rebuilding removes the node from the OPC UA hierarchy.
/// </summary>
[Fact]
public async Task Browse_AfterRemovingObject_NodeDisappears()
{
@@ -114,6 +123,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that subscriptions on deleted nodes receive a bad-quality notification after rebuild.
/// </summary>
[Fact]
public async Task Subscribe_RemovedNode_PublishesBadQuality()
{
@@ -160,6 +172,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that subscriptions on surviving nodes continue to work after a partial rebuild.
/// </summary>
[Fact]
public async Task Subscribe_SurvivingNode_StillWorksAfterRebuild()
{
@@ -196,6 +211,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that adding a Galaxy attribute and rebuilding exposes a new OPC UA variable.
/// </summary>
[Fact]
public async Task Browse_AddAttribute_NewVariableAppears()
{
@@ -230,6 +248,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that removing a Galaxy attribute and rebuilding removes the OPC UA variable.
/// </summary>
[Fact]
public async Task Browse_RemoveAttribute_VariableDisappears()
{
@@ -260,6 +281,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that rebuilds preserve subscription bookkeeping for nodes that survive the metadata refresh.
/// </summary>
[Fact]
public async Task Rebuild_PreservesSubscriptionBookkeeping_ForSurvivingNodes()
{

View File

@@ -8,8 +8,14 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
/// <summary>
/// Verifies OPC UA indexed array writes against the bridge's whole-array runtime update behavior.
/// </summary>
public class ArrayWriteTests
{
/// <summary>
/// Confirms that writing a single array element updates the correct slot while preserving the rest of the array.
/// </summary>
[Fact]
public async Task Write_SingleArrayElement_UpdatesWholeArrayValue()
{

View File

@@ -19,6 +19,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
// ── Subscription Sync ─────────────────────────────────────────────
/// <summary>
/// Confirms that multiple OPC UA clients subscribed to the same tag all receive the same runtime update.
/// </summary>
[Fact]
public async Task MultipleClients_SubscribeToSameTag_AllReceiveDataChanges()
{
@@ -70,6 +73,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that one client disconnecting does not stop remaining clients from receiving updates.
/// </summary>
[Fact]
public async Task Client_Disconnects_OtherClientsStillReceive()
{
@@ -119,6 +125,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that one client unsubscribing does not interrupt delivery to other subscribed clients.
/// </summary>
[Fact]
public async Task Client_Unsubscribes_OtherClientsStillReceive()
{
@@ -159,6 +168,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that clients subscribed to different tags only receive updates for their own monitored data.
/// </summary>
[Fact]
public async Task MultipleClients_SubscribeToDifferentTags_EachGetsOwnData()
{
@@ -206,6 +218,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
// ── Concurrent Operation Tests ────────────────────────────────────
/// <summary>
/// Confirms that concurrent browse operations from several clients all complete successfully.
/// </summary>
[Fact]
public async Task ConcurrentBrowseFromMultipleClients_AllSucceed()
{
@@ -246,6 +261,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that concurrent browse requests return consistent results across clients.
/// </summary>
[Fact]
public async Task ConcurrentBrowse_AllReturnSameResults()
{
@@ -283,6 +301,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that simultaneous browse and subscribe operations do not interfere with one another.
/// </summary>
[Fact]
public async Task ConcurrentBrowseAndSubscribe_NoInterference()
{
@@ -318,6 +339,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that concurrent subscribe, read, and browse operations complete without deadlocking the server.
/// </summary>
[Fact]
public async Task ConcurrentSubscribeAndRead_NoDeadlock()
{
@@ -355,6 +379,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
}
}
/// <summary>
/// Confirms that repeated client churn does not leave the server in an unstable state.
/// </summary>
[Fact]
public async Task RapidConnectDisconnect_ServerStaysStable()
{

View File

@@ -5,8 +5,14 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
{
/// <summary>
/// Verifies operation timing aggregation, rolling buffers, and success tracking used by the bridge metrics subsystem.
/// </summary>
public class PerformanceMetricsTests
{
/// <summary>
/// Confirms that a fresh metrics collector reports no statistics.
/// </summary>
[Fact]
public void EmptyState_ReturnsZeroStatistics()
{
@@ -15,6 +21,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
stats.ShouldBeEmpty();
}
/// <summary>
/// Confirms that repeated operation recordings update total and successful execution counts.
/// </summary>
[Fact]
public void RecordOperation_TracksCounts()
{
@@ -29,6 +38,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
stats["Read"].SuccessRate.ShouldBe(0.5);
}
/// <summary>
/// Confirms that min, max, and average timing values are calculated from recorded operations.
/// </summary>
[Fact]
public void RecordOperation_TracksMinMaxAverage()
{
@@ -43,6 +55,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
stats.AverageMilliseconds.ShouldBe(20);
}
/// <summary>
/// Confirms that the 95th percentile is calculated from the recorded timing sample.
/// </summary>
[Fact]
public void P95_CalculatedCorrectly()
{
@@ -54,6 +69,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
stats.Percentile95Milliseconds.ShouldBe(95);
}
/// <summary>
/// Confirms that the rolling buffer keeps the most recent operation durations for percentile calculations.
/// </summary>
[Fact]
public void RollingBuffer_EvictsOldEntries()
{
@@ -67,6 +85,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
stats.Percentile95Milliseconds.ShouldBeGreaterThan(1000);
}
/// <summary>
/// Confirms that a timing scope records an operation when disposed.
/// </summary>
[Fact]
public void BeginOperation_TimingScopeRecordsOnDispose()
{
@@ -85,6 +106,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
stats["Test"].AverageMilliseconds.ShouldBeGreaterThan(0);
}
/// <summary>
/// Confirms that a timing scope can mark an operation as failed before disposal.
/// </summary>
[Fact]
public void BeginOperation_SetSuccessFalse()
{
@@ -100,6 +124,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
stats.SuccessCount.ShouldBe(0);
}
/// <summary>
/// Confirms that looking up an unknown operation returns no metrics bucket.
/// </summary>
[Fact]
public void GetMetrics_UnknownOperation_ReturnsNull()
{
@@ -107,6 +134,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
metrics.GetMetrics("NonExistent").ShouldBeNull();
}
/// <summary>
/// Confirms that operation names are tracked without case sensitivity.
/// </summary>
[Fact]
public void OperationNames_AreCaseInsensitive()
{

View File

@@ -11,6 +11,9 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
{
/// <summary>
/// Verifies MXAccess client connection lifecycle behavior, including transitions, registration, and reconnect handling.
/// </summary>
public class MxAccessClientConnectionTests : IDisposable
{
private readonly StaComThread _staThread;
@@ -19,6 +22,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
private readonly MxAccessClient _client;
private readonly List<(ConnectionState Previous, ConnectionState Current)> _stateChanges = new();
/// <summary>
/// Initializes the connection test fixture with a fake runtime proxy and state-change recorder.
/// </summary>
public MxAccessClientConnectionTests()
{
_staThread = new StaComThread();
@@ -30,6 +36,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_client.ConnectionStateChanged += (_, e) => _stateChanges.Add((e.PreviousState, e.CurrentState));
}
/// <summary>
/// Disposes the connection test fixture and its supporting resources.
/// </summary>
public void Dispose()
{
_client.Dispose();
@@ -37,12 +46,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_metrics.Dispose();
}
/// <summary>
/// Confirms that a newly created MXAccess client starts in the disconnected state.
/// </summary>
[Fact]
public void InitialState_IsDisconnected()
{
_client.State.ShouldBe(ConnectionState.Disconnected);
}
/// <summary>
/// Confirms that connecting drives the expected disconnected-to-connecting-to-connected transitions.
/// </summary>
[Fact]
public async Task Connect_TransitionsToConnected()
{
@@ -53,6 +68,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_stateChanges.ShouldContain(s => s.Previous == ConnectionState.Connecting && s.Current == ConnectionState.Connected);
}
/// <summary>
/// Confirms that a successful connect registers exactly once with the runtime proxy.
/// </summary>
[Fact]
public async Task Connect_RegistersCalled()
{
@@ -60,6 +78,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_proxy.RegisterCallCount.ShouldBe(1);
}
/// <summary>
/// Confirms that disconnecting drives the expected shutdown transitions back to disconnected.
/// </summary>
[Fact]
public async Task Disconnect_TransitionsToDisconnected()
{
@@ -71,6 +92,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_stateChanges.ShouldContain(s => s.Current == ConnectionState.Disconnected);
}
/// <summary>
/// Confirms that disconnecting unregisters the runtime proxy session.
/// </summary>
[Fact]
public async Task Disconnect_UnregistersCalled()
{
@@ -79,6 +103,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_proxy.UnregisterCallCount.ShouldBe(1);
}
/// <summary>
/// Confirms that registration failures move the client into the error state.
/// </summary>
[Fact]
public async Task ConnectFails_TransitionsToError()
{
@@ -88,6 +115,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_client.State.ShouldBe(ConnectionState.Error);
}
/// <summary>
/// Confirms that repeated connect calls do not perform duplicate runtime registrations.
/// </summary>
[Fact]
public async Task DoubleConnect_NoOp()
{
@@ -96,6 +126,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_proxy.RegisterCallCount.ShouldBe(1);
}
/// <summary>
/// Confirms that reconnect increments the reconnect counter and restores the connected state.
/// </summary>
[Fact]
public async Task Reconnect_IncrementsCount()
{

View File

@@ -10,12 +10,18 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
{
/// <summary>
/// Verifies the background connectivity monitor used to reconnect the MXAccess bridge after faults or stale probes.
/// </summary>
public class MxAccessClientMonitorTests : IDisposable
{
private readonly StaComThread _staThread;
private readonly FakeMxProxy _proxy;
private readonly PerformanceMetrics _metrics;
/// <summary>
/// Initializes the monitor test fixture with a shared STA thread, fake proxy, and metrics collector.
/// </summary>
public MxAccessClientMonitorTests()
{
_staThread = new StaComThread();
@@ -24,12 +30,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_metrics = new PerformanceMetrics();
}
/// <summary>
/// Disposes the monitor test fixture resources.
/// </summary>
public void Dispose()
{
_staThread.Dispose();
_metrics.Dispose();
}
/// <summary>
/// Confirms that the monitor reconnects the client after an observed disconnect.
/// </summary>
[Fact]
public async Task Monitor_ReconnectsOnDisconnect()
{
@@ -54,6 +66,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
client.Dispose();
}
/// <summary>
/// Confirms that the monitor can be started and stopped without throwing.
/// </summary>
[Fact]
public async Task Monitor_StopsOnCancel()
{
@@ -69,6 +84,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
client.Dispose();
}
/// <summary>
/// Confirms that a stale probe tag triggers a reconnect when monitoring is enabled.
/// </summary>
[Fact]
public async Task Monitor_ProbeStale_ForcesReconnect()
{
@@ -93,6 +111,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
client.Dispose();
}
/// <summary>
/// Confirms that fresh probe updates prevent unnecessary reconnects.
/// </summary>
[Fact]
public async Task Monitor_ProbeDataChange_PreventsStaleReconnect()
{
@@ -122,6 +143,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
client.Dispose();
}
/// <summary>
/// Confirms that enabling the monitor without a probe tag does not trigger false reconnects.
/// </summary>
[Fact]
public async Task Monitor_NoProbeConfigured_NoFalseReconnect()
{

View File

@@ -11,6 +11,9 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
{
/// <summary>
/// Verifies MXAccess client read and write behavior against the fake runtime proxy used by the bridge.
/// </summary>
public class MxAccessClientReadWriteTests : IDisposable
{
private readonly StaComThread _staThread;
@@ -18,6 +21,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
private readonly PerformanceMetrics _metrics;
private readonly MxAccessClient _client;
/// <summary>
/// Initializes the COM-threaded MXAccess test fixture with a fake runtime proxy and metrics collector.
/// </summary>
public MxAccessClientReadWriteTests()
{
_staThread = new StaComThread();
@@ -28,6 +34,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_client = new MxAccessClient(_staThread, _proxy, config, _metrics);
}
/// <summary>
/// Disposes the MXAccess client fixture and its supporting STA thread and metrics collector.
/// </summary>
public void Dispose()
{
_client.Dispose();
@@ -35,6 +44,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_metrics.Dispose();
}
/// <summary>
/// Confirms that reads fail with bad-not-connected quality when the runtime session is offline.
/// </summary>
[Fact]
public async Task Read_NotConnected_ReturnsBad()
{
@@ -42,6 +54,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
result.Quality.ShouldBe(Quality.BadNotConnected);
}
/// <summary>
/// Confirms that a runtime data-change callback completes a pending read with the published value.
/// </summary>
[Fact]
public async Task Read_ReturnsValueOnDataChange()
{
@@ -59,6 +74,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
result.Quality.ShouldBe(Quality.Good);
}
/// <summary>
/// Confirms that reads time out with bad communication-failure quality when the runtime never responds.
/// </summary>
[Fact]
public async Task Read_Timeout_ReturnsBadCommFailure()
{
@@ -69,6 +87,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
result.Quality.ShouldBe(Quality.BadCommFailure);
}
/// <summary>
/// Confirms that timed-out reads are recorded as failed read operations in the metrics collector.
/// </summary>
[Fact]
public async Task Read_Timeout_RecordsFailedMetrics()
{
@@ -83,6 +104,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
stats["Read"].SuccessCount.ShouldBe(0);
}
/// <summary>
/// Confirms that writes are rejected when the runtime session is not connected.
/// </summary>
[Fact]
public async Task Write_NotConnected_ReturnsFalse()
{
@@ -90,6 +114,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
result.ShouldBe(false);
}
/// <summary>
/// Confirms that successful runtime write acknowledgments return success and record the written payload.
/// </summary>
[Fact]
public async Task Write_Success_ReturnsTrue()
{
@@ -101,6 +128,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_proxy.WrittenValues.ShouldContain(w => w.Address == "TestTag.Attr" && (int)w.Value == 42);
}
/// <summary>
/// Confirms that MXAccess error codes on write completion are surfaced as failed writes.
/// </summary>
[Fact]
public async Task Write_ErrorCode_ReturnsFalse()
{
@@ -111,6 +141,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
result.ShouldBe(false);
}
/// <summary>
/// Confirms that write timeouts are recorded as failed write operations in the metrics collector.
/// </summary>
[Fact]
public async Task Write_Timeout_ReturnsFalse_AndRecordsFailedMetrics()
{
@@ -126,6 +159,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
stats["Write"].SuccessCount.ShouldBe(0);
}
/// <summary>
/// Confirms that successful reads contribute a read entry to the metrics collector.
/// </summary>
[Fact]
public async Task Read_RecordsMetrics()
{
@@ -141,6 +177,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
stats["Read"].TotalCount.ShouldBe(1);
}
/// <summary>
/// Confirms that writes contribute a write entry to the metrics collector.
/// </summary>
[Fact]
public async Task Write_RecordsMetrics()
{

View File

@@ -11,6 +11,9 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
{
/// <summary>
/// Verifies how the MXAccess client manages persistent subscriptions, reconnect replay, and probe-tag behavior.
/// </summary>
public class MxAccessClientSubscriptionTests : IDisposable
{
private readonly StaComThread _staThread;
@@ -18,6 +21,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
private readonly PerformanceMetrics _metrics;
private readonly MxAccessClient _client;
/// <summary>
/// Initializes the subscription test fixture with a fake runtime proxy and STA thread.
/// </summary>
public MxAccessClientSubscriptionTests()
{
_staThread = new StaComThread();
@@ -27,6 +33,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_client = new MxAccessClient(_staThread, _proxy, new MxAccessConfiguration(), _metrics);
}
/// <summary>
/// Disposes the subscription test fixture and its supporting resources.
/// </summary>
public void Dispose()
{
_client.Dispose();
@@ -34,6 +43,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_metrics.Dispose();
}
/// <summary>
/// Confirms that subscribing creates a runtime item, advises it, and increments the active subscription count.
/// </summary>
[Fact]
public async Task Subscribe_CreatesItemAndAdvises()
{
@@ -45,6 +57,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_client.ActiveSubscriptionCount.ShouldBe(1);
}
/// <summary>
/// Confirms that unsubscribing clears the active subscription count after a tag was previously monitored.
/// </summary>
[Fact]
public async Task Unsubscribe_RemovesItemAndUnadvises()
{
@@ -55,6 +70,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_client.ActiveSubscriptionCount.ShouldBe(0);
}
/// <summary>
/// Confirms that runtime data changes are delivered to the per-subscription callback.
/// </summary>
[Fact]
public async Task OnDataChange_InvokesCallback()
{
@@ -70,6 +88,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
received.Value.Quality.ShouldBe(Quality.Good);
}
/// <summary>
/// Confirms that runtime data changes are also delivered to the client's global tag-change event.
/// </summary>
[Fact]
public async Task OnDataChange_InvokesGlobalHandler()
{
@@ -84,6 +105,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
globalAddr.ShouldBe("TestTag.Attr");
}
/// <summary>
/// Confirms that stored subscriptions are replayed after reconnect so live updates resume automatically.
/// </summary>
[Fact]
public async Task StoredSubscriptions_ReplayedAfterReconnect()
{
@@ -102,6 +126,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
callbackInvoked.ShouldBe(true);
}
/// <summary>
/// Confirms that one-shot reads do not remove persistent subscriptions when the client reconnects.
/// </summary>
[Fact]
public async Task OneShotRead_DoesNotRemovePersistentSubscription_OnReconnect()
{
@@ -122,6 +149,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_client.ActiveSubscriptionCount.ShouldBe(1);
}
/// <summary>
/// Confirms that transient writes do not prevent later removal of a persistent subscription.
/// </summary>
[Fact]
public async Task OneShotWrite_DoesNotBreakPersistentUnsubscribe()
{
@@ -138,6 +168,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_proxy.Items.Values.ShouldNotContain("TestTag.Attr");
}
/// <summary>
/// Confirms that the configured probe tag is subscribed during connect so connectivity monitoring can start immediately.
/// </summary>
[Fact]
public async Task ProbeTag_SubscribedOnConnect()
{
@@ -152,6 +185,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
client.Dispose();
}
/// <summary>
/// Confirms that the probe tag cannot be unsubscribed accidentally because it is reserved for connection monitoring.
/// </summary>
[Fact]
public async Task ProbeTag_ProtectedFromUnsubscribe()
{

View File

@@ -7,18 +7,30 @@ using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
{
/// <summary>
/// Verifies the single-threaded apartment worker used to marshal COM calls for the MXAccess bridge.
/// </summary>
public class StaComThreadTests : IDisposable
{
private readonly StaComThread _thread;
/// <summary>
/// Starts a fresh STA thread instance for each test.
/// </summary>
public StaComThreadTests()
{
_thread = new StaComThread();
_thread.Start();
}
/// <summary>
/// Disposes the STA thread after each test.
/// </summary>
public void Dispose() => _thread.Dispose();
/// <summary>
/// Confirms that queued work runs on a thread configured for STA apartment state.
/// </summary>
[Fact]
public async Task RunAsync_ExecutesOnStaThread()
{
@@ -26,6 +38,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
apartmentState.ShouldBe(ApartmentState.STA);
}
/// <summary>
/// Confirms that action delegates run to completion on the STA thread.
/// </summary>
[Fact]
public async Task RunAsync_Action_Completes()
{
@@ -34,6 +49,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
executed.ShouldBe(true);
}
/// <summary>
/// Confirms that function delegates can return results from the STA thread.
/// </summary>
[Fact]
public async Task RunAsync_Func_ReturnsResult()
{
@@ -41,6 +59,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
result.ShouldBe(42);
}
/// <summary>
/// Confirms that exceptions thrown on the STA thread propagate back to the caller.
/// </summary>
[Fact]
public async Task RunAsync_PropagatesException()
{
@@ -48,6 +69,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
_thread.RunAsync(() => throw new InvalidOperationException("test error")));
}
/// <summary>
/// Confirms that disposing the STA thread stops it from accepting additional work.
/// </summary>
[Fact]
public void Dispose_Stops_Thread()
{
@@ -59,6 +83,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
Should.Throw<ObjectDisposedException>(() => thread.RunAsync(() => { }).GetAwaiter().GetResult());
}
/// <summary>
/// Confirms that multiple queued work items all execute successfully on the STA thread.
/// </summary>
[Fact]
public async Task MultipleWorkItems_ExecuteInOrder()
{

View File

@@ -6,8 +6,14 @@ using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
{
/// <summary>
/// Verifies how bridge VTQ values are translated to and from OPC UA data values for the published namespace.
/// </summary>
public class DataValueConverterTests
{
/// <summary>
/// Confirms that boolean runtime values are preserved when converted to OPC UA data values.
/// </summary>
[Fact]
public void FromVtq_Boolean()
{
@@ -17,6 +23,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
Opc.Ua.StatusCode.IsGood(dv.StatusCode).ShouldBe(true);
}
/// <summary>
/// Confirms that integer runtime values are preserved when converted to OPC UA data values.
/// </summary>
[Fact]
public void FromVtq_Int32()
{
@@ -25,6 +34,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBe(42);
}
/// <summary>
/// Confirms that float runtime values are preserved when converted to OPC UA data values.
/// </summary>
[Fact]
public void FromVtq_Float()
{
@@ -33,6 +45,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBe(3.14f);
}
/// <summary>
/// Confirms that double runtime values are preserved when converted to OPC UA data values.
/// </summary>
[Fact]
public void FromVtq_Double()
{
@@ -41,6 +56,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBe(3.14159);
}
/// <summary>
/// Confirms that string runtime values are preserved when converted to OPC UA data values.
/// </summary>
[Fact]
public void FromVtq_String()
{
@@ -49,6 +67,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBe("hello");
}
/// <summary>
/// Confirms that UTC timestamps remain UTC when a VTQ is converted for OPC UA clients.
/// </summary>
[Fact]
public void FromVtq_DateTime_IsUtc()
{
@@ -58,6 +79,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
((DateTime)dv.Value).Kind.ShouldBe(DateTimeKind.Utc);
}
/// <summary>
/// Confirms that elapsed-time values are exposed to OPC UA clients in seconds.
/// </summary>
[Fact]
public void FromVtq_TimeSpan_ConvertedToSeconds()
{
@@ -66,6 +90,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBe(150.0);
}
/// <summary>
/// Confirms that string arrays remain arrays when exposed through OPC UA.
/// </summary>
[Fact]
public void FromVtq_StringArray()
{
@@ -75,6 +102,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBe(arr);
}
/// <summary>
/// Confirms that integer arrays remain arrays when exposed through OPC UA.
/// </summary>
[Fact]
public void FromVtq_IntArray()
{
@@ -84,6 +114,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBe(arr);
}
/// <summary>
/// Confirms that bad runtime quality is translated to a bad OPC UA status code.
/// </summary>
[Fact]
public void FromVtq_BadQuality_MapsToStatusCode()
{
@@ -92,6 +125,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
Opc.Ua.StatusCode.IsBad(dv.StatusCode).ShouldBe(true);
}
/// <summary>
/// Confirms that uncertain runtime quality is translated to an uncertain OPC UA status code.
/// </summary>
[Fact]
public void FromVtq_UncertainQuality()
{
@@ -100,6 +136,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
Opc.Ua.StatusCode.IsUncertain(dv.StatusCode).ShouldBe(true);
}
/// <summary>
/// Confirms that null runtime values remain null when converted for OPC UA.
/// </summary>
[Fact]
public void FromVtq_NullValue()
{
@@ -108,6 +147,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
dv.Value.ShouldBeNull();
}
/// <summary>
/// Confirms that a data value can round-trip back into a VTQ without losing the process value or quality.
/// </summary>
[Fact]
public void ToVtq_RoundTrip()
{

View File

@@ -6,8 +6,15 @@ using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
{
/// <summary>
/// Verifies the in-memory address-space model built from Galaxy hierarchy and attribute rows.
/// </summary>
public class LmxNodeManagerBuildTests
{
/// <summary>
/// Creates representative Galaxy hierarchy and attribute rows for address-space builder tests.
/// </summary>
/// <returns>The hierarchy and attribute rows used by the tests.</returns>
private static (List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes) CreateTestData()
{
var hierarchy = new List<GalaxyObjectInfo>
@@ -29,6 +36,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
return (hierarchy, attributes);
}
/// <summary>
/// Confirms that object and variable counts are computed correctly from the seeded Galaxy model.
/// </summary>
[Fact]
public void BuildAddressSpace_CreatesCorrectNodeCounts()
{
@@ -39,6 +49,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model.VariableCount.ShouldBe(4); // MachineID, DownloadPath, JobStepNumber, BatchItems
}
/// <summary>
/// Confirms that runtime tag references are populated for every published variable.
/// </summary>
[Fact]
public void BuildAddressSpace_TagReferencesPopulated()
{
@@ -51,6 +64,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems[]").ShouldBe(true);
}
/// <summary>
/// Confirms that array attributes are represented in the tag-reference map.
/// </summary>
[Fact]
public void BuildAddressSpace_ArrayVariable_HasCorrectInfo()
{
@@ -60,6 +76,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems[]").ShouldBe(true);
}
/// <summary>
/// Confirms that Galaxy areas are not counted as object nodes in the resulting model.
/// </summary>
[Fact]
public void BuildAddressSpace_Areas_AreNotCountedAsObjects()
{
@@ -73,6 +92,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model.ObjectCount.ShouldBe(1); // Only Obj1, not Area1
}
/// <summary>
/// Confirms that only top-level Galaxy nodes are returned as roots in the model.
/// </summary>
[Fact]
public void BuildAddressSpace_RootNodes_AreTopLevel()
{
@@ -86,6 +108,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model.RootNodes.Count.ShouldBe(1); // Only Root1 is a root
}
/// <summary>
/// Confirms that variables for multiple MX data types are included in the model.
/// </summary>
[Fact]
public void BuildAddressSpace_DataTypeMappings()
{

View File

@@ -6,8 +6,14 @@ using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
{
/// <summary>
/// Verifies rebuild behavior by comparing address-space models before and after metadata changes.
/// </summary>
public class LmxNodeManagerRebuildTests
{
/// <summary>
/// Confirms that rebuilding with new metadata replaces the old tag-reference set.
/// </summary>
[Fact]
public void Rebuild_NewBuild_ReplacesOldData()
{
@@ -40,6 +46,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model2.NodeIdToTagReference.ContainsKey("NewObj.NewAttr").ShouldBe(true);
}
/// <summary>
/// Confirms that object counts are recalculated from the latest rebuild input.
/// </summary>
[Fact]
public void Rebuild_UpdatesNodeCounts()
{
@@ -59,6 +68,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model2.ObjectCount.ShouldBe(1);
}
/// <summary>
/// Confirms that empty metadata produces an empty address-space model.
/// </summary>
[Fact]
public void EmptyHierarchy_ProducesEmptyModel()
{

View File

@@ -6,8 +6,14 @@ using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
{
/// <summary>
/// Verifies translation between bridge quality values and OPC UA status codes.
/// </summary>
public class OpcUaQualityMapperTests
{
/// <summary>
/// Confirms that good bridge quality maps to an OPC UA good status.
/// </summary>
[Fact]
public void Good_MapsToGoodStatusCode()
{
@@ -15,6 +21,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
StatusCode.IsGood(sc).ShouldBe(true);
}
/// <summary>
/// Confirms that bad bridge quality maps to an OPC UA bad status.
/// </summary>
[Fact]
public void Bad_MapsToBadStatusCode()
{
@@ -22,6 +31,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
StatusCode.IsBad(sc).ShouldBe(true);
}
/// <summary>
/// Confirms that uncertain bridge quality maps to an OPC UA uncertain status.
/// </summary>
[Fact]
public void Uncertain_MapsToUncertainStatusCode()
{
@@ -29,6 +41,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
StatusCode.IsUncertain(sc).ShouldBe(true);
}
/// <summary>
/// Confirms that communication failures map to a bad OPC UA status code.
/// </summary>
[Fact]
public void BadCommFailure_MapsCorrectly()
{
@@ -36,6 +51,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
StatusCode.IsBad(sc).ShouldBe(true);
}
/// <summary>
/// Confirms that the OPC UA good status maps back to bridge good quality.
/// </summary>
[Fact]
public void FromStatusCode_Good()
{
@@ -43,6 +61,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
q.ShouldBe(Quality.Good);
}
/// <summary>
/// Confirms that the OPC UA bad status maps back to bridge bad quality.
/// </summary>
[Fact]
public void FromStatusCode_Bad()
{
@@ -50,6 +71,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
q.ShouldBe(Quality.Bad);
}
/// <summary>
/// Confirms that the OPC UA uncertain status maps back to bridge uncertain quality.
/// </summary>
[Fact]
public void FromStatusCode_Uncertain()
{

View File

@@ -3,8 +3,14 @@ using Xunit;
namespace ZB.MOM.WW.LmxOpcUa.Tests
{
/// <summary>
/// Placeholder unit test that keeps the unit test project wired into the solution.
/// </summary>
public class SampleTest
{
/// <summary>
/// Confirms that the unit test assembly is executing.
/// </summary>
[Fact]
public void Placeholder_ShouldPass()
{

View File

@@ -7,10 +7,16 @@ using ZB.MOM.WW.LmxOpcUa.Host.Status;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
{
/// <summary>
/// Verifies how the dashboard health service classifies bridge health from connection state and metrics.
/// </summary>
public class HealthCheckServiceTests
{
private readonly HealthCheckService _sut = new();
/// <summary>
/// Confirms that a disconnected runtime is reported as unhealthy.
/// </summary>
[Fact]
public void NotConnected_ReturnsUnhealthy()
{
@@ -20,6 +26,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
result.Message.ShouldContain("not connected");
}
/// <summary>
/// Confirms that a connected runtime with no metrics history is still considered healthy.
/// </summary>
[Fact]
public void Connected_NoMetrics_ReturnsHealthy()
{
@@ -28,6 +37,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
result.Color.ShouldBe("green");
}
/// <summary>
/// Confirms that good success-rate metrics keep the service in a healthy state.
/// </summary>
[Fact]
public void Connected_GoodMetrics_ReturnsHealthy()
{
@@ -39,6 +51,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
result.Status.ShouldBe("Healthy");
}
/// <summary>
/// Confirms that poor operation success rates degrade the reported health state.
/// </summary>
[Fact]
public void Connected_LowSuccessRate_ReturnsDegraded()
{
@@ -53,18 +68,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
result.Color.ShouldBe("yellow");
}
/// <summary>
/// Confirms that the boolean health helper reports true when the runtime is connected.
/// </summary>
[Fact]
public void IsHealthy_Connected_ReturnsTrue()
{
_sut.IsHealthy(ConnectionState.Connected, null).ShouldBe(true);
}
/// <summary>
/// Confirms that the boolean health helper reports false when the runtime is disconnected.
/// </summary>
[Fact]
public void IsHealthy_Disconnected_ReturnsFalse()
{
_sut.IsHealthy(ConnectionState.Disconnected, null).ShouldBe(false);
}
/// <summary>
/// Confirms that the error connection state is treated as unhealthy.
/// </summary>
[Fact]
public void Error_ReturnsUnhealthy()
{
@@ -72,6 +96,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
result.Status.ShouldBe("Unhealthy");
}
/// <summary>
/// Confirms that the reconnecting state is treated as unhealthy while recovery is in progress.
/// </summary>
[Fact]
public void Reconnecting_ReturnsUnhealthy()
{

View File

@@ -9,8 +9,14 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
{
/// <summary>
/// Verifies the HTML, JSON, and health snapshots generated for the operator status dashboard.
/// </summary>
public class StatusReportServiceTests
{
/// <summary>
/// Confirms that the generated HTML contains every dashboard panel expected by operators.
/// </summary>
[Fact]
public void GenerateHtml_ContainsAllPanels()
{
@@ -25,6 +31,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
html.ShouldContain("Footer");
}
/// <summary>
/// Confirms that the generated HTML includes the configured auto-refresh meta tag.
/// </summary>
[Fact]
public void GenerateHtml_ContainsMetaRefresh()
{
@@ -33,6 +42,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
html.ShouldContain("meta http-equiv='refresh' content='10'");
}
/// <summary>
/// Confirms that the connection panel renders the current runtime connection state.
/// </summary>
[Fact]
public void GenerateHtml_ConnectionPanel_ShowsState()
{
@@ -41,6 +53,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
html.ShouldContain("Connected");
}
/// <summary>
/// Confirms that the Galaxy panel renders the bridged Galaxy name.
/// </summary>
[Fact]
public void GenerateHtml_GalaxyPanel_ShowsName()
{
@@ -49,6 +64,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
html.ShouldContain("TestGalaxy");
}
/// <summary>
/// Confirms that the operations table renders the expected performance metric headers.
/// </summary>
[Fact]
public void GenerateHtml_OperationsTable_ShowsHeaders()
{
@@ -62,6 +80,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
html.ShouldContain("P95 (ms)");
}
/// <summary>
/// Confirms that the footer renders timestamp and version information.
/// </summary>
[Fact]
public void GenerateHtml_Footer_ContainsTimestampAndVersion()
{
@@ -71,6 +92,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
html.ShouldContain("Version:");
}
/// <summary>
/// Confirms that the generated JSON includes the major dashboard sections.
/// </summary>
[Fact]
public void GenerateJson_Deserializes()
{
@@ -86,6 +110,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
json.ShouldContain("Footer");
}
/// <summary>
/// Confirms that the report service reports healthy when the runtime connection is up.
/// </summary>
[Fact]
public void IsHealthy_WhenConnected_ReturnsTrue()
{
@@ -93,6 +120,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
sut.IsHealthy().ShouldBe(true);
}
/// <summary>
/// Confirms that the report service reports unhealthy when the runtime connection is down.
/// </summary>
[Fact]
public void IsHealthy_WhenDisconnected_ReturnsFalse()
{
@@ -102,6 +132,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
sut.IsHealthy().ShouldBe(false);
}
/// <summary>
/// Creates a status report service preloaded with representative runtime, Galaxy, and metrics data.
/// </summary>
/// <returns>A configured status report service for dashboard assertions.</returns>
private static StatusReportService CreateService()
{
var mxClient = new FakeMxAccessClient();

View File

@@ -9,12 +9,18 @@ using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
{
/// <summary>
/// Verifies the lightweight HTTP dashboard host that exposes bridge status to operators.
/// </summary>
public class StatusWebServerTests : IDisposable
{
private readonly StatusWebServer _server;
private readonly HttpClient _client;
private readonly int _port;
/// <summary>
/// Starts a status web server on a random test port and prepares an HTTP client for endpoint assertions.
/// </summary>
public StatusWebServerTests()
{
_port = new Random().Next(18000, 19000);
@@ -26,12 +32,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
_client = new HttpClient { BaseAddress = new Uri($"http://localhost:{_port}") };
}
/// <summary>
/// Disposes the test HTTP client and stops the status web server.
/// </summary>
public void Dispose()
{
_client.Dispose();
_server.Dispose();
}
/// <summary>
/// Confirms that the dashboard root responds with HTML content.
/// </summary>
[Fact]
public async Task Root_ReturnsHtml200()
{
@@ -40,6 +52,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
}
/// <summary>
/// Confirms that the JSON status endpoint responds successfully.
/// </summary>
[Fact]
public async Task ApiStatus_ReturnsJson200()
{
@@ -48,6 +63,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json");
}
/// <summary>
/// Confirms that the health endpoint returns HTTP 200 when the bridge is healthy.
/// </summary>
[Fact]
public async Task ApiHealth_Returns200WhenHealthy()
{
@@ -58,6 +76,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
body.ShouldContain("healthy");
}
/// <summary>
/// Confirms that unknown dashboard routes return HTTP 404.
/// </summary>
[Fact]
public async Task UnknownPath_Returns404()
{
@@ -65,6 +86,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <summary>
/// Confirms that unsupported HTTP methods are rejected with HTTP 405.
/// </summary>
[Fact]
public async Task PostMethod_Returns405()
{
@@ -72,6 +96,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
response.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed);
}
/// <summary>
/// Confirms that cache-control headers disable caching for dashboard responses.
/// </summary>
[Fact]
public async Task CacheHeaders_Present()
{
@@ -80,6 +107,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
response.Headers.CacheControl?.NoStore.ShouldBe(true);
}
/// <summary>
/// Confirms that the server can be started and stopped cleanly.
/// </summary>
[Fact]
public void StartStop_DoesNotThrow()
{

View File

@@ -16,6 +16,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
/// </summary>
public class ChangeDetectionToRebuildWiringTest
{
/// <summary>
/// Confirms that a changed deploy timestamp causes the change-detection pipeline to raise another rebuild signal.
/// </summary>
[Fact]
public async Task ChangedTimestamp_TriggersRebuild()
{

View File

@@ -12,6 +12,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
/// </summary>
public class MxAccessToNodeManagerWiringTest
{
/// <summary>
/// Confirms that a simulated data change reaches the global tag-value-changed event.
/// </summary>
[Fact]
public async Task DataChange_ReachesGlobalHandler()
{
@@ -33,6 +36,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
receivedVtq.Value.Quality.ShouldBe(Quality.Good);
}
/// <summary>
/// Confirms that a simulated data change reaches the stored per-tag subscription callback.
/// </summary>
[Fact]
public async Task DataChange_ReachesSubscriptionCallback()
{

View File

@@ -13,6 +13,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
/// </summary>
public class OpcUaReadToMxAccessWiringTest
{
/// <summary>
/// Confirms that the resolved OPC UA read path uses the expected full Galaxy tag reference.
/// </summary>
[Fact]
public async Task Read_ResolvesCorrectTagReference()
{

View File

@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
/// </summary>
public class OpcUaWriteToMxAccessWiringTest
{
/// <summary>
/// Confirms that the resolved OPC UA write path targets the expected Galaxy tag reference and payload.
/// </summary>
[Fact]
public async Task Write_SendsCorrectTagAndValue()
{

View File

@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
/// </summary>
public class ServiceStartupSequenceTest
{
/// <summary>
/// Confirms that startup with fake dependencies creates the expected bridge components and state.
/// </summary>
[Fact]
public void Start_WithFakes_AllComponentsCreated()
{
@@ -71,6 +74,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
}
}
/// <summary>
/// Confirms that when MXAccess is initially unavailable, the background monitor reconnects it later.
/// </summary>
[Fact]
public async Task Start_WhenMxAccessIsInitiallyDown_MonitorReconnectsInBackground()
{

View File

@@ -15,6 +15,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
/// </summary>
public class ShutdownCompletesTest
{
/// <summary>
/// Confirms that a started service can shut down within the required time budget.
/// </summary>
[Fact]
public void Shutdown_CompletesWithin30Seconds()
{

View File

@@ -9,18 +9,34 @@ namespace OpcUaCli.Commands;
[Command("browse", Description = "Browse the OPC UA address space")]
public class BrowseCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL to connect to before browsing.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
public string? NodeId { get; init; }
/// <summary>
/// Gets the maximum browse depth when recursive traversal is enabled.
/// </summary>
[CommandOption("depth", 'd', Description = "Maximum browse depth")]
public int Depth { get; init; } = 1;
/// <summary>
/// Gets a value indicating whether browse recursion should continue into child objects.
/// </summary>
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
public bool Recursive { get; init; }
/// <summary>
/// Connects to the OPC UA endpoint and writes the browse tree to the console.
/// </summary>
/// <param name="console">The console used to emit browse output.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);

View File

@@ -7,9 +7,16 @@ namespace OpcUaCli.Commands;
[Command("connect", Description = "Test connection to an OPC UA server")]
public class ConnectCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL to test.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Connects to the OPC UA endpoint and prints the resolved server metadata.
/// </summary>
/// <param name="console">The console used to report connection results.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);

View File

@@ -9,12 +9,22 @@ namespace OpcUaCli.Commands;
[Command("read", Description = "Read a value from a node")]
public class ReadCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL to connect to before reading.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Gets the node identifier whose value should be read.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Connects to the endpoint, reads the target node, and prints the returned value details.
/// </summary>
/// <param name="console">The console used to report the read result.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);

View File

@@ -9,15 +9,28 @@ namespace OpcUaCli.Commands;
[Command("subscribe", Description = "Monitor a node for value changes")]
public class SubscribeCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL to connect to before subscribing.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Gets the node identifier to monitor for value changes.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Gets the sampling and publishing interval, in milliseconds, for the monitored item.
/// </summary>
[CommandOption("interval", 'i', Description = "Polling interval in milliseconds")]
public int Interval { get; init; } = 1000;
/// <summary>
/// Connects to the OPC UA endpoint and streams monitored-item notifications until cancellation.
/// </summary>
/// <param name="console">The console used to display subscription updates.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);

View File

@@ -9,15 +9,28 @@ namespace OpcUaCli.Commands;
[Command("write", Description = "Write a value to a node")]
public class WriteCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL to connect to before issuing the write.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Gets the node identifier that should receive the write.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Gets the textual value supplied on the command line before type conversion.
/// </summary>
[CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
public string Value { get; init; } = default!;
/// <summary>
/// Connects to the OPC UA endpoint, converts the supplied value, and writes it to the target node.
/// </summary>
/// <param name="console">The console used to report the write result.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);

View File

@@ -6,6 +6,11 @@ namespace OpcUaCli;
public static class OpcUaHelper
{
/// <summary>
/// Creates an OPC UA client session for the specified endpoint URL.
/// </summary>
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
/// <returns>An active OPC UA client session.</returns>
public static async Task<Session> ConnectAsync(string endpointUrl)
{
var config = new ApplicationConfiguration
@@ -61,6 +66,12 @@ public static class OpcUaHelper
#pragma warning restore CS0618
}
/// <summary>
/// Converts a raw command-line string into the runtime type expected by the target node.
/// </summary>
/// <param name="rawValue">The raw string supplied by the user.</param>
/// <param name="currentValue">The current node value used to infer the target type.</param>
/// <returns>A typed value suitable for an OPC UA write request.</returns>
public static object ConvertValue(string rawValue, object? currentValue)
{
return currentValue switch