S8PurchaseDeliveryProviderMappingTests.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  5. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  6. using Microsoft.Extensions.Logging.Abstractions;
  7. using Xunit;
  8. namespace Admin.NET.Test.S8;
  9. /// <summary>
  10. /// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 的取数作用域、canonical 映射与 TIMEOUT 判定契约。
  11. /// 锁定项与失败后果:
  12. ///
  13. /// A. 作用域只能按 tenant → Phase 1 拍板:Security Boundary = Tenant。若有人日后往 SQL 里补回
  14. /// factory_id 过滤,规则会恒返 0 行且不报错(dwd_supplier_delivery.factory_id
  15. /// 是写死的常量 1,与 S8 侧真实 SysOrg.Id 永不相等)——静默失效,最难发现。
  16. /// B. 快照按 tenant 各自收敛 → 用全局 MAX(stat_date) 会让落后一天的租户整租户静默丢数据。
  17. /// C. 身份稳定且唯一 → source_object_id 进 DedupKey,格式一变历史异常与新异常直接断代。
  18. /// D. DedupKey 预算守卫 → 预算比 source_object_id 列宽(64)更紧;落在预算与列宽之间的键
  19. /// 会过得了列校验、卡在 DedupKey 上,数据库静默截断会把两个 PO Line 折叠成一条异常。
  20. /// E. due_at 双分支 → need_date 为 NULL 时回落 due_date;两者实测 18/36 行不同,不是等价列。
  21. /// F. 判定归 evaluator → Provider 不滤 status,"逾期但已完成不命中"必须由 evaluator 判出来。
  22. /// G. 取数路径唯一 → Provider 取数失败必须暴露为 dataset_query_failed,而不是换一条路。
  23. ///
  24. /// 不接 DB、不接 DI、不触发任何真实规则。SQL 在真实库上的行为已由实施前的只读核验覆盖
  25. /// (4 租户身份唯一、跨租户交集 0、外层走 idx_stat_date),本文件锁定的是结构契约。
  26. /// </summary>
  27. public class S8PurchaseDeliveryProviderMappingTests
  28. {
  29. private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
  30. private const string RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";
  31. private static S8MonitoringDataRequest Request(
  32. long tenantId = 838257186181189) => new()
  33. {
  34. TenantId = tenantId,
  35. DatasetCode = PurchaseDelivery,
  36. RuleCode = RuleCode,
  37. RuleType = "TIMEOUT"
  38. };
  39. private static AdoS8WatchRule NewRule() => new()
  40. {
  41. Id = 900101,
  42. RuleCode = RuleCode,
  43. SceneCode = "S4",
  44. WatchObjectType = "PURCHASE_ORDER_LINE",
  45. SourceObjectType = "PURCHASE_ORDER_LINE",
  46. Severity = "SERIOUS",
  47. RuleType = "TIMEOUT",
  48. DatasetCode = PurchaseDelivery
  49. };
  50. /// <summary>
  51. /// S8-RULE-GOVERNANCE-BATCH1:判定语义已迁至代码定义,evaluator 不再解析 params_json 的 A 类字段。
  52. /// 本 helper 用 Rule 01 的**正式定义**构造生效规则,因此这些用例现在同时也在守护
  53. /// 「定义与实际判定一致」——比原先直接喂一份手写 params_json 更接近真实运行。
  54. /// </summary>
  55. private static S8EffectiveRule Effective(AdoS8WatchRule rule) =>
  56. S8EffectiveRule.Resolve(
  57. rule,
  58. new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
  59. .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode));
  60. /// <summary>真实 DWD 行样本(取自 UAT 租户 2026-09-05 快照)。</summary>
  61. private static S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow Row(
  62. string poNo, string poLine, DateTime? needDate, DateTime? dueDate, string status,
  63. string supplierName = "珩睿", string supplierCode = "10003232") => new()
  64. {
  65. SourceObjectId = $"{poNo}#{poLine}",
  66. RelatedObjectCode = poNo,
  67. RelatedObjectName = string.IsNullOrEmpty(supplierName) ? supplierCode : supplierName,
  68. DueAt = needDate ?? dueDate,
  69. Status = status,
  70. DueAtSource = needDate == null ? "due_date" : "need_date",
  71. PoNo = poNo,
  72. PoLine = poLine,
  73. PoType = "po",
  74. SupplierCode = supplierCode,
  75. SupplierName = supplierName,
  76. ItemCode = "ITM-001",
  77. ItemName = "物料",
  78. OrderQty = 3000m,
  79. RemainingQty = 3000m,
  80. NeedDate = needDate,
  81. DueDate = dueDate,
  82. RiskLevel = "HIGH",
  83. StatDate = new DateTime(2026, 9, 5)
  84. };
  85. private static S8MonitoringRowSet RowSet(params S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow[] rows) =>
  86. S8MonitoringRowSet.FromRows(
  87. rows.Select(r => S8MonitoringRow.FromValues(S8PurchaseDeliveryDataProvider.ToCanonicalValues(r))));
  88. // ============================================================
  89. // A/B. 作用域与快照收敛(SQL 结构契约)
  90. // ============================================================
  91. [Fact]
  92. public void T4_Sql_FiltersByTenant_InBothOuterQueryAndSnapshotSubquery()
  93. {
  94. // 外层与快照子查询都必须带租户,否则会拿到别的租户的最新快照日期。
  95. var sql = S8PurchaseDeliveryDataProvider.Sql;
  96. Assert.Contains("d.tenant_id = @TenantId", sql);
  97. Assert.Contains("x.tenant_id = @TenantId", sql);
  98. }
  99. [Fact]
  100. public void T5_Sql_DoesNotUseFactoryAsDatasetFilter()
  101. {
  102. // Phase 1 拍板:Factory 是业务上下文,不是本数据集的过滤条件。
  103. // 这条断言是防回归的主锁——补回 factory 过滤会让规则恒返 0 行且不报任何错。
  104. Assert.DoesNotContain("factory_id", S8PurchaseDeliveryDataProvider.Sql);
  105. }
  106. [Fact]
  107. public void T5b_FactoryIsNotAQueryDimension_ButTenantIdIs()
  108. {
  109. // S8-TENANT-ONLY-BATCH5:原用例构造「同租户换工厂」的请求来证明工厂不影响取数。
  110. // 现在连构造都做不到了 —— 请求上没有工厂这一维,该结论由类型系统保证。
  111. // 保留断言的另一半:租户仍然是、且是唯一的取数作用域。
  112. Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId"));
  113. var otherTenant = Request(tenantId: 838257212780613);
  114. Assert.NotEqual(Request().TenantId, otherTenant.TenantId);
  115. // 工厂不在 SQL 中,因此不可能成为取数差异的来源。
  116. Assert.DoesNotContain("@FactoryId", S8PurchaseDeliveryDataProvider.Sql);
  117. }
  118. [Fact]
  119. public void T6_Sql_ConvergesSnapshot_PerTenantLatest_NotGlobalMax()
  120. {
  121. var sql = S8PurchaseDeliveryDataProvider.Sql;
  122. // 反向索引扫描而非聚合:大租户上实测 143ms → 2.7ms。
  123. Assert.Contains("ORDER BY x.stat_date DESC", sql);
  124. Assert.Contains("LIMIT 1", sql);
  125. Assert.DoesNotContain("MAX(", sql);
  126. }
  127. [Fact]
  128. public void Sql_ExcludesDegenerateIdentity_AndDoesNotFilterOnBusinessState()
  129. {
  130. var sql = S8PurchaseDeliveryDataProvider.Sql;
  131. // 身份任一段为空会产生退化的 "#",必须排除。
  132. Assert.Contains("d.po_no <> ''", sql);
  133. Assert.Contains("d.po_line <> ''", sql);
  134. // 判定归 evaluator:Provider 不得按状态或不可信的 remaining_qty 预筛。
  135. Assert.DoesNotContain("remaining_qty >", sql);
  136. Assert.DoesNotContain("delivery_status <>", sql);
  137. Assert.DoesNotContain("delivery_status !=", sql);
  138. }
  139. [Fact]
  140. public async Task LoadAsync_ValidatesScope_BeforeTouchingDatabase()
  141. {
  142. // 故意注入 null 数据库:若作用域校验不是第一步,这里会得到 NullReferenceException。
  143. var provider = new S8PurchaseDeliveryDataProvider(
  144. null, NullLogger<S8PurchaseDeliveryDataProvider>.Instance);
  145. var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
  146. provider.LoadAsync(Request(tenantId: 0)));
  147. Assert.Equal("dataset_scope_invalid", ex.Reason);
  148. // 原用例第二段断言「factoryId = 0 同样被拒」。工厂不再是作用域之后该断言必须反向:
  149. // 合法租户下不存在任何以工厂为由的拒绝,请求校验必须直接通过。
  150. // (这正是 Batch 2 供给的 factory_id = 0 运行策略此前过不去的那道门。)
  151. Request().EnsureValid();
  152. Assert.DoesNotContain("Factory", ex.Message, StringComparison.OrdinalIgnoreCase);
  153. }
  154. // ============================================================
  155. // C/D. 身份与 DedupKey 预算
  156. // ============================================================
  157. [Fact]
  158. public void T7_Identity_IsStableComposite_UniquePerLine()
  159. {
  160. var line3 = Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED");
  161. var line4 = Row("PO202608260002", "4", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
  162. var v3 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line3);
  163. var v4 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line4);
  164. Assert.Equal("PO202608260002#3", v3[S8CanonicalColumns.SourceObjectId]);
  165. Assert.Equal("PO202608260002", v3[S8CanonicalColumns.RelatedObjectCode]);
  166. // 同单不同行必须是不同对象,否则同一采购单只会产出一条异常。
  167. Assert.NotEqual(v3[S8CanonicalColumns.SourceObjectId], v4[S8CanonicalColumns.SourceObjectId]);
  168. }
  169. [Fact]
  170. public void T7b_Identity_IsInvariantAcrossSnapshots()
  171. {
  172. // 身份只由 po_no + po_line 决定,与 stat_date 无关;否则每天换一次身份,
  173. // 昨天的异常今天会被判恢复、并新建一条。
  174. var day1 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
  175. var day2 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
  176. day2.StatDate = new DateTime(2026, 9, 6);
  177. Assert.Equal(
  178. S8PurchaseDeliveryDataProvider.ToCanonicalValues(day1)[S8CanonicalColumns.SourceObjectId],
  179. S8PurchaseDeliveryDataProvider.ToCanonicalValues(day2)[S8CanonicalColumns.SourceObjectId]);
  180. }
  181. [Fact]
  182. public void T7c_DedupKey_StaysWithinColumnLimit_ForRealWorstCase()
  183. {
  184. // 实测该租户最长 source_object_id 为 16 字符;此处按真实规则编码与作用域算完整 DedupKey。
  185. var key = S8TimeoutRuleEvaluator.BuildDedupKey(
  186. 838257186181189, RuleCode, "PURCHASE_ORDER_LINE", "PO202608260002#4");
  187. Assert.True(key.Length <= S8PurchaseDeliveryDataProvider.DedupKeyMaxLength,
  188. $"DedupKey 长度 {key.Length} 超过 {S8PurchaseDeliveryDataProvider.DedupKeyMaxLength}:{key}");
  189. }
  190. [Fact]
  191. public void T7d_Budget_IsTighterThanSourceObjectIdColumnWidth()
  192. {
  193. // 关键认知:预算(128 − 前缀)比 source_object_id 的列宽 64 更紧。
  194. // 落在预算与 64 之间的键会过得了列校验、卡在 DedupKey 上。
  195. var budget = S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request());
  196. Assert.True(budget > 0);
  197. Assert.True(budget < 64, $"预期预算紧于列宽 64,实际 {budget}");
  198. // 预算 + 前缀必须正好等于上限,证明算式与 BuildDedupKey 的格式一致。
  199. var probe = new string('x', budget);
  200. var key = S8TimeoutRuleEvaluator.BuildDedupKey(
  201. Request().TenantId, RuleCode, "PURCHASE_ORDER_LINE", probe);
  202. Assert.Equal(S8PurchaseDeliveryDataProvider.DedupKeyMaxLength, key.Length);
  203. }
  204. [Fact]
  205. public void T7e_Budget_ShrinksWhenRuleCodeGrows()
  206. {
  207. // 预算按 request 现算而非写死;换更长的规则编码必须自动收紧。
  208. var longRule = new S8MonitoringDataRequest
  209. {
  210. TenantId = Request().TenantId,
  211. DatasetCode = PurchaseDelivery,
  212. RuleCode = RuleCode + "_MUCH_LONGER_SUFFIX",
  213. RuleType = "TIMEOUT"
  214. };
  215. Assert.True(
  216. S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(longRule)
  217. < S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request()));
  218. }
  219. // ============================================================
  220. // E. Canonical 映射
  221. // ============================================================
  222. [Fact]
  223. public void T8_Canonical_Mapping_IsComplete_WhenNeedDatePresent()
  224. {
  225. var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
  226. Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  227. Assert.Equal("PO202608260002#3", values[S8CanonicalColumns.SourceObjectId]);
  228. Assert.Equal("PO202608260002", values[S8CanonicalColumns.RelatedObjectCode]);
  229. Assert.Equal(new DateTime(2026, 8, 17, 4, 33, 7), values[S8CanonicalColumns.DueAt]);
  230. Assert.Equal("DELAYED", values[S8CanonicalColumns.Status]);
  231. Assert.Equal("need_date", values["due_at_source"]);
  232. }
  233. [Fact]
  234. public void T8b_DueAt_FallsBackToDueDate_WhenNeedDateIsNull()
  235. {
  236. // 真实样本 PW202608280001#1:need_date 为 NULL,是唯一能验回落分支的行。
  237. var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
  238. Row("PW202608280001", "1", null, new DateTime(2026, 8, 29), "DELAYED", supplierName: "怡祥"));
  239. Assert.Equal(new DateTime(2026, 8, 29), values[S8CanonicalColumns.DueAt]);
  240. // due_at 是两种业务日期的混合,必须逐行自证用的是哪个基准。
  241. Assert.Equal("due_date", values["due_at_source"]);
  242. }
  243. [Fact]
  244. public void T8c_Canonical_DoesNotFabricateBusinessJudgements()
  245. {
  246. // Provider 不得构造 is_delivery_complete / is_active_obligation / severity ——
  247. // 上一轮数据契约审计已判这三项 NOT_PROVEN,凭空构造等于把中台缺口埋进 S8。
  248. var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
  249. Row("PO202608260002", "1", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "COMPLETED"));
  250. Assert.DoesNotContain("is_delivery_complete", values.Keys);
  251. Assert.DoesNotContain("is_active_obligation", values.Keys);
  252. Assert.DoesNotContain("severity", values.Keys);
  253. }
  254. // ============================================================
  255. // F. TIMEOUT 判定(由 evaluator 判,不由 Provider 预筛)
  256. // ============================================================
  257. [Fact]
  258. public void T9_Timeout_Hits_WhenOverdueAndNotCompleted()
  259. {
  260. var rowSet = RowSet(Row("PO202608260002", "3",
  261. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  262. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  263. rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
  264. var hit = Assert.Single(hits);
  265. Assert.Equal("PO202608260002#3", hit.SourceObjectId);
  266. Assert.Equal("PO202608260002", hit.RelatedObjectCode);
  267. Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
  268. }
  269. [Fact]
  270. public void T10_Timeout_NoHit_WhenNotYetDue()
  271. {
  272. // 真实样本 PO202608280001#1:due 2026-09-17,状态 OPEN。
  273. var rowSet = RowSet(Row("PO202608280001", "1",
  274. new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN", supplierName: "怡祥"));
  275. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  276. rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
  277. Assert.Empty(hits);
  278. }
  279. [Fact]
  280. public void T11_Timeout_NoHit_WhenOverdueButCompleted()
  281. {
  282. // 真实样本 PO202608260002#1:已逾期但 COMPLETED。
  283. // 这一条必须由 evaluator 判出来 —— 若 Provider 在 SQL 里滤掉已完成行,
  284. // 本测试会"通过"但测的是过滤而非判定,且该行不在 hits 里会被误判为恢复。
  285. var rowSet = RowSet(Row("PO202608260002", "1",
  286. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
  287. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  288. rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
  289. Assert.Empty(hits);
  290. }
  291. [Fact]
  292. public void T11b_Timeout_NoHit_ForClosedAndCancelled()
  293. {
  294. // dwd_supplier_delivery 有三个写入方,词表不一致:批量转换产 COMPLETED/DELAYED/OPEN,
  295. // 增量写入方产 CANCELLED/CLOSED/PARTIAL。当前库中后两值 0 行,这是零行为变更的安全余量。
  296. var rowSet = RowSet(
  297. Row("PO-CLOSED", "1", new DateTime(2026, 8, 1), null, "CLOSED"),
  298. Row("PO-CANCELLED", "1", new DateTime(2026, 8, 1), null, "CANCELLED"));
  299. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  300. rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
  301. Assert.Empty(hits);
  302. }
  303. [Fact]
  304. public void T11c_Timeout_Hits_ForPartial_BecausePartialIsNotTerminal()
  305. {
  306. // PARTIAL 刻意不列入 completedStates:部分收货不是终态,仍需继续监控。
  307. var rowSet = RowSet(Row("PO-PARTIAL", "1", new DateTime(2026, 8, 1), null, "PARTIAL"));
  308. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  309. rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
  310. Assert.Single(hits);
  311. }
  312. [Fact]
  313. public void T13_Recovery_Contract_HitSetShrinksWhenStatusBecomesCompleted()
  314. {
  315. // 恢复判定的输入是"本轮 hits 的 dedup_key 集合"(见 ReconcileRecoveriesForRuleAsync)。
  316. // 这里证明:同一对象在状态转为 COMPLETED 后不再出现在 hits 中,
  317. // 从而进入恢复候选。真实 recovered_at 写入属运行态,由 Batch B 验证。
  318. var before = RowSet(Row("PO202608260002", "2",
  319. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  320. var after = RowSet(Row("PO202608260002", "2",
  321. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
  322. var hitsBefore = S8TimeoutRuleEvaluator.EvaluateRows(
  323. before, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
  324. var hitsAfter = S8TimeoutRuleEvaluator.EvaluateRows(
  325. after, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
  326. var dedupKey = Assert.Single(hitsBefore).DedupKey;
  327. Assert.Empty(hitsAfter);
  328. Assert.DoesNotContain(dedupKey, hitsAfter.Select(h => h.DedupKey));
  329. }
  330. [Fact]
  331. public void T14_Hit_CarriesExceptionTypeAndDiagnosticPayload()
  332. {
  333. var rowSet = RowSet(Row("PO202608260002", "3",
  334. new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
  335. var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
  336. rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5)));
  337. Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode);
  338. Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
  339. Assert.Equal("PO202608260002#3", hit.SourceObjectId);
  340. Assert.Equal("T838257186181189:RRULE_S4_PURCHASE_DELIVERY_DATE_DELAY:PURCHASE_ORDER_LINE:PO202608260002#3",
  341. hit.DedupKey);
  342. // 诊断列必须随 payload 落盘:remaining_qty 供后续数据治理观察,
  343. // due_at_source 用于追溯每行实际使用了 need_date 还是 due_date。
  344. Assert.Contains("remaining_qty", hit.SourcePayload);
  345. Assert.Contains("due_at_source", hit.SourcePayload);
  346. Assert.Contains("stat_date", hit.SourcePayload);
  347. }
  348. // ============================================================
  349. // G. 取数路径唯一
  350. // ============================================================
  351. //
  352. // 原 T12 / T12b 断言「STANDARD_DATASET 绝不回落 Legacy」,做法是注入一个
  353. // SpyLegacy 探针并断言 CallCount == 0,同时在 rule.Expression 里塞一条
  354. // "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN" 当诱饵。
  355. //
  356. // S8-STANDARD-DATASET-HARD-CUTOVER-1 之后这两条已无法成立、也无需成立:
  357. // · IS8LegacySqlDataProvider 不存在 → 探针类型写不出来;
  358. // · AdoS8WatchRule.Expression / DataSourceId 不存在 → 诱饵放不进去。
  359. // 「不回落」从一条需要反复验证的运行期行为,变成了类型系统层面的事实。
  360. //
  361. // 结构性替代断言见 S8MonitoringDataGatewayTests.Gateway_HasNoLegacyDependency
  362. // 与 WatchRule_HasNoSqlOrDataSourceFields;此处只保留失败分类这一条仍有意义的行为。
  363. [Fact]
  364. public async Task T12_ProviderFailure_SurfacesAsQueryFailed()
  365. {
  366. var gateway = new S8MonitoringDataGateway(
  367. new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new ThrowingPurchaseDeliveryProvider() }),
  368. new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
  369. NullLogger<S8MonitoringDataGateway>.Instance);
  370. var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
  371. gateway.LoadAsync(838257186181189, NewRule(), "TIMEOUT", 1000));
  372. Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
  373. }
  374. private sealed class EmptyPurchaseDeliveryProvider : IS8MonitoringDataProvider
  375. {
  376. public string DatasetCode => PurchaseDelivery;
  377. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  378. public Task<S8MonitoringRowSet> LoadAsync(
  379. S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  380. {
  381. request.EnsureValid();
  382. return Task.FromResult(S8MonitoringRowSet.Empty);
  383. }
  384. }
  385. private sealed class ThrowingPurchaseDeliveryProvider : IS8MonitoringDataProvider
  386. {
  387. public string DatasetCode => PurchaseDelivery;
  388. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  389. public Task<S8MonitoringRowSet> LoadAsync(
  390. S8MonitoringDataRequest request, CancellationToken cancellationToken = default) =>
  391. throw new InvalidOperationException("dwd unavailable");
  392. }
  393. }