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}");
}
}