|
@@ -0,0 +1,433 @@
|
|
|
|
|
+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;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// <b>P0-B · 写前校验和闸门</b>(<c>S0DimMaterializer</c> 的 [1.5] 步)契约测试。
|
|
|
|
|
+///
|
|
|
|
|
+/// <para>闸门本身在 <c>S0DimMaterializer.RunAsync</c> 里编排,而该方法依赖
|
|
|
|
|
+/// <c>ISqlSugarClient</c> / <c>MdpSourcePullDispatcher</c> 等一串需要真实数据库的协作者,
|
|
|
|
|
+/// 无法在本项目现有的「纯单测、不连库」范式下直接跑。故按两层拆测:</para>
|
|
|
|
|
+///
|
|
|
|
|
+/// <list type="number">
|
|
|
|
|
+/// <item><b>判定层</b> —— 闸门的判据被抽成纯函数
|
|
|
|
|
+/// <see cref="S0DimChecksumPair.IsMatchOf"/>,对它做表驱动断言(B1–B7 场景逐条覆盖);</item>
|
|
|
|
|
+/// <item><b>编排层</b> —— 对 <c>S0DimMaterializer.cs</c> 的源码做结构断言:
|
|
|
|
|
+/// 闸门必须出现在**任何** purge / pull / DELETE / INSERT 之前,且跳过分支必须直接 return。
|
|
|
|
|
+/// 这一层无法证明运行时行为,但能把「闸门被挪到写操作之后」这种致命回归钉在测试期。</item>
|
|
|
|
|
+/// </list>
|
|
|
|
|
+///
|
|
|
|
|
+/// <para>⚠️ 本文件**不**验证 checksum 值本身算得对不对 —— 那是
|
|
|
|
|
+/// <c>S0DimSqlBuilder.BuildSourceChecksumSql</c> / <c>BuildDimChecksumSql</c> 的既有职责,
|
|
|
|
|
+/// 且已被 dim 事务内的写后对账每轮实证。本文件只验证「闸门用的是同一套口径」。</para>
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public class S0DimChecksumGateTests
|
|
|
|
|
+{
|
|
|
|
|
+ private const StringComparison Ord = StringComparison.Ordinal;
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────────────────────────────────────────
|
|
|
|
|
+ // 第一层 · 判定纯函数(B1–B7)
|
|
|
|
|
+ // ────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B1 · 源与 dim 完全一致 → 判定为「无变化」,整轮跳过(0 次 DELETE / 0 次 INSERT)。
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// <para>「0 次 DELETE / 0 次 INSERT」由第二层的
|
|
|
|
|
+ /// <see cref="Skip_branch_must_return_before_any_write"/> 从编排结构上保证:
|
|
|
|
|
+ /// 跳过分支在 purge / pull / ReplaceAsync 之前 <c>return</c>,它们根本不会被执行到。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>B2 · 首次运行:源有数据、dim 还是空的 → 必须放行走 FULL。</summary>
|
|
|
|
|
+ [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));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B3 · 源变空(上游把这批主数据删光了):src=0 而 dim 仍有行 → 必须放行走 FULL,
|
|
|
|
|
+ /// 让 FULL REPLACE 把 dim 清到 0。闸门绝不能因为「源是空的」就跳过,
|
|
|
|
|
+ /// 否则 dim 会永久保留已被删除的主数据。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B4 · 业务键不变、属性列变了 → checksum 变、count 不变 → 必须放行。
|
|
|
|
|
+ /// 证明闸门**不是只比业务键**。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void B4_attribute_only_change_must_refresh()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.False(Match(10, "1000", 10, "1001"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B4 补强 · 从 SQL 层证明 checksum 覆盖的是「业务键 + 全部非租户属性列」,
|
|
|
|
|
+ /// 而不是只有业务键。任一属性列漏出 checksum,该列的变化都会被闸门永久吞掉。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B5 · dim 被外部破坏(源没动、dim 少了一行 / 多了一行)→ 必须放行,
|
|
|
|
|
+ /// 由 FULL REPLACE 自动修复。闸门不能把「dim 坏了」误读成「无变化」。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B6 · 上一轮 FAILED(dim 回滚、停在更旧的快照)后重跑 → 与源不一致 → 必须重新 FULL。
|
|
|
|
|
+ /// 这是闸门推理链的关键反面:闸门的「相等即可跳过」只有在**上一轮真的成功过**时才成立,
|
|
|
|
|
+ /// 而失败轮恰恰不会让两者相等,所以推理是自洽的。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void B6_rerun_after_failed_round_must_refresh()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.False(Match(120, "555", 100, "444"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B7 · 租户隔离:判定是**逐租户**的纯函数,租户 A 相等不会让租户 B 跳过,反之亦然。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// B7 补强 · 两条 checksum SQL 都必须带 <c>tenant_id = @TenantId</c> 谓词。
|
|
|
|
|
+ /// 少了它,闸门就会拿跨租户聚合值判等 —— 一个租户的变化会被另一个租户的数据掩盖。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 判定必须**同时**比较 count 与 checksum。
|
|
|
|
|
+ /// 只比其中一个都会漏判(count 相同内容变了 / 内容和相同但行数变了)。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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")); // 两者都相同才跳过
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>校验和是 DECIMAL(40,0) 的大整数:判等必须精确,不能退化成浮点近似。</summary>
|
|
|
|
|
+ [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));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>读数对象的 IsMatch 与静态判据必须是同一个判断,不能各写一份。</summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 校验和外化成字符串,且与 <c>S0DimReconcileResult</c> 的格式一致(十进制、无分组分隔符)。
|
|
|
|
|
+ /// 20+ 位整数一旦以 JSON 数字外化,经 MySQL JSON / JS 取出会退化成 DOUBLE 丢精度。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────────────────────────────────────────
|
|
|
|
|
+ // 第二层 · 状态语义
|
|
|
|
|
+ // ────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 跳过状态必须是独立取值:既不是 FAILED,也**不得**与 SUCCESS 相同。
|
|
|
|
|
+ /// 混同 SUCCESS 会让「自动刷新是否真的在工作」不可观测。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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>
|
|
|
|
|
+ /// 跳过的那一轮必须能被事后复核:<c>summary_json</c> 里要有
|
|
|
|
|
+ /// source/dim 的 count 与 checksum、以及 skip_reason。
|
|
|
|
|
+ /// 序列化选项与 <c>S0DimRefreshService</c> 一致(PascalCase,无命名策略)。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────────────────────────────────────────
|
|
|
|
|
+ // 第三层 · 编排结构(源码断言)
|
|
|
|
|
+ // ────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 闸门必须位于**任何**写操作之前。
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// <para>这是本批次唯一真正致命的回归形态:闸门若被挪到 purge / pull 之后,
|
|
|
|
|
+ /// 它就不再能避免无用功,而且会在「已经把 staging 清空」之后才决定跳过。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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})");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>跳过分支必须**立即 return**,不得继续往下走到 purge / pull / replace。</summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 跳过时 <c>DimRows</c> 必须填 dim 当前真实行数,**不能填 0** ——
|
|
|
|
|
+ /// 0 会被 run log 的读者读成「dim 被清空了」。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 闸门与写后对账必须**共用同一条读取路径**(<c>S0DimReconciler.ReadChecksumPairAsync</c>)。
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// <para>闸门的正确性完全依赖「写后对账断言过 source==dim」这条推理。
|
|
|
|
|
+ /// 一旦两处各自读一套 checksum,推理会在某次单边修改后静默失效 ——
|
|
|
|
|
+ /// 表现为「明明变了却一直跳过」,且没有任何报错。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 闸门不得引入 INCR / 水位 / anti-join 等语义:本轮只做 FULL + 写前判等。
|
|
|
|
|
+ /// <c>AssertEntityContractAsync</c> 对 sync_mode / incr_column 的 fail-fast 必须原样保留。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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"));
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>截取 RunAsync 方法体(到下一个成员声明为止),避免命中类注释里的示意文字。</summary>
|
|
|
|
|
+ private static string RunAsyncBody(string src)
|
|
|
|
|
+ {
|
|
|
|
|
+ var start = src.IndexOf("public async Task<S0DimMaterializeResult> 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}");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|