fix(adminui): S7 typed page no longer wipes Tags on save
- S7DriverPage.FormModel now preserves Tags through Form ↔ Options translation (was hard-coding Tags = [] on every save, silently destroying any tag list that operators had configured). - Add FormModel_RoundTrip tests for OpcUaClient and Historian mirror classes — both were translating Options ↔ form-model entirely untested. - Surface S7 Tags in the round-trip test so this regression can't reach merge again.
This commit is contained in:
+5
-2
@@ -300,6 +300,9 @@ else
|
|||||||
// Tags JSON view (read-only)
|
// Tags JSON view (read-only)
|
||||||
public string? TagsJson { get; set; }
|
public string? TagsJson { get; set; }
|
||||||
|
|
||||||
|
// Preserved originals (round-tripped unchanged from original options)
|
||||||
|
private IReadOnlyList<S7TagDefinition> _tags = [];
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
public string? ResilienceConfig { get; set; }
|
public string? ResilienceConfig { get; set; }
|
||||||
public byte[] RowVersion { get; set; } = [];
|
public byte[] RowVersion { get; set; } = [];
|
||||||
@@ -326,6 +329,7 @@ else
|
|||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||||
TagsJson = tagsJson,
|
TagsJson = tagsJson,
|
||||||
|
_tags = o.Tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,8 +348,7 @@ else
|
|||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||||
},
|
},
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||||
// Tags preserved from original JSON; this form does not edit the tag list.
|
Tags = _tags,
|
||||||
Tags = [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
@@ -80,4 +81,32 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
back.ProbeTimeoutSeconds.ShouldBe(20);
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
back.PipeName.ShouldBe("otopcua-historian");
|
back.PipeName.ShouldBe("otopcua-historian");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormModel_RoundTrip_PreservesAllFields()
|
||||||
|
{
|
||||||
|
// Construct a record with non-default values for every property and verify
|
||||||
|
// that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless.
|
||||||
|
var original = new WonderwareHistorianClientOptions(
|
||||||
|
PipeName: "otopcua-historian-prod",
|
||||||
|
SharedSecret: "sup3rs3cr3t",
|
||||||
|
PeerName: "OtOpcUa-Redundant",
|
||||||
|
ConnectTimeout: TimeSpan.FromSeconds(18),
|
||||||
|
CallTimeout: TimeSpan.FromSeconds(45))
|
||||||
|
{
|
||||||
|
ProbeTimeoutSeconds = 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original);
|
||||||
|
var result = form.ToRecord();
|
||||||
|
|
||||||
|
result.PipeName.ShouldBe("otopcua-historian-prod");
|
||||||
|
result.SharedSecret.ShouldBe("sup3rs3cr3t");
|
||||||
|
result.PeerName.ShouldBe("OtOpcUa-Redundant");
|
||||||
|
result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
|
||||||
|
result.CallTimeout.ShouldBe(TimeSpan.FromSeconds(45));
|
||||||
|
result.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
|
||||||
|
result.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(45));
|
||||||
|
result.ProbeTimeoutSeconds.ShouldBe(30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
@@ -1,7 +1,9 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
@@ -79,4 +81,76 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
|||||||
back.ShouldNotBeNull();
|
back.ShouldNotBeNull();
|
||||||
back.ProbeTimeoutSeconds.ShouldBe(20);
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormModel_RoundTrip_PreservesAllFields()
|
||||||
|
{
|
||||||
|
// Construct options with non-default values for every editable property plus
|
||||||
|
// non-empty EndpointUrls and UnsMappingTable — both are "read-only" in the form
|
||||||
|
// but must survive the FormModel translation unchanged.
|
||||||
|
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://backup:4840" };
|
||||||
|
var unsMappingTable = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Line1/"] = "Site/Area1/Line1",
|
||||||
|
["Line2/"] = "Site/Area1/Line2",
|
||||||
|
};
|
||||||
|
|
||||||
|
var original = new OpcUaClientDriverOptions
|
||||||
|
{
|
||||||
|
EndpointUrl = "opc.tcp://fallback:4840",
|
||||||
|
EndpointUrls = endpointUrls,
|
||||||
|
ApplicationUri = "urn:test:OtOpcUa:GatewayClient",
|
||||||
|
SessionName = "TestSession",
|
||||||
|
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
|
||||||
|
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
|
||||||
|
AuthType = OpcUaAuthType.Username,
|
||||||
|
Username = "opcuser",
|
||||||
|
Password = "p@ssw0rd",
|
||||||
|
UserCertificatePath = @"C:\certs\user.pfx",
|
||||||
|
UserCertificatePassword = "certpass",
|
||||||
|
PerEndpointConnectTimeout = TimeSpan.FromSeconds(4),
|
||||||
|
Timeout = TimeSpan.FromSeconds(12),
|
||||||
|
SessionTimeout = TimeSpan.FromSeconds(150),
|
||||||
|
KeepAliveInterval = TimeSpan.FromSeconds(7),
|
||||||
|
ReconnectPeriod = TimeSpan.FromSeconds(8),
|
||||||
|
AutoAcceptCertificates = true,
|
||||||
|
BrowseRoot = "i=85",
|
||||||
|
MaxDiscoveredNodes = 3000,
|
||||||
|
MaxBrowseDepth = 5,
|
||||||
|
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
|
||||||
|
UnsMappingTable = unsMappingTable,
|
||||||
|
ProbeTimeoutSeconds = 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original);
|
||||||
|
var result = form.ToRecord();
|
||||||
|
|
||||||
|
result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840");
|
||||||
|
result.EndpointUrls.Count.ShouldBe(2);
|
||||||
|
result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840");
|
||||||
|
result.EndpointUrls[1].ShouldBe("opc.tcp://backup:4840");
|
||||||
|
result.ApplicationUri.ShouldBe("urn:test:OtOpcUa:GatewayClient");
|
||||||
|
result.SessionName.ShouldBe("TestSession");
|
||||||
|
result.SecurityMode.ShouldBe(OpcUaSecurityMode.SignAndEncrypt);
|
||||||
|
result.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.Basic256Sha256);
|
||||||
|
result.AuthType.ShouldBe(OpcUaAuthType.Username);
|
||||||
|
result.Username.ShouldBe("opcuser");
|
||||||
|
result.Password.ShouldBe("p@ssw0rd");
|
||||||
|
result.UserCertificatePath.ShouldBe(@"C:\certs\user.pfx");
|
||||||
|
result.UserCertificatePassword.ShouldBe("certpass");
|
||||||
|
result.PerEndpointConnectTimeout.ShouldBe(TimeSpan.FromSeconds(4));
|
||||||
|
result.Timeout.ShouldBe(TimeSpan.FromSeconds(12));
|
||||||
|
result.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(150));
|
||||||
|
result.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(7));
|
||||||
|
result.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(8));
|
||||||
|
result.AutoAcceptCertificates.ShouldBeTrue();
|
||||||
|
result.BrowseRoot.ShouldBe("i=85");
|
||||||
|
result.MaxDiscoveredNodes.ShouldBe(3000);
|
||||||
|
result.MaxBrowseDepth.ShouldBe(5);
|
||||||
|
result.TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.SystemPlatform);
|
||||||
|
result.UnsMappingTable.Count.ShouldBe(2);
|
||||||
|
result.UnsMappingTable["Line1/"].ShouldBe("Site/Area1/Line1");
|
||||||
|
result.UnsMappingTable["Line2/"].ShouldBe("Site/Area1/Line2");
|
||||||
|
result.ProbeTimeoutSeconds.ShouldBe(25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ public sealed class S7DriverPageFormSerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void FormModel_RoundTrip_PreservesEditableFields()
|
public void FormModel_RoundTrip_PreservesEditableFields()
|
||||||
{
|
{
|
||||||
|
var tags = new[]
|
||||||
|
{
|
||||||
|
new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true),
|
||||||
|
new S7TagDefinition("Status", "DB1.DBW4", S7DataType.Int16, Writable: false),
|
||||||
|
};
|
||||||
|
|
||||||
var opts = new S7DriverOptions
|
var opts = new S7DriverOptions
|
||||||
{
|
{
|
||||||
Host = "192.168.1.50",
|
Host = "192.168.1.50",
|
||||||
@@ -84,6 +90,7 @@ public sealed class S7DriverPageFormSerializationTests
|
|||||||
Timeout = TimeSpan.FromSeconds(4),
|
Timeout = TimeSpan.FromSeconds(4),
|
||||||
},
|
},
|
||||||
ProbeTimeoutSeconds = 20,
|
ProbeTimeoutSeconds = 20,
|
||||||
|
Tags = tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
@@ -100,5 +107,14 @@ public sealed class S7DriverPageFormSerializationTests
|
|||||||
roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(8));
|
roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(8));
|
||||||
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
||||||
roundTripped.ProbeTimeoutSeconds.ShouldBe(20);
|
roundTripped.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
|
|
||||||
|
// Tags must survive the FormModel round-trip unchanged (regression guard for the
|
||||||
|
// Tags = [] data-loss bug fixed in this PR).
|
||||||
|
roundTripped.Tags.Count.ShouldBe(2);
|
||||||
|
roundTripped.Tags[0].Name.ShouldBe("Speed");
|
||||||
|
roundTripped.Tags[0].Address.ShouldBe("DB1.DBD0");
|
||||||
|
roundTripped.Tags[0].DataType.ShouldBe(S7DataType.Float32);
|
||||||
|
roundTripped.Tags[1].Name.ShouldBe("Status");
|
||||||
|
roundTripped.Tags[1].Writable.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user