Merge B1-wp6b-ui

This commit is contained in:
Joseph Doherty
2026-07-15 21:51:21 -04:00
22 changed files with 266 additions and 1029 deletions
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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.
}
@@ -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,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,
@@ -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();
}
}
@@ -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 -----
@@ -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 -----
@@ -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",
@@ -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();