feat(admin): add five-identifier ranked equipment search (Phase 6.4 Stream B.5)
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>
This commit is contained in:
@@ -12,6 +12,98 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@
|
||||
<section class="panel rise mb-3" style="animation-delay:.02s">
|
||||
<div class="panel-head">Search equipment</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small mb-1">
|
||||
Search by ZTag, MachineCode, SAPID, EquipmentId, or EquipmentUuid
|
||||
</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="e.g. z-001 or MC-42 or SAP-…"
|
||||
@bind="_searchQuery"
|
||||
@bind:event="oninput"
|
||||
@onkeydown="OnSearchKeyDown"/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="fuzzyCheck" @bind="_searchFuzzy"/>
|
||||
<label class="form-check-label small" for="fuzzyCheck">Fuzzy (substring)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RunSearchAsync" disabled="@_searchBusy">Search</button>
|
||||
@if (_searchHits is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-link ms-1" @onclick="ClearSearch">Clear</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (_searchError is not null)
|
||||
{
|
||||
<p class="small text-danger mt-2 mb-0">@_searchError</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_searchHits is not null)
|
||||
{
|
||||
@if (_searchHits.Count == 0)
|
||||
{
|
||||
<p class="p-3 text-muted small mb-0">No matches.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap" style="max-height: 340px; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
|
||||
<th style="width:110px">Matched</th><th style="width:80px">Gen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var hit in _searchHits)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@hit.Equipment.EquipmentId</span></td>
|
||||
<td>@hit.Equipment.Name</td>
|
||||
<td>@hit.Equipment.MachineCode</td>
|
||||
<td>@hit.Equipment.ZTag</td>
|
||||
<td>@hit.Equipment.SAPID</td>
|
||||
<td>
|
||||
@if (hit.MatchedField is not null)
|
||||
{
|
||||
var chipClass = hit.Score switch
|
||||
{
|
||||
100 => "chip chip-ok",
|
||||
50 => "chip chip-warn",
|
||||
_ => "chip chip-idle",
|
||||
};
|
||||
<span class="@chipClass">@hit.MatchedField</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (hit.IsPublished)
|
||||
{ <span class="chip chip-ok">pub</span> }
|
||||
else
|
||||
{ <span class="chip chip-idle">draft</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="p-2 text-muted small mb-0">
|
||||
@_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s").
|
||||
Exact = green, prefix = amber, fuzzy = grey.
|
||||
Fuzzy matching requires the "Fuzzy" checkbox.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (_equipment is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
@@ -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<EquipmentSearchHit>? _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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="maxResults"/> rows.
|
||||
/// </summary>
|
||||
/// <param name="query">Search term (trimmed; empty returns empty results, not all rows).</param>
|
||||
/// <param name="clusterId">Cluster to scope the search to.</param>
|
||||
/// <param name="maxResults">Cap to prevent full-table dumps (default 50).</param>
|
||||
/// <param name="allowFuzzy">When true, LIKE-prefix suffix matches score 20 (opt-in per spec).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public async Task<IReadOnlyList<EquipmentSearchHit>> 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;
|
||||
}
|
||||
|
||||
/// <summary>Score one equipment row against the search term. Returns (score, matchedFieldName).</summary>
|
||||
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<Equipment?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One hit from <see cref="EquipmentService.SearchAsync"/>.</summary>
|
||||
/// <param name="Equipment">The matched equipment row.</param>
|
||||
/// <param name="Score">Match score: 100 = exact, 50 = prefix, 20 = fuzzy.</param>
|
||||
/// <param name="MatchedField">Which identifier field produced the highest score.</param>
|
||||
/// <param name="IsPublished">True when the row is from a published generation (aids tie-break display).</param>
|
||||
public sealed record EquipmentSearchHit(
|
||||
Equipment Equipment,
|
||||
int Score,
|
||||
string? MatchedField,
|
||||
bool IsPublished);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user