Task #155 — TagService + TagsTab CRUD UI for Modbus tags

Closes the remaining loop on user-visible Modbus tag editing. Pre-#155 tags
arrived only via SQL seeding or runtime ITagDiscovery; the Admin UI had no
interactive surface for creating / editing / deleting tag rows.

Changes:

- TagService.cs (Admin/Services/) — CRUD wrapper around OtOpcUaConfigDbContext.Tags.
  ListAsync supports optional driver / equipment filters; CreateAsync auto-derives
  TagId; UpdateAsync persists editable fields; DeleteAsync removes the row. Mirrors
  the EquipmentService shape.
- TagsTab.razor (Components/Pages/Clusters/) — list + filter + add/edit/remove form.
  The address/config editor is conditional: when the selected DriverInstance is
  Modbus, ModbusAddressEditor (#145) renders with live-parse preview; otherwise a
  generic JSON textarea (matches the DriversTab pattern from #147). Save-side
  serializes the address-string into TagConfig as `{"addressString":"..."}` JSON.
- ClusterDetail.razor — new "Tags" tab in the cluster-detail nav strip + the routing
  switch.
- Program.cs — TagService registered as a scoped DI service.

Drive-by fix: ModbusDriverFactoryExtensions.CreateInstance promoted from internal
to public — Admin.Tests was using it via reflection-friendly internal access that
broke under the #153 logger overload addition. Public is the right access modifier
anyway since the Server-side bootstrapper calls it from a different assembly.

Drive-by fix #2: ModbusDriverConfigDto was missing MaxReadGap (#143) — surfaced by
the #147 round-trip test that flips MaxReadGap=12 in the view model and asserts
it lands on the resolved options. Added the field + binding line. Confirms #143's
DriverConfig JSON binding was incomplete since the original commit; no production
deployment configured this knob through JSON until now so the gap stayed hidden.

Tests (4 new TagServiceTests):
- Create_And_List_Surfaces_The_Tag — CreateAsync auto-assigns TagId; list returns
  the row.
- List_Filters_By_DriverInstance — driver-scoped filter works.
- Update_Persists_Editable_Fields — Name / DataType / AccessLevel / TagConfig all
  persist through Update.
- Delete_Removes_The_Row — basic delete verification.

113 + 4 (TagService) + 2 (DriversTab round-trip restored after compile fix) = 119
Admin tests green. Solution build clean.

Caveat: bUnit-style render tests for TagsTab still aren't included — Admin.Tests
doesn't have bUnit set up. The TagService logic is fully covered; the razor
component's parser/save glue is exercised by hand at runtime for now.
This commit is contained in:
Joseph Doherty
2026-04-25 01:51:02 -04:00
parent 802366c2c6
commit ec57df1009
6 changed files with 450 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// #155 — TagService CRUD round-trip coverage. Mirrors the EquipmentService test shape;
/// uses EF Core InMemory so no SQL Server is required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagServiceTests
{
[Fact]
public async Task Create_And_List_Surfaces_The_Tag()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var created = await svc.CreateAsync(draftId: 1, NewTag("Temp"), TestContext.Current.CancellationToken);
created.TagId.ShouldNotBeNullOrEmpty();
created.GenerationId.ShouldBe(1);
var list = await svc.ListAsync(1, ct: TestContext.Current.CancellationToken);
list.Count.ShouldBe(1);
list[0].Name.ShouldBe("Temp");
}
[Fact]
public async Task List_Filters_By_DriverInstance()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
await svc.CreateAsync(1, NewTag("a", driver: "drv-1"), TestContext.Current.CancellationToken);
await svc.CreateAsync(1, NewTag("b", driver: "drv-2"), TestContext.Current.CancellationToken);
await svc.CreateAsync(1, NewTag("c", driver: "drv-1"), TestContext.Current.CancellationToken);
var d1 = await svc.ListAsync(1, driverInstanceId: "drv-1", ct: TestContext.Current.CancellationToken);
d1.Count.ShouldBe(2);
d1.Select(t => t.Name).ShouldBe(new[] { "a", "c" }, ignoreOrder: true);
}
[Fact]
public async Task Update_Persists_Editable_Fields()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var t = await svc.CreateAsync(1, NewTag("Original"), TestContext.Current.CancellationToken);
t.Name = "Renamed";
t.DataType = "Float";
t.AccessLevel = TagAccessLevel.ReadWrite;
t.TagConfig = "{\"addressString\":\"40001:F\"}";
await svc.UpdateAsync(t, TestContext.Current.CancellationToken);
var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken))[0];
fresh.Name.ShouldBe("Renamed");
fresh.DataType.ShouldBe("Float");
fresh.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
fresh.TagConfig.ShouldContain("40001:F");
}
[Fact]
public async Task Delete_Removes_The_Row()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var t = await svc.CreateAsync(1, NewTag("Doomed"), TestContext.Current.CancellationToken);
await svc.DeleteAsync(t.TagRowId, TestContext.Current.CancellationToken);
(await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
private static Tag NewTag(string name, string driver = "drv-1") => new()
{
TagId = string.Empty, // CreateAsync auto-assigns
DriverInstanceId = driver,
Name = name,
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}