S8PurchaseDeliveryProviderMappingTests.cs 22 KB

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