S0DimChecksumGateTests.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. using System;
  2. using System.IO;
  3. using System.Linq;
  4. using System.Text.Json;
  5. using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
  6. using Xunit;
  7. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim;
  8. /// <summary>
  9. /// <b>P0-B · 写前校验和闸门</b>(<c>S0DimMaterializer</c> 的 [1.5] 步)契约测试。
  10. ///
  11. /// <para>闸门本身在 <c>S0DimMaterializer.RunAsync</c> 里编排,而该方法依赖
  12. /// <c>ISqlSugarClient</c> / <c>MdpSourcePullDispatcher</c> 等一串需要真实数据库的协作者,
  13. /// 无法在本项目现有的「纯单测、不连库」范式下直接跑。故按两层拆测:</para>
  14. ///
  15. /// <list type="number">
  16. /// <item><b>判定层</b> —— 闸门的判据被抽成纯函数
  17. /// <see cref="S0DimChecksumPair.IsMatchOf"/>,对它做表驱动断言(B1–B7 场景逐条覆盖);</item>
  18. /// <item><b>编排层</b> —— 对 <c>S0DimMaterializer.cs</c> 的源码做结构断言:
  19. /// 闸门必须出现在**任何** purge / pull / DELETE / INSERT 之前,且跳过分支必须直接 return。
  20. /// 这一层无法证明运行时行为,但能把「闸门被挪到写操作之后」这种致命回归钉在测试期。</item>
  21. /// </list>
  22. ///
  23. /// <para>⚠️ 本文件**不**验证 checksum 值本身算得对不对 —— 那是
  24. /// <c>S0DimSqlBuilder.BuildSourceChecksumSql</c> / <c>BuildDimChecksumSql</c> 的既有职责,
  25. /// 且已被 dim 事务内的写后对账每轮实证。本文件只验证「闸门用的是同一套口径」。</para>
  26. /// </summary>
  27. public class S0DimChecksumGateTests
  28. {
  29. private const StringComparison Ord = StringComparison.Ordinal;
  30. // ────────────────────────────────────────────────────────────────
  31. // 第一层 · 判定纯函数(B1–B7)
  32. // ────────────────────────────────────────────────────────────────
  33. /// <summary>
  34. /// B1 · 源与 dim 完全一致 → 判定为「无变化」,整轮跳过(0 次 DELETE / 0 次 INSERT)。
  35. ///
  36. /// <para>「0 次 DELETE / 0 次 INSERT」由第二层的
  37. /// <see cref="Skip_branch_must_return_before_any_write"/> 从编排结构上保证:
  38. /// 跳过分支在 purge / pull / ReplaceAsync 之前 <c>return</c>,它们根本不会被执行到。</para>
  39. /// </summary>
  40. [Theory]
  41. [InlineData(0, "0", 0, "0")] // 空源 + 空 dim:也是「一致」,无事可做
  42. [InlineData(1, "42", 1, "42")]
  43. [InlineData(57199, "66123456789012345678901", 57199, "66123456789012345678901")]
  44. public void B1_identical_source_and_dim_is_no_change(
  45. int srcCnt, string srcChk, int dimCnt, string dimChk)
  46. {
  47. Assert.True(Match(srcCnt, srcChk, dimCnt, dimChk));
  48. }
  49. /// <summary>B2 · 首次运行:源有数据、dim 还是空的 → 必须放行走 FULL。</summary>
  50. [Theory]
  51. [InlineData(10, "123", 0, "0")]
  52. [InlineData(1, "7", 0, "0")]
  53. public void B2_first_run_empty_dim_must_refresh(int srcCnt, string srcChk, int dimCnt, string dimChk)
  54. {
  55. Assert.False(Match(srcCnt, srcChk, dimCnt, dimChk));
  56. }
  57. /// <summary>
  58. /// B3 · 源变空(上游把这批主数据删光了):src=0 而 dim 仍有行 → 必须放行走 FULL,
  59. /// 让 FULL REPLACE 把 dim 清到 0。闸门绝不能因为「源是空的」就跳过,
  60. /// 否则 dim 会永久保留已被删除的主数据。
  61. /// </summary>
  62. [Fact]
  63. public void B3_source_became_empty_must_refresh_and_not_be_swallowed()
  64. {
  65. Assert.False(Match(0, "0", 5, "999"));
  66. // 更隐蔽的一种:源清空后 checksum 也变 0,只比 checksum 就会误判「一致」。
  67. // count 参与判定才能拦住它 —— 这正是 B4 要求「不得只比 checksum」的现实理由之一。
  68. Assert.False(Match(0, "0", 5, "0"));
  69. }
  70. /// <summary>
  71. /// B4 · 业务键不变、属性列变了 → checksum 变、count 不变 → 必须放行。
  72. /// 证明闸门**不是只比业务键**。
  73. /// </summary>
  74. [Fact]
  75. public void B4_attribute_only_change_must_refresh()
  76. {
  77. Assert.False(Match(10, "1000", 10, "1001"));
  78. }
  79. /// <summary>
  80. /// B4 补强 · 从 SQL 层证明 checksum 覆盖的是「业务键 + 全部非租户属性列」,
  81. /// 而不是只有业务键。任一属性列漏出 checksum,该列的变化都会被闸门永久吞掉。
  82. /// </summary>
  83. [Fact]
  84. public void B4_checksum_sql_must_cover_every_non_tenant_attribute_column()
  85. {
  86. foreach (var def in S0DimCatalog.All)
  87. {
  88. var srcSql = S0DimSqlBuilder.BuildSourceChecksumSql(def);
  89. var dimSql = S0DimSqlBuilder.BuildDimChecksumSql(def);
  90. var attrs = def.Columns.Where(c => c.Kind != S0DimValueKind.TenantIdColumn).ToList();
  91. Assert.NotEmpty(attrs);
  92. foreach (var c in attrs)
  93. {
  94. Assert.True(srcSql.Contains($"`{c.JsonPath}`", Ord),
  95. $"{def.Key}: 源侧 checksum 漏了属性列 {c.JsonPath},该列的变化将被闸门吞掉");
  96. Assert.True(dimSql.Contains($"`{c.TargetColumn}`", Ord),
  97. $"{def.Key}: dim 侧 checksum 漏了属性列 {c.TargetColumn}");
  98. }
  99. // 业务键也必须在校验和里(否则「删一行 + 加一行」且属性和恰好相抵时不可见)
  100. foreach (var bk in def.SourceBizKeyColumns)
  101. Assert.True(srcSql.Contains($"`{bk}`", Ord), $"{def.Key}: 源侧 checksum 漏了业务键列 {bk}");
  102. }
  103. }
  104. /// <summary>
  105. /// B5 · dim 被外部破坏(源没动、dim 少了一行 / 多了一行)→ 必须放行,
  106. /// 由 FULL REPLACE 自动修复。闸门不能把「dim 坏了」误读成「无变化」。
  107. /// </summary>
  108. [Theory]
  109. [InlineData(10, "1000", 9, "900")] // 少一行
  110. [InlineData(10, "1000", 11, "1100")] // 多一行
  111. [InlineData(10, "1000", 10, "900")] // 行数对得上但内容被改过
  112. public void B5_corrupted_dim_must_refresh(int srcCnt, string srcChk, int dimCnt, string dimChk)
  113. {
  114. Assert.False(Match(srcCnt, srcChk, dimCnt, dimChk));
  115. }
  116. /// <summary>
  117. /// B6 · 上一轮 FAILED(dim 回滚、停在更旧的快照)后重跑 → 与源不一致 → 必须重新 FULL。
  118. /// 这是闸门推理链的关键反面:闸门的「相等即可跳过」只有在**上一轮真的成功过**时才成立,
  119. /// 而失败轮恰恰不会让两者相等,所以推理是自洽的。
  120. /// </summary>
  121. [Fact]
  122. public void B6_rerun_after_failed_round_must_refresh()
  123. {
  124. Assert.False(Match(120, "555", 100, "444"));
  125. }
  126. /// <summary>
  127. /// B7 · 租户隔离:判定是**逐租户**的纯函数,租户 A 相等不会让租户 B 跳过,反之亦然。
  128. /// </summary>
  129. [Fact]
  130. public void B7_tenant_scoped_decision_must_not_cross_contaminate()
  131. {
  132. // 租户 A:一致 → 跳过
  133. Assert.True(Match(10, "1000", 10, "1000"));
  134. // 租户 B:不一致 → 刷新。两次调用之间无共享状态,A 的结论不会外溢到 B。
  135. Assert.False(Match(20, "2000", 19, "1900"));
  136. }
  137. /// <summary>
  138. /// B7 补强 · 两条 checksum SQL 都必须带 <c>tenant_id = @TenantId</c> 谓词。
  139. /// 少了它,闸门就会拿跨租户聚合值判等 —— 一个租户的变化会被另一个租户的数据掩盖。
  140. /// </summary>
  141. [Fact]
  142. public void B7_checksum_sql_must_be_tenant_scoped()
  143. {
  144. foreach (var def in S0DimCatalog.All)
  145. {
  146. Assert.Contains("`tenant_id` = @TenantId", S0DimSqlBuilder.BuildSourceChecksumSql(def), Ord);
  147. Assert.Contains("`tenant_id` = @TenantId", S0DimSqlBuilder.BuildDimChecksumSql(def), Ord);
  148. }
  149. }
  150. /// <summary>
  151. /// 判定必须**同时**比较 count 与 checksum。
  152. /// 只比其中一个都会漏判(count 相同内容变了 / 内容和相同但行数变了)。
  153. /// </summary>
  154. [Fact]
  155. public void Decision_must_compare_both_count_and_checksum()
  156. {
  157. Assert.False(Match(10, "1000", 11, "1000")); // checksum 相同、count 不同 → 不得跳过
  158. Assert.False(Match(10, "1000", 10, "1001")); // count 相同、checksum 不同 → 不得跳过
  159. Assert.True(Match(10, "1000", 10, "1000")); // 两者都相同才跳过
  160. }
  161. /// <summary>校验和是 DECIMAL(40,0) 的大整数:判等必须精确,不能退化成浮点近似。</summary>
  162. [Fact]
  163. public void Decision_must_compare_large_checksums_exactly()
  164. {
  165. const string a = "66123456789012345678901";
  166. const string b = "66123456789012345678902"; // 仅末位不同
  167. Assert.False(Match(10, a, 10, b));
  168. Assert.True(Match(10, a, 10, a));
  169. }
  170. /// <summary>读数对象的 IsMatch 与静态判据必须是同一个判断,不能各写一份。</summary>
  171. [Fact]
  172. public void IsMatch_property_must_delegate_to_the_static_predicate()
  173. {
  174. foreach (var (sc, sk, dc, dk) in new[]
  175. {
  176. (10, 1000m, 10, 1000m),
  177. (10, 1000m, 10, 1001m),
  178. (10, 1000m, 9, 1000m),
  179. (0, 0m, 0, 0m)
  180. })
  181. {
  182. var pair = Pair(sc, sk, dc, dk);
  183. Assert.Equal(S0DimChecksumPair.IsMatchOf(sc, sk, dc, dk), pair.IsMatch);
  184. }
  185. }
  186. /// <summary>
  187. /// 校验和外化成字符串,且与 <c>S0DimReconcileResult</c> 的格式一致(十进制、无分组分隔符)。
  188. /// 20+ 位整数一旦以 JSON 数字外化,经 MySQL JSON / JS 取出会退化成 DOUBLE 丢精度。
  189. /// </summary>
  190. [Fact]
  191. public void Checksum_must_be_externalized_as_exact_decimal_string()
  192. {
  193. var pair = Pair(3, 66123456789012345678901m, 3, 66123456789012345678901m);
  194. Assert.Equal("66123456789012345678901", pair.SourceChecksum);
  195. Assert.Equal("66123456789012345678901", pair.DimChecksum);
  196. Assert.DoesNotContain(",", pair.SourceChecksum, Ord);
  197. Assert.DoesNotContain(".", pair.SourceChecksum, Ord);
  198. }
  199. // ────────────────────────────────────────────────────────────────
  200. // 第二层 · 状态语义
  201. // ────────────────────────────────────────────────────────────────
  202. /// <summary>
  203. /// 跳过状态必须是独立取值:既不是 FAILED,也**不得**与 SUCCESS 相同。
  204. /// 混同 SUCCESS 会让「自动刷新是否真的在工作」不可观测。
  205. /// </summary>
  206. [Fact]
  207. public void Skip_status_must_be_distinct_from_success_and_failed()
  208. {
  209. Assert.Equal("SKIPPED_NO_CHANGE", S0DimMaterializeResult.StatusSkippedNoChange);
  210. Assert.NotEqual(S0DimMaterializeResult.StatusSuccess, S0DimMaterializeResult.StatusSkippedNoChange);
  211. Assert.NotEqual(S0DimMaterializeResult.StatusFailed, S0DimMaterializeResult.StatusSkippedNoChange);
  212. Assert.Equal("NO_CHANGE", S0DimMaterializeResult.SkipReasonNoChange);
  213. // mdp_transform_run_log.status 是 VARCHAR(30)(1.0.130.sql)——
  214. // 新状态值必须装得下,否则整条 run log 写入会在运行期报错。
  215. Assert.True(S0DimMaterializeResult.StatusSkippedNoChange.Length <= 30);
  216. }
  217. /// <summary>
  218. /// 跳过的那一轮必须能被事后复核:<c>summary_json</c> 里要有
  219. /// source/dim 的 count 与 checksum、以及 skip_reason。
  220. /// 序列化选项与 <c>S0DimRefreshService</c> 一致(PascalCase,无命名策略)。
  221. /// </summary>
  222. [Fact]
  223. public void Skipped_item_must_be_auditable_in_summary_json()
  224. {
  225. var item = new S0DimMaterializeResult
  226. {
  227. Key = "WORK_CENTER",
  228. TenantId = 797403760988229L,
  229. BatchId = "S0DIM_797403760988229_20260915000000000",
  230. Status = S0DimMaterializeResult.StatusSkippedNoChange,
  231. SkipReason = S0DimMaterializeResult.SkipReasonNoChange,
  232. DimRows = 42,
  233. Checksum = Pair(42, 66123456789012345678901m, 42, 66123456789012345678901m)
  234. };
  235. var json = JsonSerializer.Serialize(item, new JsonSerializerOptions
  236. {
  237. Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
  238. });
  239. Assert.Contains("\"Status\":\"SKIPPED_NO_CHANGE\"", json, Ord);
  240. Assert.Contains("\"SkipReason\":\"NO_CHANGE\"", json, Ord);
  241. Assert.Contains("\"SourceCount\":42", json, Ord);
  242. Assert.Contains("\"DimCount\":42", json, Ord);
  243. Assert.Contains("\"SourceChecksum\":\"66123456789012345678901\"", json, Ord);
  244. Assert.Contains("\"DimChecksum\":\"66123456789012345678901\"", json, Ord);
  245. // 跳过时这三项必须是 0(本轮一行都没动),DimRows 则是 dim 真实行数
  246. Assert.Contains("\"StagingPurged\":0", json, Ord);
  247. Assert.Contains("\"SourcePulled\":0", json, Ord);
  248. Assert.Contains("\"StagingWritten\":0", json, Ord);
  249. Assert.Contains("\"DimRows\":42", json, Ord);
  250. // 大整数不得以 JSON 数字外化(会丢精度)
  251. Assert.DoesNotContain("66123456789012345678901,", json, Ord);
  252. Assert.DoesNotContain(":66123456789012345678901", json, Ord);
  253. }
  254. // ────────────────────────────────────────────────────────────────
  255. // 第三层 · 编排结构(源码断言)
  256. // ────────────────────────────────────────────────────────────────
  257. /// <summary>
  258. /// 闸门必须位于**任何**写操作之前。
  259. ///
  260. /// <para>这是本批次唯一真正致命的回归形态:闸门若被挪到 purge / pull 之后,
  261. /// 它就不再能避免无用功,而且会在「已经把 staging 清空」之后才决定跳过。</para>
  262. /// </summary>
  263. [Fact]
  264. public void Gate_must_run_before_any_write_operation()
  265. {
  266. var src = MaterializerSource();
  267. var body = RunAsyncBody(src);
  268. // 锚点必须是**真实调用**而不是裸方法名:RunAsync 里闸门上方有一段解释性注释
  269. // 同样提到 ReadChecksumPairAsync,裸串会先命中注释 —— 那样即便有人把调用搬到
  270. // purge 之后、只把注释留在原位,本断言仍会通过,形同虚设(已用 mutation 实证)。
  271. var gate = body.IndexOf("await _reconciler.ReadChecksumPairAsync", Ord);
  272. Assert.True(gate >= 0, "写前闸门不见了:RunAsync 里找不到 await _reconciler.ReadChecksumPairAsync");
  273. foreach (var write in new[]
  274. {
  275. "_purge.PurgeAsync", // [2] staging DELETE
  276. "PullAllByEntityCodeAsync", // [3] staging INSERT(逐行路径)
  277. "_sameDbLoader.LoadAsync", // [3] staging INSERT(同库快路径)
  278. "MdpStdFullReplace.ReplaceAsync", // [6] dim DELETE + INSERT
  279. "BuildInsertSql" // [6] dim INSERT
  280. })
  281. {
  282. var at = body.IndexOf(write, Ord);
  283. Assert.True(at >= 0, $"RunAsync 里找不到 {write},本断言的锚点已失效,请同步更新");
  284. Assert.True(gate < at, $"写前闸门必须在 {write} 之前,实际在其之后(闸门位置 {gate} > {at})");
  285. }
  286. }
  287. /// <summary>跳过分支必须**立即 return**,不得继续往下走到 purge / pull / replace。</summary>
  288. [Fact]
  289. public void Skip_branch_must_return_before_any_write()
  290. {
  291. var body = RunAsyncBody(MaterializerSource());
  292. var gate = body.IndexOf("if (checksum.IsMatch)", Ord);
  293. Assert.True(gate >= 0, "找不到闸门判定分支 if (checksum.IsMatch)");
  294. var purge = body.IndexOf("_purge.PurgeAsync", Ord);
  295. var branch = body[gate..purge];
  296. Assert.Contains("return result;", branch, Ord);
  297. Assert.Contains(nameof(S0DimMaterializeResult.StatusSkippedNoChange), branch, Ord);
  298. // 跳过分支里不得出现任何写动作
  299. Assert.DoesNotContain("PurgeAsync", branch, Ord);
  300. Assert.DoesNotContain("ReplaceAsync", branch, Ord);
  301. Assert.DoesNotContain("PullAll", branch, Ord);
  302. }
  303. /// <summary>
  304. /// 跳过时 <c>DimRows</c> 必须填 dim 当前真实行数,**不能填 0** ——
  305. /// 0 会被 run log 的读者读成「dim 被清空了」。
  306. /// </summary>
  307. [Fact]
  308. public void Skip_branch_must_report_actual_dim_rows_not_zero()
  309. {
  310. var body = RunAsyncBody(MaterializerSource());
  311. var gate = body.IndexOf("if (checksum.IsMatch)", Ord);
  312. var branch = body[gate..body.IndexOf("_purge.PurgeAsync", Ord)];
  313. Assert.Contains("result.DimRows = checksum.DimCount;", branch, Ord);
  314. Assert.DoesNotContain("result.DimRows = 0", branch, Ord);
  315. }
  316. /// <summary>
  317. /// 闸门与写后对账必须**共用同一条读取路径**(<c>S0DimReconciler.ReadChecksumPairAsync</c>)。
  318. ///
  319. /// <para>闸门的正确性完全依赖「写后对账断言过 source==dim」这条推理。
  320. /// 一旦两处各自读一套 checksum,推理会在某次单边修改后静默失效 ——
  321. /// 表现为「明明变了却一直跳过」,且没有任何报错。</para>
  322. /// </summary>
  323. [Fact]
  324. public void Gate_and_post_write_reconcile_must_share_one_read_path()
  325. {
  326. var materializer = MaterializerSource();
  327. var reconciler = File.ReadAllText(FindS0DimSource("S0DimReconciler.cs"));
  328. // 写后对账(ReconcileAsync 的 Layer 3)也走同一个方法
  329. Assert.Contains("await ReadChecksumPairAsync(def, tenantId, ct)", reconciler, Ord);
  330. // checksum SQL 只允许在这一个方法里被读取 —— 别处出现即意味着出现了第二套口径
  331. Assert.Equal(1, Occurrences(reconciler, "BuildSourceChecksumSql"));
  332. Assert.Equal(1, Occurrences(reconciler, "BuildDimChecksumSql"));
  333. Assert.Equal(0, Occurrences(materializer, "BuildSourceChecksumSql"));
  334. Assert.Equal(0, Occurrences(materializer, "BuildDimChecksumSql"));
  335. }
  336. /// <summary>
  337. /// 闸门不得引入 INCR / 水位 / anti-join 等语义:本轮只做 FULL + 写前判等。
  338. /// <c>AssertEntityContractAsync</c> 对 sync_mode / incr_column 的 fail-fast 必须原样保留。
  339. /// </summary>
  340. [Fact]
  341. public void Gate_must_not_introduce_incremental_semantics()
  342. {
  343. var src = MaterializerSource();
  344. Assert.Contains("sync_mode 必须为 FULL", src, Ord);
  345. Assert.Contains("incr_column 必须为空", src, Ord);
  346. Assert.Contains("FullRefresh = true", src, Ord);
  347. }
  348. // ────────────────────────────────────────────────────────────────
  349. // helpers
  350. // ────────────────────────────────────────────────────────────────
  351. private static bool Match(int srcCnt, string srcChk, int dimCnt, string dimChk) =>
  352. S0DimChecksumPair.IsMatchOf(srcCnt, decimal.Parse(srcChk), dimCnt, decimal.Parse(dimChk));
  353. private static S0DimChecksumPair Pair(int srcCnt, decimal srcChk, int dimCnt, decimal dimChk) =>
  354. new()
  355. {
  356. SourceCount = srcCnt,
  357. SourceChecksumValue = srcChk,
  358. DimCount = dimCnt,
  359. DimChecksumValue = dimChk
  360. };
  361. private static int Occurrences(string haystack, string needle)
  362. {
  363. int n = 0, at = 0;
  364. while ((at = haystack.IndexOf(needle, at, Ord)) >= 0) { n++; at += needle.Length; }
  365. return n;
  366. }
  367. private static string MaterializerSource() => File.ReadAllText(FindS0DimSource("S0DimMaterializer.cs"));
  368. /// <summary>截取 RunAsync 方法体(到下一个成员声明为止),避免命中类注释里的示意文字。</summary>
  369. private static string RunAsyncBody(string src)
  370. {
  371. var start = src.IndexOf("public async Task<S0DimMaterializeResult> RunAsync(", Ord);
  372. Assert.True(start >= 0, "找不到 RunAsync 方法");
  373. var end = src.IndexOf("private const int MaterializeCommandTimeoutSeconds", Ord);
  374. Assert.True(end > start, "RunAsync 方法体的结束锚点已失效,请同步更新本测试");
  375. return src[start..end];
  376. }
  377. private static string FindS0DimSource(string fileName)
  378. {
  379. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  380. while (dir != null)
  381. {
  382. var candidate = Path.Combine(dir.FullName,
  383. "Admin.NET.Plugin.AiDOP", "DataPlatform", "S0Dim", fileName);
  384. if (File.Exists(candidate)) return candidate;
  385. dir = dir.Parent;
  386. }
  387. throw new FileNotFoundException($"未找到 {fileName}");
  388. }
  389. }