Implements the missing Stream B.5 search from the Phase 6.4 plan: - EquipmentService.SearchAsync scopes to a cluster, scores hits across ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid (decision #117): exact = 100, prefix = 50, fuzzy (opt-in) = 20; published generation outranks draft on equal scores per spec. - EquipmentSearchHit record carries Score + MatchedField for badge display. - EquipmentTab.razor gains a search panel with per-row matched-field chips (green exact, amber prefix, grey fuzzy) and fuzzy opt-in checkbox. - 14 new unit tests in EquipmentSearchTests.cs (Category=Unit) cover exact, prefix, fuzzy, case-insensitivity, tie-break, cross-cluster isolation, and maxResults cap; all 148 admin unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
281 lines
9.7 KiB
C#
281 lines
9.7 KiB
C#
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>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<OtOpcUaConfigDbContext>()
|
|
.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);
|
|
}
|
|
}
|