using System.Text.RegularExpressions;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim;
///
/// 消费者迁移契约(Wave 1:Supplier / Warehouse / Equipment)。
///
/// 这些断言守的是**迁移方向不被悄悄回退**:
/// 智慧运营看板筛选下拉的三个主档来源必须读 S0 标准层 dim_*,
/// 而不是各自的源表。此前全仓 27 个标准层对象的业务消费者数为 0——
/// 标准层建好了却没人读,这三条是第一批真实消费。
///
/// 用静态源码断言而非集成测试,是因为要守的性质是「SQL 文本读的是哪张表」,
/// 集成测试只能证明「有结果」,证明不了「结果来自标准层」。
///
public class S0ConsumerAdoptionTests
{
private const StringComparison Ord = StringComparison.Ordinal;
private const string ConsumerFile =
"server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilterSearch.cs";
/// 取某个搜索方法的方法体(切到下一个同签名方法为止)。
private static string BodyOf(string methodName)
{
var src = StripComments(ReadRepoFile(ConsumerFile));
var sigIdx = src.IndexOf($"Task> {methodName}(", Ord);
Assert.True(sigIdx >= 0, $"未找到方法 {methodName}");
var nextIdx = src.IndexOf("Task> ", sigIdx + 40, Ord);
return nextIdx < 0 ? src[sigIdx..] : src[sigIdx..nextIdx];
}
/// 第一分支(主档)必须来自 dim_*,且源表名彻底消失在该方法内。
[Theory]
[InlineData("SearchSuppliersAsync", "dim_supplier", "SuppMaster")]
[InlineData("SearchWarehousesAsync", "dim_location", "LocationMaster")]
[InlineData("SearchEquipmentsAsync", "dim_work_center", "WorkCtrMaster")]
public void Migrated_consumer_reads_standard_layer_not_source(
string method, string dimTable, string sourceTable)
{
var body = BodyOf(method);
Assert.Contains($"FROM {dimTable}", body, Ord);
Assert.DoesNotContain($"FROM {sourceTable}", body, Ord);
}
///
/// 三条 dim 查询都必须带 tenant_id = @TenantId。
/// tenant_id 是唯一强制的隔离边界,dim_* 是跨租户共表,漏了就是跨租户泄漏。
///
[Theory]
[InlineData("SearchSuppliersAsync", "dim_supplier")]
[InlineData("SearchWarehousesAsync", "dim_location")]
[InlineData("SearchEquipmentsAsync", "dim_work_center")]
public void Migrated_consumer_filters_by_tenant(string method, string dimTable)
{
var stmt = DimStatement(BodyOf(method), dimTable);
Assert.Contains("tenant_id = @TenantId", stmt, Ord);
}
///
/// 必须过滤停用行。实测省掉 is_active 会在租户 824585161322565
/// 多放出 113 个已停用供应商 —— 这不是洁癖,是可观测的错数。
///
[Theory]
[InlineData("SearchSuppliersAsync", "dim_supplier")]
[InlineData("SearchWarehousesAsync", "dim_location")]
[InlineData("SearchEquipmentsAsync", "dim_work_center")]
public void Migrated_consumer_excludes_inactive(string method, string dimTable)
{
var stmt = DimStatement(BodyOf(method), dimTable);
Assert.Contains("is_active", stmt, Ord);
}
///
/// 必须 GROUP BY 业务码。dim_work_center / dim_location 的唯一键含
/// domain_code,同码跨 domain 可合法出现两行(实测已有 2 个租户持多 domain);
/// 不聚合时重复行会被下游 seen 去重,却已白占 LIMIT 名额,
/// 表现为「下拉莫名少一项」这种极难归因的静默缺陷。
///
[Theory]
[InlineData("SearchSuppliersAsync", "dim_supplier", "supplier_code")]
[InlineData("SearchWarehousesAsync", "dim_location", "location_code")]
[InlineData("SearchEquipmentsAsync", "dim_work_center", "work_center_code")]
public void Migrated_consumer_collapses_duplicate_codes(
string method, string dimTable, string codeColumn)
{
var stmt = DimStatement(BodyOf(method), dimTable);
Assert.Contains($"GROUP BY TRIM({codeColumn})", stmt, Ord);
Assert.Matches(new Regex(@"MIN\(TRIM\(\w+_name\)\)\s+AS Name"), stmt);
}
///
/// 对外 Value 必须是业务码,绝不能是 dim_*.id 这类代理键。
/// 代理键每次 FULL REPLACE 都会重编,泄漏出去就是前端筛选值随刷新失效。
///
[Theory]
[InlineData("SearchSuppliersAsync", "dim_supplier")]
[InlineData("SearchWarehousesAsync", "dim_location")]
[InlineData("SearchEquipmentsAsync", "dim_work_center")]
public void Migrated_consumer_never_exposes_surrogate_id(string method, string dimTable)
{
var stmt = DimStatement(BodyOf(method), dimTable);
Assert.DoesNotContain("id AS Code", stmt, Ord);
Assert.DoesNotContain("`id`", stmt, Ord);
Assert.Matches(new Regex(@"SELECT TRIM\(\w+_code\)\s+AS Code"), stmt);
}
///
/// dim 查询内不得 JOIN 回源表补数据 —— 那等于迁移没做完,
/// 且会让标准层的缺口被源表悄悄填上、永远暴露不出来。
///
[Theory]
[InlineData("SearchSuppliersAsync", "dim_supplier")]
[InlineData("SearchWarehousesAsync", "dim_location")]
[InlineData("SearchEquipmentsAsync", "dim_work_center")]
public void Migrated_consumer_does_not_join_back_to_source(string method, string dimTable)
{
var stmt = DimStatement(BodyOf(method), dimTable);
Assert.DoesNotContain("JOIN", stmt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("COALESCE", stmt, StringComparison.OrdinalIgnoreCase);
}
///
/// 第二分支是「补充」不是「兜底」:它由 rows.Count >= limit 短路,
/// 而不是 rows.Count == 0。区别是实质性的——
/// 兜底会在标准层返回 0 行时静默改读源表,让迁移失败看起来像成功。
///
[Theory]
[InlineData("SearchSuppliersAsync")]
[InlineData("SearchWarehousesAsync")]
[InlineData("SearchEquipmentsAsync")]
public void Second_branch_is_supplement_not_fallback(string method)
{
var body = BodyOf(method);
Assert.Contains("if (rows.Count >= limit) return rows;", body, Ord);
Assert.DoesNotContain("if (rows.Count == 0)", body, Ord);
}
///
/// 取数失败不得静默。降级返回空列表可以保留(该 helper 被 11 个下拉共用,
/// 改成抛出会波及 8 个尚未迁移的消费者),但必须留日志——
/// 否则标准层掉线时前端只看到空下拉,排障无任何抓手。
///
[Fact]
public void Query_helpers_do_not_swallow_exceptions_silently()
{
var src = StripComments(ReadRepoFile(ConsumerFile));
Assert.DoesNotContain("catch\n {", src, Ord);
Assert.Equal(2, Regex.Matches(src, @"catch \(Exception ex\)").Count);
Assert.Equal(2, Regex.Matches(src, @"Log\.Error\(").Count);
}
/// 整个消费者文件里,三张已迁移的源主档表必须彻底不再出现。
[Theory]
[InlineData("SuppMaster")]
[InlineData("LocationMaster")]
[InlineData("WorkCtrMaster")]
public void Migrated_source_tables_have_zero_references_in_consumer(string sourceTable)
{
Assert.DoesNotContain(sourceTable, StripComments(ReadRepoFile(ConsumerFile)), Ord);
}
/// 切出某个 dim 表所在的那条 SQL 语句(从 SELECT 到 LIMIT)。
private static string DimStatement(string body, string dimTable)
{
var from = body.IndexOf($"FROM {dimTable}", Ord);
Assert.True(from >= 0, $"未找到 FROM {dimTable}");
var select = body.LastIndexOf("SELECT", from, Ord);
var end = body.IndexOf("LIMIT @Limit", from, Ord);
Assert.True(select >= 0 && end > select, "SQL 语句边界解析失败");
return body[select..(end + "LIMIT @Limit".Length)];
}
/// 从仓库根读源文件做静态断言。测试进程工作目录在 bin 下,需向上回溯。
private static string ReadRepoFile(string relative)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "server")))
dir = dir.Parent;
Assert.NotNull(dir);
var path = Path.Combine(dir!.FullName, relative);
Assert.True(File.Exists(path), $"未找到 {path}");
return File.ReadAllText(path);
}
///
/// 剥掉 // 与 /// 注释行后再做断言。
/// 否则「不得出现 X」这类断言会被解释为什么不能出现 X 的注释本身打败 —— 已踩过一次。
///
private static string StripComments(string source) =>
string.Join("\n", source.Split('\n').Where(l => !l.TrimStart().StartsWith("//", Ord)));
}