diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor index f185419..550b801 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor @@ -12,6 +12,98 @@ +@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@ +
+
Search equipment
+
+
+
+ + +
+
+
+ + +
+
+
+ + @if (_searchHits is not null) + { + + } +
+
+ @if (_searchError is not null) + { +

@_searchError

+ } +
+ + @if (_searchHits is not null) + { + @if (_searchHits.Count == 0) + { +

No matches.

+ } + else + { +
+ + + + + + + + + @foreach (var hit in _searchHits) + { + + + + + + + + + + } + +
EquipmentIdNameMachineCodeZTagSAPIDMatchedGen
@hit.Equipment.EquipmentId@hit.Equipment.Name@hit.Equipment.MachineCode@hit.Equipment.ZTag@hit.Equipment.SAPID + @if (hit.MatchedField is not null) + { + var chipClass = hit.Score switch + { + 100 => "chip chip-ok", + 50 => "chip chip-warn", + _ => "chip chip-idle", + }; + @hit.MatchedField + } + + @if (hit.IsPublished) + { pub } + else + { draft } +
+
+

+ @_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s"). + Exact = green, prefix = amber, fuzzy = grey. + Fuzzy matching requires the "Fuzzy" checkbox. +

+ } + } +
+ @if (_equipment is null) {

Loading…

@@ -114,6 +206,41 @@ else if (_equipment.Count > 0) private Equipment _draft = NewBlankDraft(); private string? _error; + // ── Five-identifier search ────────────────────────────────────────── + private string _searchQuery = string.Empty; + private bool _searchFuzzy; + private IReadOnlyList? _searchHits; + private bool _searchBusy; + private string? _searchError; + + private async Task RunSearchAsync() + { + _searchError = null; + if (string.IsNullOrWhiteSpace(_searchQuery)) { _searchHits = null; return; } + _searchBusy = true; + try + { + _searchHits = await EquipmentSvc.SearchAsync( + _searchQuery, ClusterId, CancellationToken.None, + maxResults: 50, allowFuzzy: _searchFuzzy); + } + catch (Exception ex) { _searchError = ex.Message; } + finally { _searchBusy = false; } + } + + private void ClearSearch() + { + _searchQuery = string.Empty; + _searchHits = null; + _searchError = null; + } + + private async Task OnSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") await RunSearchAsync(); + } + // ─────────────────────────────────────────────────────────────────── + private static Equipment NewBlankDraft() => new() { EquipmentId = string.Empty, DriverInstanceId = string.Empty, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs index ee93822..2d52e18 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Admin.Services; @@ -18,6 +19,118 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db) .OrderBy(e => e.Name) .ToListAsync(ct); + /// + /// Five-identifier ranked search across a cluster (all draft + published generations). + /// Identifiers: ZTag, MachineCode, SAPID, EquipmentId, EquipmentUuid (decision #117). + /// Scoring: exact match on any identifier = 100, prefix match = 50, fuzzy (opt-in) = 20. + /// Tie-break: Published generation outranks Draft; within same status by Name ascending. + /// Returns at most rows. + /// + /// Search term (trimmed; empty returns empty results, not all rows). + /// Cluster to scope the search to. + /// Cap to prevent full-table dumps (default 50). + /// When true, LIKE-prefix suffix matches score 20 (opt-in per spec). + /// Cancellation token. + public async Task> SearchAsync( + string query, + string clusterId, + CancellationToken ct, + int maxResults = 50, + bool allowFuzzy = false) + { + ArgumentNullException.ThrowIfNull(clusterId); + query = query?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(query)) + return []; + + // Load candidates from DB — we filter generation to this cluster via the Join. + // The scoring is pure-LINQ post-load because EF InMemory doesn't support CASE WHEN scoring + // and the SQL-provider translation for this small set is acceptable (bounded by cluster). + var candidates = await db.Equipment.AsNoTracking() + .Join(db.ConfigGenerations.AsNoTracking(), + e => e.GenerationId, + g => g.GenerationId, + (e, g) => new { Equipment = e, Generation = g }) + .Where(x => x.Generation.ClusterId == clusterId) + .Select(x => new + { + x.Equipment, + IsPublished = x.Generation.Status == GenerationStatus.Published, + }) + .ToListAsync(ct) + .ConfigureAwait(false); + + var lower = query.ToLowerInvariant(); + + var scored = candidates + .Select(c => + { + var (score, matchedField) = ScoreEquipment(c.Equipment, lower, allowFuzzy); + return new + { + c.Equipment, + c.IsPublished, + Score = score, + MatchedField = matchedField, + }; + }) + .Where(x => x.Score > 0) + // Tie-break: highest score → published before draft → name + .OrderByDescending(x => x.Score) + .ThenByDescending(x => x.IsPublished ? 1 : 0) + .ThenBy(x => x.Equipment.Name) + .Take(maxResults) + .Select(x => new EquipmentSearchHit(x.Equipment, x.Score, x.MatchedField, x.IsPublished)) + .ToList(); + + return scored; + } + + /// Score one equipment row against the search term. Returns (score, matchedFieldName). + private static (int Score, string? MatchedField) ScoreEquipment(Equipment e, string lower, bool allowFuzzy) + { + // Evaluate each identifier in priority order — first exact match wins with score 100. + var identifiers = new (string FieldName, string? Value)[] + { + ("ZTag", e.ZTag), + ("MachineCode", e.MachineCode), + ("SAPID", e.SAPID), + ("EquipmentId", e.EquipmentId), + ("EquipmentUuid", e.EquipmentUuid == Guid.Empty ? null : e.EquipmentUuid.ToString()), + }; + + foreach (var (fieldName, value) in identifiers) + { + if (string.IsNullOrEmpty(value)) continue; + var v = value.ToLowerInvariant(); + if (v == lower) + return (100, fieldName); + } + + // Prefix match — score 50 + foreach (var (fieldName, value) in identifiers) + { + if (string.IsNullOrEmpty(value)) continue; + var v = value.ToLowerInvariant(); + if (v.StartsWith(lower, StringComparison.Ordinal)) + return (50, fieldName); + } + + // Fuzzy (substring) match — score 20, opt-in only + if (allowFuzzy) + { + foreach (var (fieldName, value) in identifiers) + { + if (string.IsNullOrEmpty(value)) continue; + var v = value.ToLowerInvariant(); + if (v.Contains(lower, StringComparison.Ordinal)) + return (20, fieldName); + } + } + + return (0, null); + } + public Task FindAsync(long generationId, string equipmentId, CancellationToken ct) => db.Equipment.AsNoTracking() .FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct); @@ -73,3 +186,14 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db) await db.SaveChangesAsync(ct); } } + +/// One hit from . +/// The matched equipment row. +/// Match score: 100 = exact, 50 = prefix, 20 = fuzzy. +/// Which identifier field produced the highest score. +/// True when the row is from a published generation (aids tie-break display). +public sealed record EquipmentSearchHit( + Equipment Equipment, + int Score, + string? MatchedField, + bool IsPublished); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentSearchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentSearchTests.cs new file mode 100644 index 0000000..c20be68 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentSearchTests.cs @@ -0,0 +1,280 @@ +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; + +/// +/// Unit tests for the Phase 6.4 Stream B.5 five-identifier ranked search. +/// Decision #117 identifiers: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid. +/// Scoring: exact match = 100, prefix = 50, fuzzy (opt-in) = 20. +/// Tie-break: published generation outranks draft. +/// +[Trait("Category", "Unit")] +public sealed class EquipmentSearchTests : IDisposable +{ + private readonly OtOpcUaConfigDbContext _db; + private readonly EquipmentService _svc; + + private const string ClusterId = "cluster-1"; + private const long DraftGenId = 1L; + private const long PublishedGenId = 2L; + + public EquipmentSearchTests() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"eq-search-{Guid.NewGuid():N}") + .Options; + _db = new OtOpcUaConfigDbContext(opts); + + // Seed two generations — draft + published — for the same cluster. + _db.ConfigGenerations.AddRange( + new ConfigGeneration + { + GenerationId = DraftGenId, + ClusterId = ClusterId, + Status = GenerationStatus.Draft, + CreatedBy = "test", + }, + new ConfigGeneration + { + GenerationId = PublishedGenId, + ClusterId = ClusterId, + Status = GenerationStatus.Published, + CreatedBy = "test", + PublishedAt = DateTime.UtcNow, + PublishedBy = "test", + }); + _db.SaveChanges(); + + _svc = new EquipmentService(_db); + } + + public void Dispose() => _db.Dispose(); + + // ── Helpers ────────────────────────────────────────────────────────── + + private Equipment AddEquipment( + long generationId, + string name, + string ztag, + string machineCode = "MC", + string sapid = "", + Guid? uuid = null, + string equipmentId = "") + { + var uu = uuid ?? Guid.NewGuid(); + var eq = new Equipment + { + EquipmentRowId = Guid.NewGuid(), + GenerationId = generationId, + EquipmentId = string.IsNullOrEmpty(equipmentId) ? $"EQ-{uu:N}"[..14] : equipmentId, + EquipmentUuid = uu, + DriverInstanceId = "drv", + UnsLineId = "line-1", + Name = name, + MachineCode = machineCode, + ZTag = ztag, + SAPID = string.IsNullOrEmpty(sapid) ? null : sapid, + }; + _db.Equipment.Add(eq); + _db.SaveChanges(); + return eq; + } + + // ── Exact-match tests (score 100) ──────────────────────────────────── + + [Fact] + public async Task ExactMatch_ZTag_Returns_Score100() + { + AddEquipment(DraftGenId, "Oven-A", ztag: "z-001"); + + var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(1); + hits[0].Score.ShouldBe(100); + hits[0].MatchedField.ShouldBe("ZTag"); + hits[0].Equipment.Name.ShouldBe("Oven-A"); + } + + [Fact] + public async Task ExactMatch_IsCaseInsensitive() + { + AddEquipment(DraftGenId, "Welder-1", ztag: "Z-ABC"); + + var hits = await _svc.SearchAsync("z-abc", ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(1); + hits[0].Score.ShouldBe(100); + } + + [Fact] + public async Task ExactMatch_MachineCode_Returns_Score100() + { + AddEquipment(DraftGenId, "Wrapper", ztag: "z-2", machineCode: "MC-42"); + + var hits = await _svc.SearchAsync("MC-42", ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(1); + hits[0].Score.ShouldBe(100); + hits[0].MatchedField.ShouldBe("MachineCode"); + } + + [Fact] + public async Task ExactMatch_SAPID_Returns_Score100() + { + AddEquipment(DraftGenId, "Conveyor", ztag: "z-3", sapid: "SAP-999"); + + var hits = await _svc.SearchAsync("SAP-999", ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(1); + hits[0].Score.ShouldBe(100); + hits[0].MatchedField.ShouldBe("SAPID"); + } + + [Fact] + public async Task ExactMatch_EquipmentUuid_Returns_Score100() + { + var uu = Guid.NewGuid(); + AddEquipment(DraftGenId, "Robot-A", ztag: "z-4", uuid: uu); + + var hits = await _svc.SearchAsync(uu.ToString(), ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(1); + hits[0].Score.ShouldBe(100); + hits[0].MatchedField.ShouldBe("EquipmentUuid"); + } + + // ── Prefix-match tests (score 50) ──────────────────────────────────── + + [Fact] + public async Task PrefixMatch_ZTag_Returns_Score50() + { + AddEquipment(DraftGenId, "Press-1", ztag: "z-alpha-001"); + + var hits = await _svc.SearchAsync("z-alpha", ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(1); + hits[0].Score.ShouldBe(50); + hits[0].MatchedField.ShouldBe("ZTag"); + } + + [Fact] + public async Task ExactOutranks_Prefix_InResults() + { + // exact: z-001 == "z-001" → score 100 + // prefix: z-001x startsWith "z-001" → score 50 + AddEquipment(DraftGenId, "Exact-Hit", ztag: "z-001"); + AddEquipment(DraftGenId, "Prefix-Hit", ztag: "z-001x"); + + var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(2); + hits[0].Score.ShouldBe(100); + hits[0].Equipment.Name.ShouldBe("Exact-Hit"); + hits[1].Score.ShouldBe(50); + hits[1].Equipment.Name.ShouldBe("Prefix-Hit"); + } + + // ── Fuzzy-match tests (score 20, opt-in) ───────────────────────────── + + [Fact] + public async Task FuzzyMatch_Disabled_DoesNotReturn_SubstringOnly_Hit() + { + AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-INFIX-suffix"); + + var hits = await _svc.SearchAsync("INFIX", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: false); + + hits.ShouldBeEmpty(); + } + + [Fact] + public async Task FuzzyMatch_Enabled_Returns_Score20() + { + AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-infix-suffix"); + + var hits = await _svc.SearchAsync("infix", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: true); + + hits.Count.ShouldBe(1); + hits[0].Score.ShouldBe(20); + hits[0].MatchedField.ShouldBe("ZTag"); + } + + // ── Tie-break: published outranks draft ─────────────────────────────── + + [Fact] + public async Task PublishedGeneration_Outranks_Draft_ForEqualScore() + { + // Same ZTag prefix "mc-" in both draft + published generation. + AddEquipment(DraftGenId, "Draft-Eq", ztag: "mc-001"); + AddEquipment(PublishedGenId, "Published-Eq", ztag: "mc-002"); + + // Both hit prefix match on "mc-" (score 50). + var hits = await _svc.SearchAsync("mc-", ClusterId, TestContext.Current.CancellationToken); + + hits.Count.ShouldBe(2); + // Published generation should come first. + hits[0].IsPublished.ShouldBeTrue(); + hits[1].IsPublished.ShouldBeFalse(); + } + + // ── Empty / no-match ───────────────────────────────────────────────── + + [Fact] + public async Task EmptyQuery_Returns_EmptyList() + { + AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999"); + + var hits = await _svc.SearchAsync(" ", ClusterId, TestContext.Current.CancellationToken); + + hits.ShouldBeEmpty(); + } + + [Fact] + public async Task NoMatch_Returns_EmptyList() + { + AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999"); + + var hits = await _svc.SearchAsync("xyzzy-unknown", ClusterId, TestContext.Current.CancellationToken); + + hits.ShouldBeEmpty(); + } + + // ── Cross-cluster isolation ─────────────────────────────────────────── + + [Fact] + public async Task Equipment_In_DifferentCluster_NotReturned() + { + // Seed a generation in a different cluster. + _db.ConfigGenerations.Add(new ConfigGeneration + { + GenerationId = 99L, + ClusterId = "cluster-other", + Status = GenerationStatus.Draft, + CreatedBy = "test", + }); + _db.SaveChanges(); + AddEquipment(99L, "OtherEq", ztag: "z-001"); + + var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken); + + hits.ShouldBeEmpty("equipment from another cluster must be invisible"); + } + + // ── MaxResults cap ──────────────────────────────────────────────────── + + [Fact] + public async Task MaxResults_Limits_Output() + { + for (var i = 0; i < 10; i++) + AddEquipment(DraftGenId, $"Eq-{i}", ztag: $"zprefix-{i:D3}"); + + var hits = await _svc.SearchAsync("zprefix-", ClusterId, TestContext.Current.CancellationToken, maxResults: 3); + + hits.Count.ShouldBe(3); + } +}