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); } }