using System; using System.IO; using System.Linq; using System.Text.Json; using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim; /// /// P0-B · 写前校验和闸门S0DimMaterializer 的 [1.5] 步)契约测试。 /// /// 闸门本身在 S0DimMaterializer.RunAsync 里编排,而该方法依赖 /// ISqlSugarClient / MdpSourcePullDispatcher 等一串需要真实数据库的协作者, /// 无法在本项目现有的「纯单测、不连库」范式下直接跑。故按两层拆测: /// /// /// 判定层 —— 闸门的判据被抽成纯函数 /// ,对它做表驱动断言(B1–B7 场景逐条覆盖); /// 编排层 —— 对 S0DimMaterializer.cs 的源码做结构断言: /// 闸门必须出现在**任何** purge / pull / DELETE / INSERT 之前,且跳过分支必须直接 return。 /// 这一层无法证明运行时行为,但能把「闸门被挪到写操作之后」这种致命回归钉在测试期。 /// /// /// ⚠️ 本文件**不**验证 checksum 值本身算得对不对 —— 那是 /// S0DimSqlBuilder.BuildSourceChecksumSql / BuildDimChecksumSql 的既有职责, /// 且已被 dim 事务内的写后对账每轮实证。本文件只验证「闸门用的是同一套口径」。 /// public class S0DimChecksumGateTests { private const StringComparison Ord = StringComparison.Ordinal; // ──────────────────────────────────────────────────────────────── // 第一层 · 判定纯函数(B1–B7) // ──────────────────────────────────────────────────────────────── /// /// B1 · 源与 dim 完全一致 → 判定为「无变化」,整轮跳过(0 次 DELETE / 0 次 INSERT)。 /// /// 「0 次 DELETE / 0 次 INSERT」由第二层的 /// 从编排结构上保证: /// 跳过分支在 purge / pull / ReplaceAsync 之前 return,它们根本不会被执行到。 /// [Theory] [InlineData(0, "0", 0, "0")] // 空源 + 空 dim:也是「一致」,无事可做 [InlineData(1, "42", 1, "42")] [InlineData(57199, "66123456789012345678901", 57199, "66123456789012345678901")] public void B1_identical_source_and_dim_is_no_change( int srcCnt, string srcChk, int dimCnt, string dimChk) { Assert.True(Match(srcCnt, srcChk, dimCnt, dimChk)); } /// B2 · 首次运行:源有数据、dim 还是空的 → 必须放行走 FULL。 [Theory] [InlineData(10, "123", 0, "0")] [InlineData(1, "7", 0, "0")] public void B2_first_run_empty_dim_must_refresh(int srcCnt, string srcChk, int dimCnt, string dimChk) { Assert.False(Match(srcCnt, srcChk, dimCnt, dimChk)); } /// /// B3 · 源变空(上游把这批主数据删光了):src=0 而 dim 仍有行 → 必须放行走 FULL, /// 让 FULL REPLACE 把 dim 清到 0。闸门绝不能因为「源是空的」就跳过, /// 否则 dim 会永久保留已被删除的主数据。 /// [Fact] public void B3_source_became_empty_must_refresh_and_not_be_swallowed() { Assert.False(Match(0, "0", 5, "999")); // 更隐蔽的一种:源清空后 checksum 也变 0,只比 checksum 就会误判「一致」。 // count 参与判定才能拦住它 —— 这正是 B4 要求「不得只比 checksum」的现实理由之一。 Assert.False(Match(0, "0", 5, "0")); } /// /// B4 · 业务键不变、属性列变了 → checksum 变、count 不变 → 必须放行。 /// 证明闸门**不是只比业务键**。 /// [Fact] public void B4_attribute_only_change_must_refresh() { Assert.False(Match(10, "1000", 10, "1001")); } /// /// B4 补强 · 从 SQL 层证明 checksum 覆盖的是「业务键 + 全部非租户属性列」, /// 而不是只有业务键。任一属性列漏出 checksum,该列的变化都会被闸门永久吞掉。 /// [Fact] public void B4_checksum_sql_must_cover_every_non_tenant_attribute_column() { foreach (var def in S0DimCatalog.All) { var srcSql = S0DimSqlBuilder.BuildSourceChecksumSql(def); var dimSql = S0DimSqlBuilder.BuildDimChecksumSql(def); var attrs = def.Columns.Where(c => c.Kind != S0DimValueKind.TenantIdColumn).ToList(); Assert.NotEmpty(attrs); foreach (var c in attrs) { Assert.True(srcSql.Contains($"`{c.JsonPath}`", Ord), $"{def.Key}: 源侧 checksum 漏了属性列 {c.JsonPath},该列的变化将被闸门吞掉"); Assert.True(dimSql.Contains($"`{c.TargetColumn}`", Ord), $"{def.Key}: dim 侧 checksum 漏了属性列 {c.TargetColumn}"); } // 业务键也必须在校验和里(否则「删一行 + 加一行」且属性和恰好相抵时不可见) foreach (var bk in def.SourceBizKeyColumns) Assert.True(srcSql.Contains($"`{bk}`", Ord), $"{def.Key}: 源侧 checksum 漏了业务键列 {bk}"); } } /// /// B5 · dim 被外部破坏(源没动、dim 少了一行 / 多了一行)→ 必须放行, /// 由 FULL REPLACE 自动修复。闸门不能把「dim 坏了」误读成「无变化」。 /// [Theory] [InlineData(10, "1000", 9, "900")] // 少一行 [InlineData(10, "1000", 11, "1100")] // 多一行 [InlineData(10, "1000", 10, "900")] // 行数对得上但内容被改过 public void B5_corrupted_dim_must_refresh(int srcCnt, string srcChk, int dimCnt, string dimChk) { Assert.False(Match(srcCnt, srcChk, dimCnt, dimChk)); } /// /// B6 · 上一轮 FAILED(dim 回滚、停在更旧的快照)后重跑 → 与源不一致 → 必须重新 FULL。 /// 这是闸门推理链的关键反面:闸门的「相等即可跳过」只有在**上一轮真的成功过**时才成立, /// 而失败轮恰恰不会让两者相等,所以推理是自洽的。 /// [Fact] public void B6_rerun_after_failed_round_must_refresh() { Assert.False(Match(120, "555", 100, "444")); } /// /// B7 · 租户隔离:判定是**逐租户**的纯函数,租户 A 相等不会让租户 B 跳过,反之亦然。 /// [Fact] public void B7_tenant_scoped_decision_must_not_cross_contaminate() { // 租户 A:一致 → 跳过 Assert.True(Match(10, "1000", 10, "1000")); // 租户 B:不一致 → 刷新。两次调用之间无共享状态,A 的结论不会外溢到 B。 Assert.False(Match(20, "2000", 19, "1900")); } /// /// B7 补强 · 两条 checksum SQL 都必须带 tenant_id = @TenantId 谓词。 /// 少了它,闸门就会拿跨租户聚合值判等 —— 一个租户的变化会被另一个租户的数据掩盖。 /// [Fact] public void B7_checksum_sql_must_be_tenant_scoped() { foreach (var def in S0DimCatalog.All) { Assert.Contains("`tenant_id` = @TenantId", S0DimSqlBuilder.BuildSourceChecksumSql(def), Ord); Assert.Contains("`tenant_id` = @TenantId", S0DimSqlBuilder.BuildDimChecksumSql(def), Ord); } } /// /// 判定必须**同时**比较 count 与 checksum。 /// 只比其中一个都会漏判(count 相同内容变了 / 内容和相同但行数变了)。 /// [Fact] public void Decision_must_compare_both_count_and_checksum() { Assert.False(Match(10, "1000", 11, "1000")); // checksum 相同、count 不同 → 不得跳过 Assert.False(Match(10, "1000", 10, "1001")); // count 相同、checksum 不同 → 不得跳过 Assert.True(Match(10, "1000", 10, "1000")); // 两者都相同才跳过 } /// 校验和是 DECIMAL(40,0) 的大整数:判等必须精确,不能退化成浮点近似。 [Fact] public void Decision_must_compare_large_checksums_exactly() { const string a = "66123456789012345678901"; const string b = "66123456789012345678902"; // 仅末位不同 Assert.False(Match(10, a, 10, b)); Assert.True(Match(10, a, 10, a)); } /// 读数对象的 IsMatch 与静态判据必须是同一个判断,不能各写一份。 [Fact] public void IsMatch_property_must_delegate_to_the_static_predicate() { foreach (var (sc, sk, dc, dk) in new[] { (10, 1000m, 10, 1000m), (10, 1000m, 10, 1001m), (10, 1000m, 9, 1000m), (0, 0m, 0, 0m) }) { var pair = Pair(sc, sk, dc, dk); Assert.Equal(S0DimChecksumPair.IsMatchOf(sc, sk, dc, dk), pair.IsMatch); } } /// /// 校验和外化成字符串,且与 S0DimReconcileResult 的格式一致(十进制、无分组分隔符)。 /// 20+ 位整数一旦以 JSON 数字外化,经 MySQL JSON / JS 取出会退化成 DOUBLE 丢精度。 /// [Fact] public void Checksum_must_be_externalized_as_exact_decimal_string() { var pair = Pair(3, 66123456789012345678901m, 3, 66123456789012345678901m); Assert.Equal("66123456789012345678901", pair.SourceChecksum); Assert.Equal("66123456789012345678901", pair.DimChecksum); Assert.DoesNotContain(",", pair.SourceChecksum, Ord); Assert.DoesNotContain(".", pair.SourceChecksum, Ord); } // ──────────────────────────────────────────────────────────────── // 第二层 · 状态语义 // ──────────────────────────────────────────────────────────────── /// /// 跳过状态必须是独立取值:既不是 FAILED,也**不得**与 SUCCESS 相同。 /// 混同 SUCCESS 会让「自动刷新是否真的在工作」不可观测。 /// [Fact] public void Skip_status_must_be_distinct_from_success_and_failed() { Assert.Equal("SKIPPED_NO_CHANGE", S0DimMaterializeResult.StatusSkippedNoChange); Assert.NotEqual(S0DimMaterializeResult.StatusSuccess, S0DimMaterializeResult.StatusSkippedNoChange); Assert.NotEqual(S0DimMaterializeResult.StatusFailed, S0DimMaterializeResult.StatusSkippedNoChange); Assert.Equal("NO_CHANGE", S0DimMaterializeResult.SkipReasonNoChange); // mdp_transform_run_log.status 是 VARCHAR(30)(1.0.130.sql)—— // 新状态值必须装得下,否则整条 run log 写入会在运行期报错。 Assert.True(S0DimMaterializeResult.StatusSkippedNoChange.Length <= 30); } /// /// 跳过的那一轮必须能被事后复核:summary_json 里要有 /// source/dim 的 count 与 checksum、以及 skip_reason。 /// 序列化选项与 S0DimRefreshService 一致(PascalCase,无命名策略)。 /// [Fact] public void Skipped_item_must_be_auditable_in_summary_json() { var item = new S0DimMaterializeResult { Key = "WORK_CENTER", TenantId = 797403760988229L, BatchId = "S0DIM_797403760988229_20260915000000000", Status = S0DimMaterializeResult.StatusSkippedNoChange, SkipReason = S0DimMaterializeResult.SkipReasonNoChange, DimRows = 42, Checksum = Pair(42, 66123456789012345678901m, 42, 66123456789012345678901m) }; var json = JsonSerializer.Serialize(item, new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); Assert.Contains("\"Status\":\"SKIPPED_NO_CHANGE\"", json, Ord); Assert.Contains("\"SkipReason\":\"NO_CHANGE\"", json, Ord); Assert.Contains("\"SourceCount\":42", json, Ord); Assert.Contains("\"DimCount\":42", json, Ord); Assert.Contains("\"SourceChecksum\":\"66123456789012345678901\"", json, Ord); Assert.Contains("\"DimChecksum\":\"66123456789012345678901\"", json, Ord); // 跳过时这三项必须是 0(本轮一行都没动),DimRows 则是 dim 真实行数 Assert.Contains("\"StagingPurged\":0", json, Ord); Assert.Contains("\"SourcePulled\":0", json, Ord); Assert.Contains("\"StagingWritten\":0", json, Ord); Assert.Contains("\"DimRows\":42", json, Ord); // 大整数不得以 JSON 数字外化(会丢精度) Assert.DoesNotContain("66123456789012345678901,", json, Ord); Assert.DoesNotContain(":66123456789012345678901", json, Ord); } // ──────────────────────────────────────────────────────────────── // 第三层 · 编排结构(源码断言) // ──────────────────────────────────────────────────────────────── /// /// 闸门必须位于**任何**写操作之前。 /// /// 这是本批次唯一真正致命的回归形态:闸门若被挪到 purge / pull 之后, /// 它就不再能避免无用功,而且会在「已经把 staging 清空」之后才决定跳过。 /// [Fact] public void Gate_must_run_before_any_write_operation() { var src = MaterializerSource(); var body = RunAsyncBody(src); // 锚点必须是**真实调用**而不是裸方法名:RunAsync 里闸门上方有一段解释性注释 // 同样提到 ReadChecksumPairAsync,裸串会先命中注释 —— 那样即便有人把调用搬到 // purge 之后、只把注释留在原位,本断言仍会通过,形同虚设(已用 mutation 实证)。 var gate = body.IndexOf("await _reconciler.ReadChecksumPairAsync", Ord); Assert.True(gate >= 0, "写前闸门不见了:RunAsync 里找不到 await _reconciler.ReadChecksumPairAsync"); foreach (var write in new[] { "_purge.PurgeAsync", // [2] staging DELETE "PullAllByEntityCodeAsync", // [3] staging INSERT(逐行路径) "_sameDbLoader.LoadAsync", // [3] staging INSERT(同库快路径) "MdpStdFullReplace.ReplaceAsync", // [6] dim DELETE + INSERT "BuildInsertSql" // [6] dim INSERT }) { var at = body.IndexOf(write, Ord); Assert.True(at >= 0, $"RunAsync 里找不到 {write},本断言的锚点已失效,请同步更新"); Assert.True(gate < at, $"写前闸门必须在 {write} 之前,实际在其之后(闸门位置 {gate} > {at})"); } } /// 跳过分支必须**立即 return**,不得继续往下走到 purge / pull / replace。 [Fact] public void Skip_branch_must_return_before_any_write() { var body = RunAsyncBody(MaterializerSource()); var gate = body.IndexOf("if (checksum.IsMatch)", Ord); Assert.True(gate >= 0, "找不到闸门判定分支 if (checksum.IsMatch)"); var purge = body.IndexOf("_purge.PurgeAsync", Ord); var branch = body[gate..purge]; Assert.Contains("return result;", branch, Ord); Assert.Contains(nameof(S0DimMaterializeResult.StatusSkippedNoChange), branch, Ord); // 跳过分支里不得出现任何写动作 Assert.DoesNotContain("PurgeAsync", branch, Ord); Assert.DoesNotContain("ReplaceAsync", branch, Ord); Assert.DoesNotContain("PullAll", branch, Ord); } /// /// 跳过时 DimRows 必须填 dim 当前真实行数,**不能填 0** —— /// 0 会被 run log 的读者读成「dim 被清空了」。 /// [Fact] public void Skip_branch_must_report_actual_dim_rows_not_zero() { var body = RunAsyncBody(MaterializerSource()); var gate = body.IndexOf("if (checksum.IsMatch)", Ord); var branch = body[gate..body.IndexOf("_purge.PurgeAsync", Ord)]; Assert.Contains("result.DimRows = checksum.DimCount;", branch, Ord); Assert.DoesNotContain("result.DimRows = 0", branch, Ord); } /// /// 闸门与写后对账必须**共用同一条读取路径**(S0DimReconciler.ReadChecksumPairAsync)。 /// /// 闸门的正确性完全依赖「写后对账断言过 source==dim」这条推理。 /// 一旦两处各自读一套 checksum,推理会在某次单边修改后静默失效 —— /// 表现为「明明变了却一直跳过」,且没有任何报错。 /// [Fact] public void Gate_and_post_write_reconcile_must_share_one_read_path() { var materializer = MaterializerSource(); var reconciler = File.ReadAllText(FindS0DimSource("S0DimReconciler.cs")); // 写后对账(ReconcileAsync 的 Layer 3)也走同一个方法 Assert.Contains("await ReadChecksumPairAsync(def, tenantId, ct)", reconciler, Ord); // checksum SQL 只允许在这一个方法里被读取 —— 别处出现即意味着出现了第二套口径 Assert.Equal(1, Occurrences(reconciler, "BuildSourceChecksumSql")); Assert.Equal(1, Occurrences(reconciler, "BuildDimChecksumSql")); Assert.Equal(0, Occurrences(materializer, "BuildSourceChecksumSql")); Assert.Equal(0, Occurrences(materializer, "BuildDimChecksumSql")); } /// /// 闸门不得引入 INCR / 水位 / anti-join 等语义:本轮只做 FULL + 写前判等。 /// AssertEntityContractAsync 对 sync_mode / incr_column 的 fail-fast 必须原样保留。 /// [Fact] public void Gate_must_not_introduce_incremental_semantics() { var src = MaterializerSource(); Assert.Contains("sync_mode 必须为 FULL", src, Ord); Assert.Contains("incr_column 必须为空", src, Ord); Assert.Contains("FullRefresh = true", src, Ord); } // ──────────────────────────────────────────────────────────────── // helpers // ──────────────────────────────────────────────────────────────── private static bool Match(int srcCnt, string srcChk, int dimCnt, string dimChk) => S0DimChecksumPair.IsMatchOf(srcCnt, decimal.Parse(srcChk), dimCnt, decimal.Parse(dimChk)); private static S0DimChecksumPair Pair(int srcCnt, decimal srcChk, int dimCnt, decimal dimChk) => new() { SourceCount = srcCnt, SourceChecksumValue = srcChk, DimCount = dimCnt, DimChecksumValue = dimChk }; private static int Occurrences(string haystack, string needle) { int n = 0, at = 0; while ((at = haystack.IndexOf(needle, at, Ord)) >= 0) { n++; at += needle.Length; } return n; } private static string MaterializerSource() => File.ReadAllText(FindS0DimSource("S0DimMaterializer.cs")); /// 截取 RunAsync 方法体(到下一个成员声明为止),避免命中类注释里的示意文字。 private static string RunAsyncBody(string src) { var start = src.IndexOf("public async Task RunAsync(", Ord); Assert.True(start >= 0, "找不到 RunAsync 方法"); var end = src.IndexOf("private const int MaterializeCommandTimeoutSeconds", Ord); Assert.True(end > start, "RunAsync 方法体的结束锚点已失效,请同步更新本测试"); return src[start..end]; } private static string FindS0DimSource(string fileName) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP", "DataPlatform", "S0Dim", fileName); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException($"未找到 {fileName}"); } }