feat(adminui): IScriptTagCatalog for tag-path completion
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
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.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="ScriptTagCatalog"/> projects ONLY the resolvable keys a virtual-tag script can
|
||||
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
|
||||
/// </summary>
|
||||
/// <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 SystemPlatform
|
||||
/// tags, and the leaf Name for virtual tags, and deliberately omits UNS browse paths. These
|
||||
/// assertions check the resolvable key is present AND that the UNS browse paths are absent.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptTagCatalogTests
|
||||
{
|
||||
private sealed class TestFactory(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||
: IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => new(opts);
|
||||
|
||||
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new OtOpcUaConfigDbContext(opts));
|
||||
}
|
||||
|
||||
private static (ScriptTagCatalog Catalog, DbContextOptions<OtOpcUaConfigDbContext> Opts) Fresh()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"scripttagcatalog-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
return (new ScriptTagCatalog(new TestFactory(opts)), opts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"),
|
||||
/// one virtual tag, and one SystemPlatform tag (EquipmentId null, FolderPath set).
|
||||
/// </summary>
|
||||
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||
{
|
||||
using var db = new OtOpcUaConfigDbContext(opts);
|
||||
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "Assembly" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "LineA" });
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = "EQ-1",
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-1",
|
||||
Name = "Machine1",
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
|
||||
// Equipment driver tag — the GetTag key is the driver FullName from TagConfig.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-EQ",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = "EQ-1",
|
||||
Name = "Speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"Motor.Speed\"}",
|
||||
});
|
||||
|
||||
// SystemPlatform tag — EquipmentId null, FolderPath set; Galaxy subscribes by "FolderPath.Name".
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-SP",
|
||||
DriverInstanceId = "DRV-GALAXY",
|
||||
EquipmentId = null,
|
||||
Name = "DownloadPath",
|
||||
FolderPath = "DelmiaReceiver_001",
|
||||
DataType = "String",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
|
||||
});
|
||||
|
||||
db.VirtualTags.Add(new VirtualTag
|
||||
{
|
||||
VirtualTagId = "VTAG-1",
|
||||
EquipmentId = "EQ-1",
|
||||
Name = "Computed",
|
||||
DataType = "Double",
|
||||
ScriptId = "SCRIPT-1",
|
||||
});
|
||||
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>A null filter returns only the resolvable keys: the driver FullName for the equipment
|
||||
/// tag, the MXAccess dot-ref for the SystemPlatform tag, and the virtual tag's leaf Name — and
|
||||
/// NONE of the UNS browse paths.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_no_filter_returns_resolvable_keys_only()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var paths = await catalog.GetPathsAsync(null, default);
|
||||
|
||||
// Equipment driver tag: the authoritative GetTag key (driver FullName).
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
|
||||
// SystemPlatform tag: MXAccess dot-ref.
|
||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
||||
|
||||
// Virtual tag: leaf Name only.
|
||||
paths.ShouldContain("Computed");
|
||||
|
||||
// The UNS browse paths are intentionally NOT suggested (the UNS-path engine is dormant).
|
||||
paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
|
||||
paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath");
|
||||
paths.ShouldNotContain("Assembly/LineA/Machine1/Computed");
|
||||
}
|
||||
|
||||
/// <summary>The result is distinct.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_results_are_distinct()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var paths = await catalog.GetPathsAsync(null, default);
|
||||
|
||||
paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
|
||||
}
|
||||
|
||||
/// <summary>A literal prefix narrows the result (case-insensitive StartsWith) to matching keys.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_prefix_filter_narrows()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var paths = await catalog.GetPathsAsync("Motor", default);
|
||||
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath");
|
||||
paths.ShouldNotContain("Computed");
|
||||
paths.ShouldAllBe(p => p.StartsWith("Motor", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>The prefix match is case-insensitive.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_prefix_filter_is_case_insensitive()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var paths = await catalog.GetPathsAsync("motor", default);
|
||||
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
}
|
||||
|
||||
/// <summary>A tag whose <c>TagConfig</c> is malformed JSON must not throw — the catalog falls back
|
||||
/// to the raw blob and still returns every other tag.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_malformed_tagconfig_does_not_throw()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-BAD",
|
||||
DriverInstanceId = "DRV-1",
|
||||
EquipmentId = "EQ-1",
|
||||
Name = "Broken",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{ this is not valid json",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
IReadOnlyList<string> paths = [];
|
||||
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
|
||||
|
||||
// The malformed tag falls through to the raw blob (acceptable), and the other tags still appear.
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
||||
paths.ShouldContain("Computed");
|
||||
}
|
||||
|
||||
/// <summary>A SystemPlatform tag with a null/empty <c>FolderPath</c> yields just its <c>Name</c>
|
||||
/// (no leading dot).</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_systemplatform_tag_without_folder_yields_name_only()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
|
||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-SP-ROOT",
|
||||
DriverInstanceId = "DRV-GALAXY",
|
||||
EquipmentId = null,
|
||||
Name = "RootScalar",
|
||||
FolderPath = null,
|
||||
DataType = "String",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"RootScalar\"}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var paths = await catalog.GetPathsAsync(null, default);
|
||||
|
||||
paths.ShouldContain("RootScalar");
|
||||
paths.ShouldNotContain(".RootScalar");
|
||||
}
|
||||
|
||||
/// <summary>An empty database yields an empty list rather than throwing.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_empty_db_returns_empty()
|
||||
{
|
||||
var (catalog, _) = Fresh();
|
||||
|
||||
var paths = await catalog.GetPathsAsync(null, default);
|
||||
|
||||
paths.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user