Merge B1-wp6b-ui
This commit is contained in:
+9
-61
@@ -43,10 +43,6 @@ public sealed class AbCipDriverPageFormSerializationTests
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Real, Writable: true),
|
||||
],
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _opts);
|
||||
@@ -66,9 +62,6 @@ public sealed class AbCipDriverPageFormSerializationTests
|
||||
back.Devices.Count.ShouldBe(1);
|
||||
back.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0");
|
||||
back.Devices[0].PlcFamily.ShouldBe(AbCipPlcFamily.ControlLogix);
|
||||
back.Tags.Count.ShouldBe(1);
|
||||
back.Tags[0].Name.ShouldBe("Speed");
|
||||
back.Tags[0].DataType.ShouldBe(AbCipDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,43 +109,11 @@ public sealed class AbCipDriverPageFormSerializationTests
|
||||
back.ConnectionSize.ShouldBe(4002);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_round_trips_through_definition()
|
||||
{
|
||||
var row = new AbCipDriverPage.AbCipTagRow
|
||||
{
|
||||
Name = "Speed", DeviceHostAddress = "ab://10.0.0.1/1,0", TagPath = "Motor1.Speed",
|
||||
DataType = AbCipDataType.Real, Writable = true,
|
||||
};
|
||||
var def = row.ToDefinition();
|
||||
var back = AbCipDriverPage.AbCipTagRow.FromDefinition(def);
|
||||
|
||||
back.Name.ShouldBe("Speed");
|
||||
back.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
|
||||
back.TagPath.ShouldBe("Motor1.Speed");
|
||||
back.DataType.ShouldBe(AbCipDataType.Real);
|
||||
back.Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_preserves_unedited_fields()
|
||||
{
|
||||
var original = new AbCipTagDefinition(
|
||||
"Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Structure,
|
||||
Writable: true, WriteIdempotent: true,
|
||||
Members: [new AbCipStructureMember("Sub", AbCipDataType.DInt)],
|
||||
SafetyTag: true);
|
||||
var row = AbCipDriverPage.AbCipTagRow.FromDefinition(original);
|
||||
row.Name = "Renamed";
|
||||
|
||||
var back = row.ToDefinition();
|
||||
back.Name.ShouldBe("Renamed");
|
||||
back.WriteIdempotent.ShouldBeTrue();
|
||||
back.SafetyTag.ShouldBeTrue();
|
||||
back.Members.ShouldNotBeNull();
|
||||
back.Members!.Count.ShouldBe(1);
|
||||
back.Members[0].Name.ShouldBe("Sub");
|
||||
}
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (AbCipDriverPage.AbCipTagRow +
|
||||
// AbCipDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
|
||||
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
|
||||
// + the device-list serialization below, all carrying the AbCipPlcFamily enum) stays.
|
||||
|
||||
[Fact]
|
||||
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||
@@ -163,32 +124,19 @@ public sealed class AbCipDriverPageFormSerializationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTagRow_rejects_duplicate_name()
|
||||
{
|
||||
var rows = new List<AbCipDriverPage.AbCipTagRow> { new() { Name = "Speed" } };
|
||||
AbCipDriverPage.AbCipTagRow.ValidateRow(new() { Name = "Speed" }, rows, null)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_and_tag_lists_survive_options_serialize_round_trip()
|
||||
public void Device_list_survives_options_serialize_round_trip()
|
||||
{
|
||||
var devices = new List<AbCipDeviceOptions>
|
||||
{
|
||||
new("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"),
|
||||
new("ab://10.0.0.2/1,0", AbCipPlcFamily.CompactLogix, "PLC-2"),
|
||||
};
|
||||
var tags = new List<AbCipTagDefinition>
|
||||
{
|
||||
new("Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Real),
|
||||
new("Run", "ab://10.0.0.2/1,0", "Motor2.Run", AbCipDataType.Bool),
|
||||
};
|
||||
var opts = new AbCipDriverPage.FormModel().ToOptions(devices, tags);
|
||||
var opts = new AbCipDriverPage.FormModel().ToOptions(devices);
|
||||
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||
var back = JsonSerializer.Deserialize<AbCipDriverOptions>(json, TestJsonOpts)!;
|
||||
back.Devices.Count.ShouldBe(2);
|
||||
back.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0");
|
||||
back.Tags.Count.ShouldBe(2);
|
||||
back.Tags[0].Name.ShouldBe("Speed");
|
||||
back.Devices[0].PlcFamily.ShouldBe(AbCipPlcFamily.ControlLogix);
|
||||
back.Devices[1].PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-59
@@ -41,11 +41,6 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-A"),
|
||||
new AbLegacyDeviceOptions("10.0.0.11", AbLegacyPlcFamily.MicroLogix),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int, Writable: false),
|
||||
new AbLegacyTagDefinition("Pump", "10.0.0.10", "B3:0/0", AbLegacyDataType.Bit, Writable: true),
|
||||
],
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _opts);
|
||||
@@ -63,12 +58,6 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
back.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Slc500);
|
||||
back.Devices[0].DeviceName.ShouldBe("PLC-A");
|
||||
back.Devices[1].PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
|
||||
back.Tags.Count.ShouldBe(2);
|
||||
back.Tags[0].Name.ShouldBe("Level");
|
||||
back.Tags[0].Address.ShouldBe("N7:5");
|
||||
back.Tags[0].DataType.ShouldBe(AbLegacyDataType.Int);
|
||||
back.Tags[0].Writable.ShouldBeFalse();
|
||||
back.Tags[1].DataType.ShouldBe(AbLegacyDataType.Bit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -114,37 +103,11 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
back.DeviceName.ShouldBe("PLC-A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_round_trips_through_definition()
|
||||
{
|
||||
var row = new AbLegacyDriverPage.AbLegacyTagRow
|
||||
{
|
||||
Name = "Level", DeviceHostAddress = "10.0.0.10", Address = "N7:5",
|
||||
DataType = AbLegacyDataType.Int, Writable = true,
|
||||
};
|
||||
var def = row.ToDefinition();
|
||||
var back = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(def);
|
||||
|
||||
back.Name.ShouldBe("Level");
|
||||
back.DeviceHostAddress.ShouldBe("10.0.0.10");
|
||||
back.Address.ShouldBe("N7:5");
|
||||
back.DataType.ShouldBe(AbLegacyDataType.Int);
|
||||
back.Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_preserves_unedited_fields()
|
||||
{
|
||||
var original = new AbLegacyTagDefinition(
|
||||
"Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int,
|
||||
Writable: true, WriteIdempotent: true);
|
||||
var row = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(original);
|
||||
row.Name = "Renamed";
|
||||
|
||||
var back = row.ToDefinition();
|
||||
back.Name.ShouldBe("Renamed");
|
||||
back.WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (AbLegacyDriverPage.AbLegacyTagRow +
|
||||
// AbLegacyDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
|
||||
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
|
||||
// + the device-list serialization below, all carrying the AbLegacyPlcFamily enum) stays.
|
||||
|
||||
[Fact]
|
||||
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||
@@ -155,32 +118,19 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTagRow_rejects_duplicate_name()
|
||||
{
|
||||
var rows = new List<AbLegacyDriverPage.AbLegacyTagRow> { new() { Name = "Level" } };
|
||||
AbLegacyDriverPage.AbLegacyTagRow.ValidateRow(new() { Name = "Level" }, rows, null)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_and_tag_lists_survive_options_serialize_round_trip()
|
||||
public void Device_list_survives_options_serialize_round_trip()
|
||||
{
|
||||
var devices = new List<AbLegacyDeviceOptions>
|
||||
{
|
||||
new("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-1"),
|
||||
new("10.0.0.11", AbLegacyPlcFamily.MicroLogix, "PLC-2"),
|
||||
};
|
||||
var tags = new List<AbLegacyTagDefinition>
|
||||
{
|
||||
new("Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int),
|
||||
new("Pump", "10.0.0.11", "B3:0/0", AbLegacyDataType.Bit),
|
||||
};
|
||||
var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices, tags);
|
||||
var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices);
|
||||
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||
var back = JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, TestJsonOpts)!;
|
||||
back.Devices.Count.ShouldBe(2);
|
||||
back.Devices[0].HostAddress.ShouldBe("10.0.0.10");
|
||||
back.Tags.Count.ShouldBe(2);
|
||||
back.Tags[0].Name.ShouldBe("Level");
|
||||
back.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Slc500);
|
||||
back.Devices[1].PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-52
@@ -52,7 +52,6 @@ public sealed class FocasDriverPageFormSerializationTests
|
||||
TimerPollInterval = TimeSpan.FromSeconds(60),
|
||||
},
|
||||
Devices = [],
|
||||
Tags = [],
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _opts);
|
||||
@@ -73,7 +72,6 @@ public sealed class FocasDriverPageFormSerializationTests
|
||||
back.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
back.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
back.Devices.ShouldBeEmpty();
|
||||
back.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,7 +121,7 @@ public sealed class FocasDriverPageFormSerializationTests
|
||||
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.FocasDriverPage.FormModel.FromOptions(opts);
|
||||
var roundTripped = form.ToOptions([], []);
|
||||
var roundTripped = form.ToOptions([]);
|
||||
|
||||
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
||||
roundTripped.Probe.Enabled.ShouldBeTrue();
|
||||
@@ -168,37 +166,11 @@ public sealed class FocasDriverPageFormSerializationTests
|
||||
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_round_trips_through_definition()
|
||||
{
|
||||
var row = new FocasDriverPage.FocasTagRow
|
||||
{
|
||||
Name = "MacroVar", DeviceHostAddress = "192.168.0.10:8193", Address = "MACRO:500",
|
||||
DataType = FocasDataType.Float64, Writable = true,
|
||||
};
|
||||
var def = row.ToDefinition();
|
||||
var back = FocasDriverPage.FocasTagRow.FromDefinition(def);
|
||||
|
||||
back.Name.ShouldBe("MacroVar");
|
||||
back.DeviceHostAddress.ShouldBe("192.168.0.10:8193");
|
||||
back.Address.ShouldBe("MACRO:500");
|
||||
back.DataType.ShouldBe(FocasDataType.Float64);
|
||||
back.Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_preserves_unedited_fields()
|
||||
{
|
||||
var original = new FocasTagDefinition(
|
||||
"MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64,
|
||||
Writable: true, WriteIdempotent: true);
|
||||
var row = FocasDriverPage.FocasTagRow.FromDefinition(original);
|
||||
row.Name = "Renamed";
|
||||
|
||||
var back = row.ToDefinition();
|
||||
back.Name.ShouldBe("Renamed");
|
||||
back.WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (FocasDriverPage.FocasTagRow +
|
||||
// FocasDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
|
||||
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
|
||||
// + the device-list serialization below, all carrying the FocasCncSeries enum) stays.
|
||||
|
||||
[Fact]
|
||||
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||
@@ -209,34 +181,19 @@ public sealed class FocasDriverPageFormSerializationTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTagRow_rejects_duplicate_name()
|
||||
{
|
||||
var rows = new List<FocasDriverPage.FocasTagRow> { new() { Name = "MacroVar" } };
|
||||
FocasDriverPage.FocasTagRow.ValidateRow(new() { Name = "MacroVar" }, rows, null)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_and_tag_lists_survive_options_serialize_round_trip()
|
||||
public void Device_list_survives_options_serialize_round_trip()
|
||||
{
|
||||
var devices = new List<FocasDeviceOptions>
|
||||
{
|
||||
new("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i),
|
||||
new("192.168.0.20:8193", "CNC2", FocasCncSeries.Zero_i_F),
|
||||
};
|
||||
var tags = new List<FocasTagDefinition>
|
||||
{
|
||||
new("MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64),
|
||||
new("Flag", "192.168.0.20:8193", "X0.0", FocasDataType.Bit),
|
||||
};
|
||||
var opts = new FocasDriverPage.FormModel().ToOptions(devices, tags);
|
||||
var opts = new FocasDriverPage.FormModel().ToOptions(devices);
|
||||
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||
var back = JsonSerializer.Deserialize<FocasDriverOptions>(json, TestJsonOpts)!;
|
||||
back.Devices.Count.ShouldBe(2);
|
||||
back.Devices[0].HostAddress.ShouldBe("192.168.0.10:8193");
|
||||
back.Devices[0].Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||
back.Tags.Count.ShouldBe(2);
|
||||
back.Tags[0].Name.ShouldBe("MacroVar");
|
||||
back.Tags[0].Address.ShouldBe("MACRO:500");
|
||||
back.Devices[1].Series.ShouldBe(FocasCncSeries.Zero_i_F);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-64
@@ -15,12 +15,6 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions TestJsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesKnownFields()
|
||||
{
|
||||
@@ -112,62 +106,9 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
back.ProbeTimeoutSeconds.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_round_trips_through_definition()
|
||||
{
|
||||
var row = new ModbusDriverPage.ModbusTagRow
|
||||
{
|
||||
Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegisters, Address = 40001,
|
||||
DataType = ModbusDataType.Int16, Writable = true,
|
||||
};
|
||||
var def = row.ToDefinition();
|
||||
var back = ModbusDriverPage.ModbusTagRow.FromDefinition(def);
|
||||
|
||||
back.Name.ShouldBe("Pump1_Speed");
|
||||
back.Address.ShouldBe(40001);
|
||||
back.DataType.ShouldBe(ModbusDataType.Int16);
|
||||
back.Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_list_survives_options_serialize_round_trip()
|
||||
{
|
||||
var tags = new List<ModbusTagDefinition>
|
||||
{
|
||||
new("A", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16),
|
||||
new("B", ModbusRegion.Coils, 2, ModbusDataType.Bool),
|
||||
};
|
||||
var opts = new ModbusDriverPage.FormModel().ToOptions(tags);
|
||||
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||
var back = JsonSerializer.Deserialize<ModbusDriverOptions>(json, TestJsonOpts)!;
|
||||
back.Tags.Count.ShouldBe(2);
|
||||
back.Tags[0].Name.ShouldBe("A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateRow_rejects_duplicate_name()
|
||||
{
|
||||
var rows = new List<ModbusDriverPage.ModbusTagRow> { new() { Name = "A" } };
|
||||
ModbusDriverPage.ModbusTagRow.ValidateRow(new() { Name = "A" }, rows, null)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDefinition_preserves_unedited_fields()
|
||||
{
|
||||
var original = new ModbusTagDefinition(
|
||||
"T", ModbusRegion.HoldingRegisters, 5, ModbusDataType.Int16,
|
||||
StringByteOrder: ModbusStringByteOrder.LowByteFirst,
|
||||
ArrayCount: 10, Deadband: 0.5, UnitId: 3, CoalesceProhibited: true);
|
||||
var row = ModbusDriverPage.ModbusTagRow.FromDefinition(original);
|
||||
row.Name = "Renamed";
|
||||
|
||||
var back = row.ToDefinition();
|
||||
back.Name.ShouldBe("Renamed");
|
||||
back.UnitId.ShouldBe((byte)3);
|
||||
back.ArrayCount.ShouldBe(10);
|
||||
back.Deadband.ShouldBe(0.5);
|
||||
back.StringByteOrder.ShouldBe(ModbusStringByteOrder.LowByteFirst);
|
||||
back.CoalesceProhibited.ShouldBeTrue();
|
||||
}
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (ModbusDriverPage.ModbusTagRow +
|
||||
// ModbusDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* / Tag_list_survives_* / ValidateRow_* / ToDefinition_preserves_* tests were removed.
|
||||
// The connection/protocol-field + enum-serialization coverage (ModbusFamily, MelsecFamily) is
|
||||
// retained by RoundTrip_PreservesKnownFields above unchanged.
|
||||
}
|
||||
|
||||
+8
-114
@@ -7,6 +7,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
// v3 Batch-1 migration: the S7 driver page dropped its pre-declared Tags editor — tags now live in
|
||||
// the Raw tree (/raw, Batch 2), and S7DriverOptions.Tags (the typed S7TagDefinition list) +
|
||||
// S7DriverPage.S7TagRow were retired (the driver now consumes RawTags). The former tag-editor and
|
||||
// tag-serialization tests (S7TagRow_*, TagList_SerializeRoundTrip_PreservesTags, and the tag legs of
|
||||
// the round-trip tests) were removed. The connection/protocol-field + enum-serialization round-trip
|
||||
// coverage (CpuType, S7DataType) is retained below unchanged.
|
||||
public sealed class S7DriverPageFormSerializationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
@@ -33,7 +39,6 @@ public sealed class S7DriverPageFormSerializationTests
|
||||
Timeout = TimeSpan.FromSeconds(3),
|
||||
},
|
||||
ProbeTimeoutSeconds = 30,
|
||||
Tags = [],
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _opts);
|
||||
@@ -51,7 +56,6 @@ public sealed class S7DriverPageFormSerializationTests
|
||||
back.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(15));
|
||||
back.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
back.ProbeTimeoutSeconds.ShouldBe(30);
|
||||
back.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -70,12 +74,6 @@ public sealed class S7DriverPageFormSerializationTests
|
||||
[Fact]
|
||||
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
|
||||
{
|
||||
Host = "192.168.1.50",
|
||||
@@ -91,15 +89,10 @@ public sealed class S7DriverPageFormSerializationTests
|
||||
Timeout = TimeSpan.FromSeconds(4),
|
||||
},
|
||||
ProbeTimeoutSeconds = 20,
|
||||
Tags = tags,
|
||||
};
|
||||
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.S7DriverPage.FormModel.FromOptions(opts);
|
||||
var tagRows = opts.Tags
|
||||
.Select(ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers.S7DriverPage.S7TagRow.FromDefinition)
|
||||
.ToList();
|
||||
var roundTripped = form.ToOptions(tagRows.Select(r => r.ToDefinition()).ToList());
|
||||
var form = S7DriverPage.FormModel.FromOptions(opts);
|
||||
var roundTripped = form.ToOptions();
|
||||
|
||||
roundTripped.Host.ShouldBe("192.168.1.50");
|
||||
roundTripped.Port.ShouldBe(102);
|
||||
@@ -111,104 +104,5 @@ public sealed class S7DriverPageFormSerializationTests
|
||||
roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(8));
|
||||
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
||||
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();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void S7TagRow_RoundTrip_PreservesEditableFields()
|
||||
{
|
||||
var def = new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true, StringLength: 80);
|
||||
|
||||
var row = S7DriverPage.S7TagRow.FromDefinition(def);
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.Name.ShouldBe("Speed");
|
||||
back.Address.ShouldBe("DB1.DBD0");
|
||||
back.DataType.ShouldBe(S7DataType.Float32);
|
||||
back.Writable.ShouldBeTrue();
|
||||
back.StringLength.ShouldBe(80);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void S7TagRow_CarriesThroughUneditedFields()
|
||||
{
|
||||
// WriteIdempotent is not exposed by the editor; it must survive FromDefinition→edit→ToDefinition.
|
||||
var def = new S7TagDefinition("Setpoint", "DB10.DBD0", S7DataType.Float32, Writable: true, WriteIdempotent: true);
|
||||
|
||||
var row = S7DriverPage.S7TagRow.FromDefinition(def);
|
||||
row.Name = "SetpointRenamed";
|
||||
row.Writable = false;
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.Name.ShouldBe("SetpointRenamed");
|
||||
back.Writable.ShouldBeFalse();
|
||||
// Un-edited field carried through via _source.
|
||||
back.WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void S7TagRow_ValidateRow_RejectsDuplicateNames()
|
||||
{
|
||||
var all = new List<S7DriverPage.S7TagRow>
|
||||
{
|
||||
S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32)),
|
||||
S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Status", "DB1.DBW4", S7DataType.Int16)),
|
||||
};
|
||||
|
||||
// Editing index 1 to a name that case-insensitively collides with index 0.
|
||||
var edited = all[1].Clone();
|
||||
edited.Name = "speed";
|
||||
S7DriverPage.S7TagRow.ValidateRow(edited, all, editIndex: 1)
|
||||
.ShouldBe("Duplicate tag name 'speed'.");
|
||||
|
||||
// Required-name guard.
|
||||
var blank = new S7DriverPage.S7TagRow();
|
||||
S7DriverPage.S7TagRow.ValidateRow(blank, all, editIndex: null)
|
||||
.ShouldBe("Name is required.");
|
||||
|
||||
// Unique name passes.
|
||||
var ok = all[1].Clone();
|
||||
ok.Name = "Torque";
|
||||
S7DriverPage.S7TagRow.ValidateRow(ok, all, editIndex: 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagList_SerializeRoundTrip_PreservesTags()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "10.1.1.1",
|
||||
Tags =
|
||||
[
|
||||
new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true),
|
||||
new S7TagDefinition("Name", "DB2.DBB0", S7DataType.String, Writable: false, StringLength: 32),
|
||||
],
|
||||
};
|
||||
|
||||
var optsSkip = new JsonSerializerOptions(_opts)
|
||||
{
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
var json = JsonSerializer.Serialize(opts, optsSkip);
|
||||
var back = JsonSerializer.Deserialize<S7DriverOptions>(json, optsSkip);
|
||||
|
||||
back.ShouldNotBeNull();
|
||||
back.Tags.Count.ShouldBe(2);
|
||||
back.Tags[0].Name.ShouldBe("Speed");
|
||||
back.Tags[0].Address.ShouldBe("DB1.DBD0");
|
||||
back.Tags[0].DataType.ShouldBe(S7DataType.Float32);
|
||||
back.Tags[0].Writable.ShouldBeTrue();
|
||||
back.Tags[1].Name.ShouldBe("Name");
|
||||
back.Tags[1].DataType.ShouldBe(S7DataType.String);
|
||||
back.Tags[1].StringLength.ShouldBe(32);
|
||||
back.Tags[1].Writable.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
+15
-20
@@ -15,9 +15,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
/// <remarks>
|
||||
/// Fidelity finding (see <see cref="ScriptTagCatalog"/> doc comment): the runtime Akka path resolves
|
||||
/// a <c>ctx.GetTag</c> literal against the driver <b>FullName</b> (the wire reference in
|
||||
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. The
|
||||
/// catalog therefore emits the FullName for equipment tags, the MXAccess dot-ref for FolderPath-scoped
|
||||
/// tags (EquipmentId null), and the leaf Name for virtual tags, and deliberately omits UNS browse paths. These
|
||||
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. v3: Tag is
|
||||
/// Raw-only (no <c>EquipmentId</c>/<c>FolderPath</c>/<c>DriverInstanceId</c>), so the catalog projects
|
||||
/// the surviving <c>Name</c>/<c>DataType</c>/<c>TagConfig</c> columns: the resolvable path derives from
|
||||
/// the <c>TagConfig.FullName</c> field when present, else the tag <c>Name</c>; the projected
|
||||
/// <c>DriverInstanceId</c> is always <see langword="null"/>. Virtual tags emit their leaf Name. These
|
||||
/// assertions check the resolvable key is present AND that the UNS browse paths are absent.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
@@ -59,26 +61,23 @@ public sealed class ScriptTagCatalogTests
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
|
||||
// Equipment driver tag — the GetTag key is the driver FullName from TagConfig.
|
||||
// Raw tag — the GetTag key is the driver FullName from TagConfig.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-EQ",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = "EQ-1",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "Speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"Motor.Speed\"}",
|
||||
});
|
||||
|
||||
// FolderPath-scoped tag — EquipmentId null, FolderPath set; subscribes by "FolderPath.Name".
|
||||
// Raw tag whose FullName is an MXAccess dot-ref; resolves by that FullName.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-SP",
|
||||
DriverInstanceId = "DRV-GALAXY",
|
||||
EquipmentId = null,
|
||||
DeviceId = "DEV-1",
|
||||
Name = "DownloadPath",
|
||||
FolderPath = "DelmiaReceiver_001",
|
||||
DataType = "String",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
|
||||
@@ -174,8 +173,7 @@ public sealed class ScriptTagCatalogTests
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-BAD",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = "EQ-1",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "Broken",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
@@ -205,10 +203,8 @@ public sealed class ScriptTagCatalogTests
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-SP-ROOT",
|
||||
DriverInstanceId = "DRV-GALAXY",
|
||||
EquipmentId = null,
|
||||
DeviceId = "DEV-1",
|
||||
Name = "RootScalar",
|
||||
FolderPath = null,
|
||||
DataType = "String",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"RootScalar\"}",
|
||||
@@ -233,8 +229,8 @@ public sealed class ScriptTagCatalogTests
|
||||
paths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A FolderPath-scoped tag resolves by its "FolderPath.Name" dot-ref to a Tag-kind info
|
||||
/// with the configured DataType + DriverInstanceId.</summary>
|
||||
/// <summary>A raw tag whose FullName is a dot-ref resolves by that FullName to a Tag-kind info with
|
||||
/// the configured DataType. v3: Tag no longer binds a driver, so the projected DriverInstanceId is null.</summary>
|
||||
[Fact]
|
||||
public async Task GetTagInfo_unbound_tag_returns_tag_info()
|
||||
{
|
||||
@@ -247,7 +243,7 @@ public sealed class ScriptTagCatalogTests
|
||||
info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath");
|
||||
info.Kind.ShouldBe("Tag");
|
||||
info.DataType.ShouldBe("String");
|
||||
info.DriverInstanceId.ShouldBe("DRV-GALAXY");
|
||||
info.DriverInstanceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary>
|
||||
@@ -353,8 +349,7 @@ public sealed class ScriptTagCatalogTests
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-EQ2",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = "EQ-1",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "Speed2",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
|
||||
+7
-65
@@ -31,7 +31,6 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
},
|
||||
ProbeTimeoutSeconds = 20,
|
||||
Devices = [],
|
||||
Tags = [],
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _opts);
|
||||
@@ -48,7 +47,6 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
back.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
back.ProbeTimeoutSeconds.ShouldBe(20);
|
||||
back.Devices.ShouldBeEmpty();
|
||||
back.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,7 +82,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.FormModel.FromOptions(opts);
|
||||
var roundTripped = form.ToOptions([], []);
|
||||
var roundTripped = form.ToOptions([]);
|
||||
|
||||
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
roundTripped.UseNativeNotifications.ShouldBeTrue();
|
||||
@@ -140,69 +138,20 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
error.ShouldContain("Duplicate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_RoundTrip_PreservesEditableFields()
|
||||
{
|
||||
var def = new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real, Writable: false);
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.Name.ShouldBe("Speed");
|
||||
back.DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.SymbolPath.ShouldBe("MAIN.rSpeed");
|
||||
back.DataType.ShouldBe(TwinCATDataType.Real);
|
||||
back.Writable.ShouldBeFalse();
|
||||
}
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (TwinCATDriverPage.TwinCATTagRow +
|
||||
// TwinCATDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* tests and the tag leg of FormModel_ToOptions_* were removed. The multi-device Devices
|
||||
// editor is RETAINED, so its coverage (DeviceRow_* + the device-list serialization below) stays.
|
||||
|
||||
[Fact]
|
||||
public void TagRow_CarriesThroughUneditedWriteIdempotent()
|
||||
{
|
||||
// WriteIdempotent is not exposed by the editor; it must survive a load→edit→save via _source.
|
||||
var def = new TwinCATTagDefinition("Cmd", "192.168.0.1.1.1:851", "GVL.Start", TwinCATDataType.Bool,
|
||||
Writable: true, WriteIdempotent: true);
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
|
||||
row.Name = "CmdRenamed"; // touch an edited field
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.Name.ShouldBe("CmdRenamed");
|
||||
back.WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagRow_ValidateRow_RejectsDuplicateName()
|
||||
{
|
||||
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(
|
||||
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real));
|
||||
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow { Name = "SPEED" }; // case-insensitive collision
|
||||
|
||||
var all = new[] { existing, dup };
|
||||
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATTagRow.ValidateRow(dup, all, editIndex: 1);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("Duplicate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormModel_ToOptions_SerializesDeviceAndTagLists()
|
||||
public void FormModel_ToOptions_SerializesDeviceList()
|
||||
{
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions());
|
||||
|
||||
var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") };
|
||||
var tags = new[]
|
||||
{
|
||||
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real,
|
||||
Writable: true, WriteIdempotent: true),
|
||||
};
|
||||
|
||||
var opts = form.ToOptions(devices, tags);
|
||||
var opts = form.ToOptions(devices);
|
||||
var json = JsonSerializer.Serialize(opts, _opts);
|
||||
var back = JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _opts);
|
||||
|
||||
@@ -210,12 +159,5 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
back.Devices.Count.ShouldBe(1);
|
||||
back.Devices[0].HostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.Devices[0].DeviceName.ShouldBe("PLC1");
|
||||
back.Tags.Count.ShouldBe(1);
|
||||
back.Tags[0].Name.ShouldBe("Speed");
|
||||
back.Tags[0].DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.Tags[0].SymbolPath.ShouldBe("MAIN.rSpeed");
|
||||
back.Tags[0].DataType.ShouldBe(TwinCATDataType.Real);
|
||||
back.Tags[0].Writable.ShouldBeTrue();
|
||||
back.Tags[0].WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
+28
-23
@@ -4,6 +4,11 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
// v3 Batch-1 migration: OpcUaClientTagConfigModel's address key was renamed FullName → NodeId and
|
||||
// its serialized JSON key moved from PascalCase "FullName" to camelCase "nodeId" (TagConfig no longer
|
||||
// carries identity; the upstream node reference is an ordinary driver-address field). The tests below
|
||||
// track that rename; the preserve-unknown-keys round-trip coverage (unrecognised keys + the TagModal
|
||||
// isHistorized/historianTagname merge keys) is retained unchanged.
|
||||
public sealed class OpcUaClientTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
@@ -15,52 +20,52 @@ public sealed class OpcUaClientTagConfigModelTests
|
||||
{
|
||||
var m = OpcUaClientTagConfigModel.FromJson(json);
|
||||
|
||||
m.FullName.ShouldBe("");
|
||||
m.NodeId.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_FullName()
|
||||
public void FromJson_reads_NodeId()
|
||||
{
|
||||
var m = OpcUaClientTagConfigModel.FromJson(
|
||||
"""{"FullName":"nsu=urn:srv;s=Line3.Temp"}""");
|
||||
"""{"nodeId":"nsu=urn:srv;s=Line3.Temp"}""");
|
||||
|
||||
m.FullName.ShouldBe("nsu=urn:srv;s=Line3.Temp");
|
||||
m.NodeId.ShouldBe("nsu=urn:srv;s=Line3.Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_FullName()
|
||||
public void Round_trip_preserves_NodeId()
|
||||
{
|
||||
var m = new OpcUaClientTagConfigModel { FullName = "ns=2;s=Line3.Temp" };
|
||||
var m = new OpcUaClientTagConfigModel { NodeId = "ns=2;s=Line3.Temp" };
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = OpcUaClientTagConfigModel.FromJson(json);
|
||||
|
||||
m2.FullName.ShouldBe("ns=2;s=Line3.Temp");
|
||||
m2.NodeId.ShouldBe("ns=2;s=Line3.Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_PascalCase_FullName()
|
||||
public void ToJson_emits_camelCase_NodeId()
|
||||
{
|
||||
var m = new OpcUaClientTagConfigModel { FullName = "ns=2;s=Line3.Temp" };
|
||||
var m = new OpcUaClientTagConfigModel { NodeId = "ns=2;s=Line3.Temp" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
// FullName is the composer/walker contract key — PascalCase, case-sensitive.
|
||||
json.ShouldContain("\"FullName\":\"ns=2;s=Line3.Temp\"");
|
||||
json.ShouldNotContain("\"fullName\"", Case.Sensitive);
|
||||
// nodeId is the v3 address key — camelCase, replacing the legacy PascalCase FullName identity key.
|
||||
json.ShouldContain("\"nodeId\":\"ns=2;s=Line3.Temp\"");
|
||||
json.ShouldNotContain("\"FullName\"", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = OpcUaClientTagConfigModel
|
||||
.FromJson("""{"FullName":"ns=2;s=X","samplingIntervalMs":250}""")
|
||||
.FromJson("""{"nodeId":"ns=2;s=X","samplingIntervalMs":250}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("samplingIntervalMs");
|
||||
json.ShouldContain("250");
|
||||
// and the exposed field still round-trips
|
||||
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||
json.ShouldContain("\"nodeId\":\"ns=2;s=X\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,32 +74,32 @@ public sealed class OpcUaClientTagConfigModelTests
|
||||
// The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model
|
||||
// does NOT model them, so they must survive a load→save untouched as preserved unknown keys.
|
||||
var json = OpcUaClientTagConfigModel
|
||||
.FromJson("""{"FullName":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""")
|
||||
.FromJson("""{"nodeId":"ns=2;s=X","isHistorized":true,"historianTagname":"Line3_Temp"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("\"isHistorized\":true");
|
||||
json.ShouldContain("\"historianTagname\":\"Line3_Temp\"");
|
||||
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||
json.ShouldContain("\"nodeId\":\"ns=2;s=X\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_trims_FullName()
|
||||
public void ToJson_trims_NodeId()
|
||||
{
|
||||
var json = new OpcUaClientTagConfigModel { FullName = " ns=2;s=X " }.ToJson();
|
||||
var json = new OpcUaClientTagConfigModel { NodeId = " ns=2;s=X " }.ToJson();
|
||||
|
||||
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
|
||||
json.ShouldContain("\"nodeId\":\"ns=2;s=X\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_FullName_blank()
|
||||
public void Validate_returns_error_when_NodeId_blank()
|
||||
{
|
||||
new OpcUaClientTagConfigModel().Validate().ShouldNotBeNullOrEmpty();
|
||||
new OpcUaClientTagConfigModel { FullName = " " }.Validate().ShouldNotBeNullOrEmpty();
|
||||
new OpcUaClientTagConfigModel { NodeId = " " }.Validate().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_FullName_present()
|
||||
public void Validate_returns_null_when_NodeId_present()
|
||||
{
|
||||
new OpcUaClientTagConfigModel { FullName = "ns=2;s=X" }.Validate().ShouldBeNull();
|
||||
new OpcUaClientTagConfigModel { NodeId = "ns=2;s=X" }.Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,19 +147,19 @@ public sealed class TagArrayConfigTests
|
||||
/// Full seam: the array helper and a driver-typed editor compose over the same canonical TagConfig
|
||||
/// blob without clobbering each other. Setting the array keys, round-tripping through the OpcUaClient
|
||||
/// typed editor (FromJson → ToJson), then reading the array keys back recovers them intact — and the
|
||||
/// editor's own FullName field survives the array merge.
|
||||
/// editor's own NodeId field survives the array merge.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Array_keys_survive_a_typed_editor_round_trip()
|
||||
{
|
||||
var withArray = TagArrayConfig.Set("""{"FullName":"ns=2;s=X"}""", isArray: true, arrayLength: 12);
|
||||
var withArray = TagArrayConfig.Set("""{"nodeId":"ns=2;s=X"}""", isArray: true, arrayLength: 12);
|
||||
|
||||
var afterEditor = OpcUaClientTagConfigModel.FromJson(withArray).ToJson();
|
||||
|
||||
var a = TagArrayConfig.Read(afterEditor);
|
||||
a.IsArray.ShouldBeTrue();
|
||||
a.ArrayLength.ShouldBe(12u);
|
||||
OpcUaClientTagConfigModel.FromJson(afterEditor).FullName.ShouldBe("ns=2;s=X");
|
||||
OpcUaClientTagConfigModel.FromJson(afterEditor).NodeId.ShouldBe("ns=2;s=X");
|
||||
}
|
||||
|
||||
/// <summary>The array helper and the historize helper compose over the same blob (both preserve
|
||||
|
||||
@@ -37,9 +37,10 @@ public sealed class TagConfigValidatorTests
|
||||
TagConfigValidator.Validate(driverType, null).ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
// v3 Batch-1: the OpcUaClient editor's address key was renamed FullName → nodeId (camelCase).
|
||||
[Fact]
|
||||
public void OpcUaClient_with_full_name_is_valid()
|
||||
=> TagConfigValidator.Validate("OpcUaClient", """{"FullName":"ns=2;s=Line3.Temp"}""").ShouldBeNull();
|
||||
public void OpcUaClient_with_node_id_is_valid()
|
||||
=> TagConfigValidator.Validate("OpcUaClient", """{"nodeId":"ns=2;s=Line3.Temp"}""").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void S7_with_address_is_valid()
|
||||
|
||||
@@ -128,19 +128,19 @@ public sealed class TagHistorizeConfigTests
|
||||
/// Full seam: the history helper and a driver-typed editor compose over the same canonical TagConfig
|
||||
/// blob without clobbering each other. Setting the history keys, round-tripping through the
|
||||
/// OpcUaClient typed editor (FromJson → ToJson), then reading the history keys back recovers them
|
||||
/// intact — and the editor's own FullName field survives the history merge.
|
||||
/// intact — and the editor's own NodeId field survives the history merge.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void History_keys_survive_a_typed_editor_round_trip()
|
||||
{
|
||||
var withHistory = TagHistorizeConfig.Set(
|
||||
"""{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "TN");
|
||||
"""{"nodeId":"ns=2;s=X"}""", isHistorized: true, historianTagname: "TN");
|
||||
|
||||
var afterEditor = OpcUaClientTagConfigModel.FromJson(withHistory).ToJson();
|
||||
|
||||
var h = TagHistorizeConfig.Read(afterEditor);
|
||||
h.IsHistorized.ShouldBeTrue();
|
||||
h.HistorianTagname.ShouldBe("TN");
|
||||
OpcUaClientTagConfigModel.FromJson(afterEditor).FullName.ShouldBe("ns=2;s=X");
|
||||
OpcUaClientTagConfigModel.FromJson(afterEditor).NodeId.ShouldBe("ns=2;s=X");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +87,15 @@ public sealed class UnsTreeServiceAreaLineTests
|
||||
area.ClusterId.ShouldBe("MAIN");
|
||||
}
|
||||
|
||||
/// <summary>The #122 guard blocks moving an area to a new cluster when driver-bound
|
||||
/// equipment would be orphaned from its driver's cluster.</summary>
|
||||
/// <summary>
|
||||
/// v3 Batch-1: the decision-#122 driver-orphan guard was RETIRED — equipment no longer binds a
|
||||
/// driver (<c>Equipment.DriverInstanceId</c> is gone), so a cluster move can never orphan
|
||||
/// driver-bound equipment. The former blocked/allowed guard tests are replaced by this one, which
|
||||
/// proves a cross-cluster area reassignment now succeeds unconditionally (equipment under the area
|
||||
/// rides along). The equipment↔driver cluster invariant is re-covered by the Batch-2 Raw-tree tests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateArea_reassign_cluster_blocked_when_driver_bound_equipment_would_orphan()
|
||||
public async Task UpdateArea_reassign_cluster_now_allowed_guard_retired()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
|
||||
@@ -106,16 +111,6 @@ public sealed class UnsTreeServiceAreaLineTests
|
||||
UnsLineId = "LINE-X",
|
||||
Name = "m",
|
||||
MachineCode = "machine_x",
|
||||
DriverInstanceId = "DRV-MAIN",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-MAIN",
|
||||
ClusterId = "MAIN",
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").RowVersion;
|
||||
@@ -123,97 +118,12 @@ public sealed class UnsTreeServiceAreaLineTests
|
||||
|
||||
var result = await service.UpdateAreaAsync("AREA-X", "a", null, "SITE-A", rv);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("decision #122");
|
||||
result.Error.ShouldContain("EQ-BOUND");
|
||||
result.Error.ShouldContain("SITE-A");
|
||||
result.Error.ShouldContain("MAIN");
|
||||
|
||||
// The area must not have been moved.
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").ClusterId.ShouldBe("MAIN");
|
||||
}
|
||||
|
||||
/// <summary>The #122 guard allows the move when the area's driver-bound equipment's driver
|
||||
/// is already in the target cluster (driverCluster == newClusterId → no orphan).</summary>
|
||||
[Fact]
|
||||
public async Task UpdateArea_reassign_cluster_allowed_when_driver_is_in_target_cluster()
|
||||
{
|
||||
// Seed: AREA-Z in cluster MAIN, a line, equipment bound to DRV-SITE-A whose cluster is
|
||||
// SITE-A. Reassigning the area to SITE-A must be allowed because the driver is already
|
||||
// there — the #122 guard's `driverCluster != newClusterId` condition is false.
|
||||
var (service, dbName) = Fresh();
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-Z", ClusterId = "MAIN", Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-Z", UnsAreaId = "AREA-Z", Name = "l" });
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-SITE-A",
|
||||
ClusterId = "SITE-A",
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = "EQ-BOUND-Z",
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-Z",
|
||||
Name = "m",
|
||||
MachineCode = "machine_z",
|
||||
DriverInstanceId = "DRV-SITE-A",
|
||||
});
|
||||
db.SaveChanges();
|
||||
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-Z").RowVersion;
|
||||
}
|
||||
|
||||
var result = await service.UpdateAreaAsync("AREA-Z", "a", null, "SITE-A", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
// Verify the area actually moved to SITE-A via a fresh context.
|
||||
// The area moved to SITE-A.
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-Z").ClusterId.ShouldBe("SITE-A");
|
||||
}
|
||||
|
||||
/// <summary>The #122 guard allows the move when the equipment under the area is driver-less
|
||||
/// (DriverInstanceId == null).</summary>
|
||||
[Fact]
|
||||
public async Task UpdateArea_reassign_cluster_allowed_when_equipment_driverless()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-Y", ClusterId = "MAIN", Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-Y", UnsAreaId = "AREA-Y", Name = "l" });
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = "EQ-FREE",
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-Y",
|
||||
Name = "m",
|
||||
MachineCode = "machine_y",
|
||||
DriverInstanceId = null,
|
||||
});
|
||||
db.SaveChanges();
|
||||
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").RowVersion;
|
||||
}
|
||||
|
||||
var result = await service.UpdateAreaAsync("AREA-Y", "a", null, "SITE-A", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").ClusterId.ShouldBe("SITE-A");
|
||||
verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").ClusterId.ShouldBe("SITE-A");
|
||||
}
|
||||
|
||||
/// <summary>Updating an area that no longer exists returns the row-gone error.</summary>
|
||||
@@ -337,30 +247,22 @@ public sealed class UnsTreeServiceAreaLineTests
|
||||
result.Error.ShouldBe("Row no longer exists.");
|
||||
}
|
||||
|
||||
/// <summary>The #122 guard blocks reparenting a line to an area in a different cluster when
|
||||
/// the line's equipment is driver-bound (the driver lives in the original cluster).</summary>
|
||||
/// <summary>
|
||||
/// v3 Batch-1: the decision-#122 driver-orphan guard was RETIRED (equipment no longer binds a
|
||||
/// driver). The former blocked/allowed line-reparent guard tests are replaced by this one, which
|
||||
/// proves reparenting a line to an area in a different cluster now succeeds unconditionally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateLine_reparent_to_other_cluster_blocked_when_driver_bound()
|
||||
public async Task UpdateLine_reparent_to_other_cluster_now_allowed_guard_retired()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
// Source area A1 in cluster MAIN.
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-MAIN", ClusterId = "MAIN", Name = "area-main" });
|
||||
// Target area A2 in cluster SITE-A.
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-SITE-A", ClusterId = "SITE-A", Name = "area-site-a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-BOUND", UnsAreaId = "A1-MAIN", Name = "line" });
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-MAIN-122",
|
||||
ClusterId = "MAIN",
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = "EQ-LINE-BOUND",
|
||||
@@ -368,7 +270,6 @@ public sealed class UnsTreeServiceAreaLineTests
|
||||
UnsLineId = "LINE-BOUND",
|
||||
Name = "eq",
|
||||
MachineCode = "mc_line_bound",
|
||||
DriverInstanceId = "DRV-MAIN-122",
|
||||
});
|
||||
db.SaveChanges();
|
||||
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").RowVersion;
|
||||
@@ -376,52 +277,12 @@ public sealed class UnsTreeServiceAreaLineTests
|
||||
|
||||
var result = await service.UpdateLineAsync("LINE-BOUND", "line", null, "A2-SITE-A", rv);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("decision #122");
|
||||
result.Error.ShouldContain("EQ-LINE-BOUND");
|
||||
result.Error.ShouldContain("A2-SITE-A");
|
||||
result.Error.ShouldContain("MAIN");
|
||||
|
||||
// The line must not have moved.
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").UnsAreaId.ShouldBe("A1-MAIN");
|
||||
}
|
||||
|
||||
/// <summary>The #122 guard allows reparenting a line to an area in a different cluster when
|
||||
/// the line's equipment is driver-less (DriverInstanceId == null).</summary>
|
||||
[Fact]
|
||||
public async Task UpdateLine_reparent_to_other_cluster_allowed_when_equipment_driverless()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-FREE", ClusterId = "MAIN", Name = "area-main" });
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-FREE", ClusterId = "SITE-A", Name = "area-site-a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-FREE", UnsAreaId = "A1-FREE", Name = "line" });
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = "EQ-LINE-FREE",
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-FREE",
|
||||
Name = "eq",
|
||||
MachineCode = "mc_line_free",
|
||||
DriverInstanceId = null,
|
||||
});
|
||||
db.SaveChanges();
|
||||
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").RowVersion;
|
||||
}
|
||||
|
||||
var result = await service.UpdateLineAsync("LINE-FREE", "line", null, "A2-FREE", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
// The line's UnsAreaId must have changed to A2-FREE.
|
||||
// The line moved to the target area.
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").UnsAreaId.ShouldBe("A2-FREE");
|
||||
verify.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").UnsAreaId.ShouldBe("A2-SITE-A");
|
||||
}
|
||||
|
||||
// ----- DeleteLine -----
|
||||
|
||||
+4
-28
@@ -105,7 +105,6 @@ public sealed class UnsTreeServiceDeleteClusterTests
|
||||
{
|
||||
DriverInstanceId = "DRV-CL",
|
||||
ClusterId = "CL-DRV",
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
@@ -151,33 +150,10 @@ public sealed class UnsTreeServiceDeleteClusterTests
|
||||
verify.ServerClusters.Any(c => c.ClusterId == "CL-NODE").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A cluster with a namespace refuses deletion with a friendly message and stays put.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteCluster_with_namespace_refuses_and_keeps_row()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.ServerClusters.Add(NewCluster("CL-NS", "zb"));
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-CL",
|
||||
ClusterId = "CL-NS",
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:zb:cl-ns:equipment",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var result = await service.DeleteClusterAsync("CL-NS");
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("CL-NS");
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.ServerClusters.Any(c => c.ClusterId == "CL-NS").ShouldBeTrue();
|
||||
}
|
||||
// v3 Batch-1: the Namespace entity was deleted (Raw/UNS split), so DescribeClusterChildrenAsync
|
||||
// no longer carries a namespace child-check. The former DeleteCluster_with_namespace_refuses test
|
||||
// was removed. The cluster-nodes / UNS-areas / driver-instances refuse-if-children checks (above)
|
||||
// remain the enforced set.
|
||||
|
||||
// ----- DeleteEnterprise -----
|
||||
|
||||
|
||||
+6
-7
@@ -14,16 +14,15 @@ public sealed class UnsTreeServiceEquipmentChildRowsTests
|
||||
return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
}
|
||||
|
||||
// v3 Batch-1: the equipment↔Tag binding was retired (Tag is Raw-only; authoring moved to the Raw
|
||||
// tree in Batch 2). LoadTagsForEquipmentAsync is a stub that always returns empty — no equipment
|
||||
// has bound driver tags to list. The former in-name-order / EquipmentId-scoping assertions moved
|
||||
// with the authoring surface to the Batch-2 Raw-tree tests.
|
||||
[Fact]
|
||||
public async Task LoadTagsForEquipment_returns_tags_in_name_order_scoped()
|
||||
public async Task LoadTagsForEquipment_returns_empty_stub()
|
||||
{
|
||||
var rows = await SeededService().LoadTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
|
||||
rows.Count.ShouldBe(2); // the EquipmentId=null orphan tag is excluded
|
||||
rows[0].TagId.ShouldBe("TAG-2"); // "running" < "speed"
|
||||
rows[0].Name.ShouldBe("running");
|
||||
rows[0].DataType.ShouldBe("Boolean");
|
||||
rows[1].TagId.ShouldBe("TAG-1");
|
||||
rows[1].DataType.ShouldBe("Float");
|
||||
rows.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -41,7 +41,6 @@ public sealed class UnsTreeServiceEquipmentTests
|
||||
{
|
||||
DriverInstanceId = "DRV-1",
|
||||
ClusterId = driverCluster,
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
@@ -125,7 +124,11 @@ public sealed class UnsTreeServiceEquipmentTests
|
||||
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Binding equipment to a driver in the same cluster as the line is allowed.</summary>
|
||||
/// <summary>
|
||||
/// Passing a driver in the same cluster as the line passes the #122 guard and the equipment
|
||||
/// persists. v3: the equipment↔driver binding column was retired, so the driver is validated by
|
||||
/// the guard but not stored — the assertion drops to equipment existence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateEquipment_driver_in_same_cluster_allowed()
|
||||
{
|
||||
@@ -138,7 +141,7 @@ public sealed class UnsTreeServiceEquipmentTests
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBe("DRV-1");
|
||||
db.Equipment.Single(e => e.MachineCode == "machine_001").UnsLineId.ShouldBe("LINE-1");
|
||||
}
|
||||
|
||||
/// <summary>Driver-less equipment is allowed regardless of cluster (no #122 guard applies).</summary>
|
||||
@@ -154,7 +157,7 @@ public sealed class UnsTreeServiceEquipmentTests
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBeNull();
|
||||
db.Equipment.Single(e => e.MachineCode == "machine_001").UnsLineId.ShouldBe("LINE-1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -172,7 +175,6 @@ public sealed class UnsTreeServiceEquipmentTests
|
||||
{
|
||||
DriverInstanceId = "DRV-1",
|
||||
ClusterId = "MAIN",
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
@@ -286,8 +288,9 @@ public sealed class UnsTreeServiceEquipmentTests
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("decision #122");
|
||||
|
||||
// The equipment row is unchanged (v3 has no persisted driver-binding column to check).
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBeNull();
|
||||
verify.Equipment.Single(e => e.EquipmentId == equipmentId).MachineCode.ShouldBe("machine_001");
|
||||
}
|
||||
|
||||
/// <summary>Updating equipment with a MachineCode that already belongs to another row is blocked.</summary>
|
||||
@@ -344,8 +347,9 @@ public sealed class UnsTreeServiceEquipmentTests
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
// v3: the driver passes the #122 guard but is not persisted (binding column retired).
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBe("DRV-1");
|
||||
verify.Equipment.Single(e => e.EquipmentId == equipmentId).UnsLineId.ShouldBe("LINE-1");
|
||||
}
|
||||
|
||||
/// <summary>CreateEquipmentAsync returns the generated EQ- id in <c>CreatedId</c> so callers can navigate to the new page.</summary>
|
||||
|
||||
@@ -36,7 +36,6 @@ public sealed class UnsTreeServiceImportTests
|
||||
{
|
||||
DriverInstanceId = "DRV-1",
|
||||
ClusterId = driverCluster,
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
@@ -194,7 +193,8 @@ public sealed class UnsTreeServiceImportTests
|
||||
result.Skipped.ShouldBe(0);
|
||||
result.Errors.ShouldBeEmpty();
|
||||
|
||||
// v3: the driver passes the #122 guard but is not persisted (binding column retired).
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Single(e => e.MachineCode == "mc_1").DriverInstanceId.ShouldBe("DRV-1");
|
||||
db.Equipment.Single(e => e.MachineCode == "mc_1").UnsLineId.ShouldBe("LINE-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ public sealed class UnsTreeServiceLoadEditTests
|
||||
{
|
||||
DriverInstanceId = "DRV-B",
|
||||
ClusterId = UnsTreeTestDb.PopulatedClusterId,
|
||||
NamespaceId = "NS-1",
|
||||
Name = "modbus-b",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
@@ -117,7 +116,6 @@ public sealed class UnsTreeServiceLoadEditTests
|
||||
{
|
||||
DriverInstanceId = "DRV-A",
|
||||
ClusterId = UnsTreeTestDb.PopulatedClusterId,
|
||||
NamespaceId = "NS-1",
|
||||
Name = "galaxy-a",
|
||||
DriverType = "Galaxy",
|
||||
DriverConfig = "{}",
|
||||
@@ -127,7 +125,6 @@ public sealed class UnsTreeServiceLoadEditTests
|
||||
{
|
||||
DriverInstanceId = "DRV-OTHER",
|
||||
ClusterId = UnsTreeTestDb.EmptyClusterId,
|
||||
NamespaceId = "NS-2",
|
||||
Name = "other",
|
||||
DriverType = "S7",
|
||||
DriverConfig = "{}",
|
||||
@@ -144,7 +141,11 @@ public sealed class UnsTreeServiceLoadEditTests
|
||||
drivers[1].Display.ShouldBe("DRV-B — modbus-b (Modbus)");
|
||||
}
|
||||
|
||||
/// <summary>Loading a seeded tag maps its fields, owning equipment, and a non-empty RowVersion.</summary>
|
||||
/// <summary>
|
||||
/// Loading a seeded Raw tag maps its surviving fields (Name/DataType/AccessLevel/TagConfig) and a
|
||||
/// non-empty RowVersion. v3: Tag is Raw-only — the equipment + driver bindings were retired, so the
|
||||
/// DTO's <c>EquipmentId</c> / <c>DriverInstanceId</c> project as empty strings.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadTag_returns_dto()
|
||||
{
|
||||
@@ -154,9 +155,9 @@ public sealed class UnsTreeServiceLoadEditTests
|
||||
|
||||
dto.ShouldNotBeNull();
|
||||
dto.TagId.ShouldBe("TAG-1");
|
||||
dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
|
||||
dto.EquipmentId.ShouldBe(string.Empty);
|
||||
dto.Name.ShouldBe("speed");
|
||||
dto.DriverInstanceId.ShouldBe("DRV-1");
|
||||
dto.DriverInstanceId.ShouldBe(string.Empty);
|
||||
dto.DataType.ShouldBe("Float");
|
||||
dto.AccessLevel.ShouldBe(TagAccessLevel.Read);
|
||||
dto.TagConfig.ShouldBe("{}");
|
||||
|
||||
@@ -70,8 +70,9 @@ public sealed class UnsTreeServiceStructureTests
|
||||
.Children.Single()
|
||||
.Children.Single();
|
||||
|
||||
// Seed: 2 driver tags + 1 virtual tag (the orphan tag has no equipment and is excluded).
|
||||
equipment.ChildCount.ShouldBe(3);
|
||||
// v3: Tag is Raw-only — driver tags no longer bind to equipment, so only the 1 virtual tag
|
||||
// contributes to the per-equipment badge count (the 2 seeded raw tags are not counted).
|
||||
equipment.ChildCount.ShouldBe(1);
|
||||
equipment.HasLazyChildren.ShouldBeFalse();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,20 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="UnsTreeService.LoadTagDriversForEquipmentAsync"/> surfaces each
|
||||
/// candidate driver's <c>DriverType</c> and <c>DriverConfig</c> alongside its id and display string,
|
||||
/// so the UNS TagModal can dispatch to a per-driver-type typed tag-config editor (F-uns-1) and feed
|
||||
/// the selected gateway's config to the Galaxy live-browse address picker.
|
||||
/// v3 Batch-1 migration: the per-equipment candidate-driver loader
|
||||
/// (<see cref="UnsTreeService.LoadTagDriversForEquipmentAsync"/>) is RETIRED. The equipment↔driver
|
||||
/// binding and the <c>Namespace</c>/<c>NamespaceKind</c> entities were deleted — equipment no longer
|
||||
/// scopes a candidate-driver list, and per-driver-typed tag authoring moved to the Raw tree (/raw) in
|
||||
/// Batch 2. The loader now always returns empty; the former "surfaces DriverType/DriverConfig" test
|
||||
/// (which fed the TagModal editor dispatch + Galaxy live-browse picker) is replaced by the stub
|
||||
/// assertion below. That editor-dispatch coverage is re-established under the Batch-2 Raw-tree tests.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceTagDriversTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A driver loaded for an equipment carries its <c>DriverType</c> and <c>DriverConfig</c> in the
|
||||
/// returned tuple.
|
||||
/// </summary>
|
||||
/// <summary>The retired loader returns empty even when a matching cluster driver exists.</summary>
|
||||
[Fact]
|
||||
public async Task LoadTagDriversForEquipment_surfaces_driver_type_and_config()
|
||||
public async Task LoadTagDriversForEquipment_returns_empty_stub()
|
||||
{
|
||||
var dbName = $"uns-tagdrivers-{Guid.NewGuid():N}";
|
||||
|
||||
@@ -45,18 +45,10 @@ public sealed class UnsTreeServiceTagDriversTests
|
||||
Name = "machine-1",
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-EQ",
|
||||
ClusterId = "MAIN",
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:zb:eq",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-EQ",
|
||||
ClusterId = "MAIN",
|
||||
NamespaceId = "NS-EQ",
|
||||
Name = "equipment driver",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = """{"endpoint":"10.0.0.1:502"}""",
|
||||
@@ -68,10 +60,6 @@ public sealed class UnsTreeServiceTagDriversTests
|
||||
|
||||
var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-1");
|
||||
|
||||
drivers.Count.ShouldBe(1);
|
||||
drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
|
||||
drivers[0].DriverType.ShouldBe("Modbus");
|
||||
// The picker (Galaxy live-browse) opens its session against the selected driver's config.
|
||||
drivers[0].DriverConfig.ShouldBe("""{"endpoint":"10.0.0.1:502"}""");
|
||||
drivers.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
@@ -8,39 +7,46 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the equipment-bound Tag CRUD mutations on <see cref="UnsTreeService"/>, including
|
||||
/// TagConfig JSON validity, the namespace-kind guard (tree tags must bind to an Equipment-kind
|
||||
/// namespace), the decision-#122 driver-cluster guard, duplicate-id / duplicate-name guards, and
|
||||
/// the driver-candidate loader scoped to the equipment's cluster.
|
||||
/// v3 Batch-1 migration of the former equipment-bound Tag CRUD tests. Per-equipment tag authoring
|
||||
/// was RETIRED: <see cref="Tag"/> is now Raw-only (no <c>EquipmentId</c>/<c>DriverInstanceId</c>/
|
||||
/// <c>FolderPath</c>), the <c>Namespace</c>/<c>NamespaceKind</c> entities were deleted, and tag
|
||||
/// authoring moved to the Raw tree (<c>/raw</c>) in Batch 2. So <see cref="UnsTreeService"/>'s
|
||||
/// <c>CreateTagAsync</c> / <c>UpdateTagAsync</c> now return a uniform Batch-1 refusal, and
|
||||
/// <c>LoadTagsForEquipmentAsync</c> / <c>LoadTagDriversForEquipmentAsync</c> return empty. The tests
|
||||
/// below assert that stub contract (preserving the intent of the old CRUD-guard / driver-loader
|
||||
/// tests) plus the still-live <c>DeleteTagAsync</c> path (which continues to remove a raw Tag row).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
|
||||
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
|
||||
/// The former TagConfig-JSON-validity, decision-#122 driver-cluster guard, Equipment-kind-namespace
|
||||
/// guard, and duplicate-id/name guards moved with the authoring surface to the Raw tree — re-covered
|
||||
/// under the Batch-2 Raw-tree service tests.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceTagTests
|
||||
{
|
||||
/// <summary>The Batch-1 refusal message the retired per-equipment tag authoring now returns.</summary>
|
||||
private const string TagAuthoringMovedError = "Tag authoring moved to the Raw tree (/raw) in v3 Batch 2.";
|
||||
|
||||
private static (UnsTreeService Service, string DbName) Fresh()
|
||||
{
|
||||
var dbName = $"uns-tag-{Guid.NewGuid():N}";
|
||||
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an area→line→equipment path in <paramref name="equipmentCluster"/>. The equipment id is
|
||||
/// always <c>EQ-1</c>. Optionally seeds an Equipment-kind driver (<c>DRV-EQ</c>) in the equipment's
|
||||
/// cluster, a non-Equipment-kind (Simulated) driver (<c>DRV-SP</c>) in the equipment's cluster, and
|
||||
/// an Equipment-kind driver (<c>DRV-OTHER</c>) in <paramref name="otherCluster"/>.
|
||||
/// </summary>
|
||||
private static void SeedHierarchyAndDrivers(
|
||||
string dbName,
|
||||
string equipmentCluster,
|
||||
bool seedEquipmentDriver = false,
|
||||
bool seedNonEquipmentDriver = false,
|
||||
string? otherCluster = null)
|
||||
/// <summary>Seeds an area→line→equipment path in <paramref name="cluster"/> under equipment <c>EQ-1</c>.</summary>
|
||||
private static void SeedHierarchy(string dbName, string cluster = "MAIN")
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = equipmentCluster, Name = "a" });
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = cluster,
|
||||
Name = cluster,
|
||||
Enterprise = "zb",
|
||||
Site = "s",
|
||||
RedundancyMode = RedundancyMode.None,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = cluster, Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
@@ -50,303 +56,77 @@ public sealed class UnsTreeServiceTagTests
|
||||
Name = "machine-1",
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
|
||||
// Equipment-kind namespace in the equipment's cluster.
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-EQ",
|
||||
ClusterId = equipmentCluster,
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:zb:eq",
|
||||
});
|
||||
// Non-Equipment-kind (Simulated) namespace in the equipment's cluster.
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-SP",
|
||||
ClusterId = equipmentCluster,
|
||||
Kind = NamespaceKind.Simulated,
|
||||
NamespaceUri = "urn:zb:sp",
|
||||
});
|
||||
|
||||
if (seedEquipmentDriver)
|
||||
{
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-EQ",
|
||||
ClusterId = equipmentCluster,
|
||||
NamespaceId = "NS-EQ",
|
||||
Name = "equipment driver",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
}
|
||||
|
||||
if (seedNonEquipmentDriver)
|
||||
{
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-SP",
|
||||
ClusterId = equipmentCluster,
|
||||
NamespaceId = "NS-SP",
|
||||
Name = "non-equipment driver",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
}
|
||||
|
||||
if (otherCluster is not null)
|
||||
{
|
||||
// Equipment-kind namespace + driver in a different cluster.
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-OTHER",
|
||||
ClusterId = otherCluster,
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:zb:other",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-OTHER",
|
||||
ClusterId = otherCluster,
|
||||
NamespaceId = "NS-OTHER",
|
||||
Name = "other-cluster driver",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
}
|
||||
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static TagInput Input(
|
||||
string tagId,
|
||||
string name,
|
||||
string driverInstanceId,
|
||||
string tagConfig = "{}") =>
|
||||
new(tagId, name, driverInstanceId, DataType: "Float",
|
||||
private static TagInput Input(string tagId, string name, string tagConfig = "{}") =>
|
||||
new(tagId, name, "DRV-EQ", DataType: "Float",
|
||||
AccessLevel: TagAccessLevel.Read, WriteIdempotent: false,
|
||||
PollGroupId: null, TagConfig: tagConfig);
|
||||
|
||||
// ----- CreateTag -----
|
||||
// ----- CreateTag / UpdateTag: retired → Batch-1 refusal -----
|
||||
|
||||
/// <summary>A valid equipment-bound tag persists with EquipmentId set and FolderPath null.</summary>
|
||||
/// <summary>Per-equipment tag creation is retired: the service returns the Batch-1 "moved to Raw tree" refusal.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_equipment_bound_persists()
|
||||
public async Task CreateTag_is_retired_returns_batch1_stub()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
SeedHierarchy(dbName);
|
||||
|
||||
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var tag = db.Tags.Single(t => t.TagId == "TAG-1");
|
||||
tag.EquipmentId.ShouldBe("EQ-1");
|
||||
tag.FolderPath.ShouldBeNull();
|
||||
tag.DriverInstanceId.ShouldBe("DRV-EQ");
|
||||
tag.Name.ShouldBe("speed");
|
||||
tag.DataType.ShouldBe("Float");
|
||||
}
|
||||
|
||||
/// <summary>A tag with invalid TagConfig JSON is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_invalid_json_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
|
||||
var result = await service.CreateTagAsync(
|
||||
"EQ-1", Input("TAG-1", "speed", "DRV-EQ", tagConfig: "{ not json"));
|
||||
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("TagConfig is not valid JSON.");
|
||||
result.Error.ShouldBe(TagAuthoringMovedError);
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A tag with a null TagConfig is blocked with the friendly "not valid JSON" message
|
||||
/// (rather than an unhandled ArgumentNullException) — AdminUI-002.</summary>
|
||||
/// <summary>The refusal is uniform — it does not depend on equipment resolvability or TagConfig validity.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_with_null_TagConfig_returns_friendly_error()
|
||||
public async Task CreateTag_refusal_is_uniform_even_for_unknown_equipment_or_bad_json()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
SeedHierarchy(dbName);
|
||||
|
||||
var result = await service.CreateTagAsync(
|
||||
"EQ-1", Input("TAG-1", "speed", "DRV-EQ", tagConfig: null!));
|
||||
(await service.CreateTagAsync("EQ-NOPE", Input("TAG-1", "speed"))).Error
|
||||
.ShouldBe(TagAuthoringMovedError);
|
||||
(await service.CreateTagAsync("EQ-1", Input("TAG-2", "speed", tagConfig: "{ not json"))).Error
|
||||
.ShouldBe(TagAuthoringMovedError);
|
||||
}
|
||||
|
||||
/// <summary>Per-equipment tag update is retired: the service returns the Batch-1 "moved to Raw tree" refusal.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateTag_is_retired_returns_batch1_stub()
|
||||
{
|
||||
var (service, _) = Fresh();
|
||||
|
||||
var result = await service.UpdateTagAsync("TAG-1", Input("TAG-1", "renamed"), []);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("TagConfig is not valid JSON.");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
|
||||
result.Error.ShouldBe(TagAuthoringMovedError);
|
||||
}
|
||||
|
||||
/// <summary>A tag with a blank/whitespace TagConfig is blocked with the friendly message — AdminUI-002.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_with_blank_TagConfig_returns_friendly_error()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
// ----- DeleteTag: still live against a raw Tag row -----
|
||||
|
||||
var result = await service.CreateTagAsync(
|
||||
"EQ-1", Input("TAG-1", "speed", "DRV-EQ", tagConfig: " "));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("TagConfig is not valid JSON.");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Binding a tag to a driver in a different cluster than the equipment is blocked (#122).</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_driver_in_other_cluster_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", otherCluster: "SITE-A");
|
||||
|
||||
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-OTHER"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("decision #122");
|
||||
result.Error.ShouldContain("DRV-OTHER");
|
||||
result.Error.ShouldContain("SITE-A");
|
||||
result.Error.ShouldContain("MAIN");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Binding a tree tag to a driver in a non-Equipment-kind namespace is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_driver_non_equipment_namespace_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedNonEquipmentDriver: true);
|
||||
|
||||
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-SP"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("DRV-SP");
|
||||
result.Error.ShouldContain("Equipment-kind namespace");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Creating a tag with a TagId that already exists is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_duplicate_tagid_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
|
||||
|
||||
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "another", "DRV-EQ"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Tag 'TAG-1' already exists.");
|
||||
}
|
||||
|
||||
/// <summary>Creating a tag for an equipment id that does not exist returns a not-found error.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_unresolvable_equipment_returns_error()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
|
||||
var result = await service.CreateTagAsync("EQ-NOPE", Input("TAG-1", "speed", "DRV-EQ"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("not found");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Creating a tag whose Name already exists on the same equipment is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateTag_duplicate_name_on_equipment_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
|
||||
|
||||
var result = await service.CreateTagAsync("EQ-1", Input("TAG-2", "speed", "DRV-EQ"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("A tag named 'speed' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// ----- UpdateTag -----
|
||||
|
||||
/// <summary>Updating a tag changes its mutable fields and keeps EquipmentId / FolderPath.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateTag_changes_fields()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
rv = db.Tags.Single(t => t.TagId == "TAG-1").RowVersion;
|
||||
}
|
||||
|
||||
var updated = new TagInput("TAG-1", "renamed", "DRV-EQ", DataType: "Int32",
|
||||
AccessLevel: TagAccessLevel.ReadWrite, WriteIdempotent: true,
|
||||
PollGroupId: " ", TagConfig: """{ "register": 40001 }""");
|
||||
|
||||
var result = await service.UpdateTagAsync("TAG-1", updated, rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var after = verify.Tags.Single(t => t.TagId == "TAG-1");
|
||||
after.Name.ShouldBe("renamed");
|
||||
after.DataType.ShouldBe("Int32");
|
||||
after.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
|
||||
after.WriteIdempotent.ShouldBeTrue();
|
||||
after.PollGroupId.ShouldBeNull(); // whitespace collapses to null
|
||||
after.EquipmentId.ShouldBe("EQ-1");
|
||||
after.FolderPath.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Updating a tag that no longer exists returns the row-gone error.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateTag_missing_row_returns_error()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
|
||||
var result = await service.UpdateTagAsync("TAG-nope", Input("TAG-nope", "x", "DRV-EQ"), []);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Row no longer exists.");
|
||||
}
|
||||
|
||||
// ----- DeleteTag -----
|
||||
|
||||
/// <summary>Deleting a tag removes the row.</summary>
|
||||
/// <summary>Deleting a raw tag removes the row (DeleteTagAsync remains functional in Batch 1).</summary>
|
||||
[Fact]
|
||||
public async Task DeleteTag_removes_row()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
|
||||
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Devices.Add(new Device
|
||||
{
|
||||
DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "d", DeviceConfig = "{}",
|
||||
});
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-1", DeviceId = "DEV-1", Name = "speed", DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
rv = db.Tags.Single(t => t.TagId == "TAG-1").RowVersion;
|
||||
}
|
||||
|
||||
@@ -371,40 +151,25 @@ public sealed class UnsTreeServiceTagTests
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- LoadTagDriversForEquipmentAsync -----
|
||||
// ----- Per-equipment loaders: retired → empty -----
|
||||
|
||||
/// <summary>
|
||||
/// The driver loader returns only Equipment-kind drivers in the equipment's cluster — excluding
|
||||
/// non-Equipment-kind drivers in the same cluster and Equipment-kind drivers in other clusters.
|
||||
/// </summary>
|
||||
/// <summary>The per-equipment tag list is retired (Tag is Raw-only) → always empty.</summary>
|
||||
[Fact]
|
||||
public async Task LoadTagDriversForEquipment_returns_only_equipment_kind_drivers_in_cluster()
|
||||
public async Task LoadTagsForEquipment_returns_empty()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(
|
||||
dbName,
|
||||
equipmentCluster: "MAIN",
|
||||
seedEquipmentDriver: true,
|
||||
seedNonEquipmentDriver: true,
|
||||
otherCluster: "SITE-A");
|
||||
SeedHierarchy(dbName);
|
||||
|
||||
var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-1");
|
||||
|
||||
drivers.Count.ShouldBe(1);
|
||||
drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
|
||||
drivers[0].Display.ShouldContain("DRV-EQ");
|
||||
drivers[0].Display.ShouldContain("equipment driver");
|
||||
(await service.LoadTagsForEquipmentAsync("EQ-1")).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An unresolvable equipment yields an empty driver list.</summary>
|
||||
/// <summary>The per-equipment candidate-driver loader is retired (equipment↔driver binding gone) → always empty.</summary>
|
||||
[Fact]
|
||||
public async Task LoadTagDriversForEquipment_unresolvable_equipment_returns_empty()
|
||||
public async Task LoadTagDriversForEquipment_returns_empty()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
|
||||
SeedHierarchy(dbName);
|
||||
|
||||
var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-nope");
|
||||
|
||||
drivers.ShouldBeEmpty();
|
||||
(await service.LoadTagDriversForEquipmentAsync("EQ-1")).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
/// Shared in-memory fixture for <c>UnsTreeService</c> structural tests. Builds an
|
||||
/// <see cref="OtOpcUaConfigDbContext"/> over a named InMemory database and seeds a small,
|
||||
/// deterministic UNS hierarchy: enterprise "zb" across two clusters (MAIN populated,
|
||||
/// SITE-A intentionally empty), plus tags and a virtual tag on one equipment node so
|
||||
/// the per-equipment count joins can be exercised.
|
||||
/// SITE-A intentionally empty), a virtual tag on one equipment node (so the per-equipment
|
||||
/// virtual-tag count join can be exercised), plus a v3 Raw-tree slice
|
||||
/// (Driver→Device→Tag). Under v3, tags are Raw-only — they no longer bind to equipment — so
|
||||
/// the per-equipment driver-tag count is always zero; only the virtual-tag count join remains.
|
||||
/// </summary>
|
||||
internal static class UnsTreeTestDb
|
||||
{
|
||||
@@ -84,12 +86,22 @@ internal static class UnsTreeTestDb
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
|
||||
// Two driver tags + one virtual tag on the seeded equipment → ChildCount 3.
|
||||
// v3 Raw tree: a device → two raw tags. Tags are Raw-only and no longer bind to equipment,
|
||||
// so they do NOT contribute to any per-equipment count. The owning DriverInstance is left
|
||||
// unseeded on purpose — no structural test reads it, and seeding one would pollute the
|
||||
// per-cluster driver-list assertions in UnsTreeServiceLoadEditTests (EF InMemory does not
|
||||
// enforce the Device→Driver FK).
|
||||
db.Devices.Add(new Device
|
||||
{
|
||||
DeviceId = "DEV-1",
|
||||
DriverInstanceId = "DRV-1",
|
||||
Name = "device-1",
|
||||
DeviceConfig = "{}",
|
||||
});
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-1",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = SeededEquipmentId,
|
||||
DeviceId = "DEV-1",
|
||||
Name = "speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
@@ -98,24 +110,14 @@ internal static class UnsTreeTestDb
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-2",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = SeededEquipmentId,
|
||||
DeviceId = "DEV-1",
|
||||
Name = "running",
|
||||
DataType = "Boolean",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
// A tag with no equipment must be ignored by the count query.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-ORPHAN",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = null,
|
||||
Name = "orphan",
|
||||
DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
|
||||
// One virtual tag on the seeded equipment → per-equipment virtual-tag ChildCount 1.
|
||||
db.VirtualTags.Add(new VirtualTag
|
||||
{
|
||||
VirtualTagId = "VTAG-1",
|
||||
|
||||
+14
-7
@@ -24,6 +24,14 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
private const string EquipBaseScript = "return ctx.GetTag(\"{{equip}}.X\");";
|
||||
private const string PlainScript = "return ctx.GetTag(\"TestMachine_001.X\");";
|
||||
|
||||
// v3 Batch-1: the equipment↔Tag binding was retired (Tag is Raw-only), so the {{equip}} token's
|
||||
// equipment-tag-derived base can never resolve — ValidateEquipTokenAsync now derives from an empty
|
||||
// tag set, so any {{equip}} script is rejected. The token-present/no-base rejection and the
|
||||
// no-token success cases stay live below; the tests that assert a DERIVABLE base (success) or the
|
||||
// divergent-prefix derivation logic are dark until per-equipment tag references return in Batch 3.
|
||||
private const string DarkUntilBatch3 =
|
||||
"v3 Batch-1: {{equip}} equipment-tag-derived base is dark — per-equipment tag references return in Batch 3.";
|
||||
|
||||
private static (UnsTreeService Service, string DbName) Fresh()
|
||||
{
|
||||
var dbName = $"uns-equiptoken-{Guid.NewGuid():N}";
|
||||
@@ -58,11 +66,11 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
});
|
||||
if (tagFullName is not null)
|
||||
{
|
||||
// v3: raw tag (no equipment binding). Kept so the dark {{equip}}-base tests still compile.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-1",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = "EQ-1",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "x",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
@@ -95,7 +103,7 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
// ----- Create -----
|
||||
|
||||
/// <summary>{{equip}} script + a driver tag whose FullName gives a base → create succeeds.</summary>
|
||||
[Fact]
|
||||
[Fact(Skip = DarkUntilBatch3)]
|
||||
public async Task Create_equip_token_with_derivable_base_succeeds()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
@@ -142,7 +150,7 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
/// {{equip}} script + TWO driver tags whose FullNames have DIFFERENT object prefixes
|
||||
/// (no single base can be derived) → create rejected, error names equipment + token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Fact(Skip = DarkUntilBatch3)]
|
||||
public async Task Create_equip_token_with_divergent_prefixes_rejected()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
@@ -152,8 +160,7 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-2",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = "EQ-1",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "y",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
@@ -176,7 +183,7 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
// ----- Update -----
|
||||
|
||||
/// <summary>{{equip}} script + a derivable base → update succeeds.</summary>
|
||||
[Fact]
|
||||
[Fact(Skip = DarkUntilBatch3)]
|
||||
public async Task Update_equip_token_with_derivable_base_succeeds()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
|
||||
Reference in New Issue
Block a user