S8MonitoringDataContractTests.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. using System.Data;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  5. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  6. using Xunit;
  7. namespace Admin.NET.Test.S8;
  8. /// <summary>
  9. /// S8-DATASET-PROVIDER-FOUNDATION-1 契约回归。
  10. ///
  11. /// 本批目标是建立 Rule → DataAccessMode → Provider → Canonical Row → Evaluator 的稳定边界。
  12. /// 本测试类锁定四件事:
  13. /// A. Legacy 兼容:DataAccessMode 为空(132 条历史规则的现状)必须解析为 LEGACY_SQL;
  14. /// B. 标准数据集契约:三个 evaluator 的判定核心可在完全没有 SQL / DataTable 的情况下运行;
  15. /// C. Tenant/Factory 必须由请求显式携带,不得隐式取值;
  16. /// D. 旧行 DataAccessMode/DatasetCode = NULL 不导致任何解析失败。
  17. ///
  18. /// 不接 DB、不接 DI、不触发任何真实规则。
  19. /// </summary>
  20. public class S8MonitoringDataContractTests
  21. {
  22. // S8-STANDARD-DATASET-HARD-CUTOVER-1:此处原有 4 条 DataAccessMode 解析用例
  23. // (NULL/空白 → LEGACY_SQL、STANDARD_DATASET 大小写不敏感、未知值抛错)。
  24. // S8DataAccessMode 整个类型已删除 —— 取数方式只剩一种,枚举本身失去意义。
  25. // 「不存在第二种取数方式」由 S8MonitoringDataGatewayTests.Gateway_HasNoLegacyDependency
  26. // 与 WatchRule_HasNoSqlOrDataSourceFields 断言。
  27. // ============================================================
  28. // Canonical Row / RowSet 表示层:必须与原 DataTable 读取语义等价
  29. // ============================================================
  30. [Fact]
  31. public void RowSet_FromDataTable_PreservesAllColumns_ForPayloadDump()
  32. {
  33. // payload 依赖"把结果集所有列原样落进 JSON",因此 RowSet 必须保留全部列(含非 canonical 列)。
  34. var table = new DataTable();
  35. table.Columns.Add("source_object_id", typeof(string));
  36. table.Columns.Add("vendor_specific_col", typeof(string));
  37. var row = table.NewRow();
  38. row["source_object_id"] = "SO-1";
  39. row["vendor_specific_col"] = "keep-me";
  40. table.Rows.Add(row);
  41. var rowSet = S8MonitoringRowSet.FromDataTable(table);
  42. Assert.Single(rowSet.Rows);
  43. Assert.True(rowSet.HasColumn("source_object_id"));
  44. Assert.True(rowSet.HasColumn("vendor_specific_col"));
  45. Assert.Equal("keep-me", rowSet.Rows[0].Values["vendor_specific_col"]);
  46. }
  47. [Fact]
  48. public void RowSet_HasColumn_False_ForAbsentColumn_DrivesParamsFallback()
  49. {
  50. // evaluator 的 canonical→params 回退依赖"结果集是否含 canonical 列"这一判断。
  51. var table = new DataTable();
  52. table.Columns.Add("planner_end_date", typeof(DateTime));
  53. var rowSet = S8MonitoringRowSet.FromDataTable(table);
  54. Assert.False(rowSet.HasColumn("due_at"));
  55. Assert.True(rowSet.HasColumn("planner_end_date"));
  56. }
  57. [Fact]
  58. public void Row_GetString_ReturnsNull_ForDbNull_AndTrims()
  59. {
  60. var table = new DataTable();
  61. table.Columns.Add("a", typeof(string));
  62. table.Columns.Add("b", typeof(string));
  63. var r = table.NewRow();
  64. r["a"] = " padded ";
  65. r["b"] = DBNull.Value;
  66. table.Rows.Add(r);
  67. var row = S8MonitoringRowSet.FromDataTable(table).Rows[0];
  68. Assert.Equal("padded", row.GetString("a"));
  69. Assert.Null(row.GetString("b"));
  70. Assert.Null(row.GetString("column_that_does_not_exist"));
  71. }
  72. [Fact]
  73. public void Row_GetDecimal_And_GetDateTime_ParseFromStringLikeLegacyReader()
  74. {
  75. // 旧实现用 Convert.ToString + TryParse,标准 Provider 也可能给出强类型值,两者都要支持。
  76. var row = S8MonitoringRow.FromValues(new Dictionary<string, object>
  77. {
  78. ["num_text"] = "12.5",
  79. ["num_typed"] = 7m,
  80. ["date_text"] = "2026-01-02 03:04:05",
  81. ["date_typed"] = new DateTime(2026, 1, 2, 3, 4, 5),
  82. ["not_a_number"] = "abc"
  83. });
  84. Assert.Equal(12.5m, row.GetDecimal("num_text"));
  85. Assert.Equal(7m, row.GetDecimal("num_typed"));
  86. Assert.Equal(new DateTime(2026, 1, 2, 3, 4, 5), row.GetDateTime("date_text"));
  87. Assert.Equal(new DateTime(2026, 1, 2, 3, 4, 5), row.GetDateTime("date_typed"));
  88. Assert.Null(row.GetDecimal("not_a_number"));
  89. }
  90. [Fact]
  91. public void Row_NullValue_IsRealNull_NotTheStringLiteralNull()
  92. {
  93. var row = S8MonitoringRow.FromValues(new Dictionary<string, object> { ["x"] = null });
  94. Assert.Null(row.GetString("x"));
  95. Assert.NotEqual("null", row.GetString("x"));
  96. }
  97. // ============================================================
  98. // B. 标准数据集契约:evaluator 判定核心脱离 SQL 运行(本批最重要验收)
  99. // ============================================================
  100. private static AdoS8WatchRule NewRule(string ruleType) => new()
  101. {
  102. Id = 900001,
  103. RuleCode = "UT_DATASET_RULE",
  104. SceneCode = "S1",
  105. WatchObjectType = "ORDER",
  106. SourceObjectType = "ORDER",
  107. Severity = "FOLLOW",
  108. RuleType = ruleType
  109. };
  110. /// <summary>
  111. /// S8-RULE-GOVERNANCE-BATCH1:判定语义已迁至代码定义。
  112. /// 这些用例原先手写一份 params_json 来表达"这条规则判什么",现在改为构造对应的
  113. /// <see cref="S8RuleDefinition"/> —— 断言内容一字未改,只是语义来源换成了正确的那一处。
  114. /// </summary>
  115. private static S8EffectiveRule Effective(
  116. string ruleType,
  117. string exceptionTypeCode = "X",
  118. string[]? completedStates = null,
  119. decimal? upperBound = null,
  120. decimal? lowerBound = null)
  121. {
  122. var definition = new S8RuleDefinition
  123. {
  124. RuleCode = "UT_DATASET_RULE",
  125. DisplayName = "单测规则",
  126. Description = "契约测试用",
  127. DatasetCode = "UT_FAKE_DATASET",
  128. RuleType = ruleType,
  129. RuleMechanism = "DATE",
  130. SourceObjectType = "ORDER",
  131. SceneCode = "S1",
  132. StageCode = "S1",
  133. ExceptionTypeCode = exceptionTypeCode,
  134. Timeout = ruleType == "TIMEOUT"
  135. ? new S8TimeoutSemantics { CompletedStates = completedStates ?? Array.Empty<string>() }
  136. : null,
  137. Shortage = ruleType == "SHORTAGE" ? new S8ShortageSemantics() : null,
  138. OutOfRange = ruleType == "OUT_OF_RANGE"
  139. ? new S8OutOfRangeSemantics { UpperBound = upperBound, LowerBound = lowerBound }
  140. : null
  141. };
  142. return S8EffectiveRule.Resolve(NewRule(ruleType), definition);
  143. }
  144. [Fact]
  145. public void Timeout_EvaluateRows_ProducesHit_WithoutAnySql()
  146. {
  147. // 关键验收:输入是 canonical rows,没有 expression、没有 DataTable、没有数据源。
  148. var rowSet = S8MonitoringRowSet.FromRows(new[]
  149. {
  150. S8MonitoringRow.FromValues(new Dictionary<string, object>
  151. {
  152. ["source_object_id"] = "SO-OVERDUE",
  153. ["related_object_code"] = "SO-OVERDUE",
  154. ["due_at"] = new DateTime(2026, 1, 1, 0, 0, 0),
  155. ["status"] = "RUNNING"
  156. })
  157. });
  158. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  159. rowSet,
  160. Effective("TIMEOUT", exceptionTypeCode: "DELIVERY_DELAY", completedStates: new[] { "COMPLETED" }),
  161. tenantId: 11,
  162. detectedAt: new DateTime(2026, 6, 1, 0, 0, 0));
  163. var hit = Assert.Single(hits);
  164. Assert.Equal("SO-OVERDUE", hit.SourceObjectId);
  165. Assert.Equal("ORDER", hit.SourceObjectType);
  166. Assert.Equal("DELIVERY_DELAY", hit.ExceptionTypeCode);
  167. Assert.Equal("T11:RUT_DATASET_RULE:ORDER:SO-OVERDUE", hit.DedupKey);
  168. }
  169. [Fact]
  170. public void Timeout_EvaluateRows_SkipsCompletedStates_SemanticsUnchanged()
  171. {
  172. var rowSet = S8MonitoringRowSet.FromRows(new[]
  173. {
  174. S8MonitoringRow.FromValues(new Dictionary<string, object>
  175. {
  176. ["source_object_id"] = "SO-DONE",
  177. ["related_object_code"] = "SO-DONE",
  178. ["due_at"] = new DateTime(2026, 1, 1),
  179. ["status"] = "COMPLETED"
  180. })
  181. });
  182. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  183. rowSet, Effective("TIMEOUT", completedStates: new[] { "COMPLETED" }),
  184. 11, new DateTime(2026, 6, 1));
  185. Assert.Empty(hits);
  186. }
  187. /// <summary>
  188. /// S8-RULE-GOVERNANCE-BATCH1:原用例 <c>Timeout_EvaluateRows_FallsBackToParamsColumn_WhenCanonicalAbsent</c>
  189. /// 验证的是「结果集没有 canonical 列时,回退到 params_json 指定的真实列名」。
  190. ///
  191. /// <para>那条回退已随本批**刻意移除**:它是"用户可以在 JSON 里指定判定读哪一列"这项能力的
  192. /// 最后一段实现,而该能力正是本批要取消的。Provider 现在必须按 canonical 契约产出行
  193. /// (由 <c>S8PurchaseDeliverySqlMappingTests</c> 守护),不存在"结果集没有 canonical 列"的合法情形。</para>
  194. ///
  195. /// <para>本用例把新语义钉住:非 canonical 列名一律读不到,该行不命中 —— 而不是悄悄换一列读。</para>
  196. /// </summary>
  197. [Fact]
  198. public void Timeout_EvaluateRows_NoLongerFallsBackToNonCanonicalColumns()
  199. {
  200. var rowSet = S8MonitoringRowSet.FromRows(new[]
  201. {
  202. S8MonitoringRow.FromValues(new Dictionary<string, object>
  203. {
  204. ["related_object_code"] = "WO-1",
  205. ["planner_end_date"] = new DateTime(2026, 1, 1),
  206. ["morder_state"] = "RUNNING"
  207. })
  208. });
  209. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  210. rowSet, Effective("TIMEOUT"), 1, new DateTime(2026, 6, 1));
  211. Assert.Empty(hits);
  212. }
  213. [Fact]
  214. public void OutOfRange_EvaluateRows_AboveUpper_WithoutAnySql()
  215. {
  216. var rowSet = S8MonitoringRowSet.FromRows(new[]
  217. {
  218. S8MonitoringRow.FromValues(new Dictionary<string, object>
  219. {
  220. ["source_object_id"] = "ITEM-1",
  221. ["related_object_code"] = "ITEM-1",
  222. ["measured_value"] = 130m
  223. })
  224. });
  225. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  226. rowSet, Effective("OUT_OF_RANGE", exceptionTypeCode: "QUALITY_OUT_OF_SPEC", upperBound: 100m),
  227. 11, DateTime.Now);
  228. var hit = Assert.Single(hits);
  229. Assert.Equal("ITEM-1", hit.SourceObjectId);
  230. Assert.Equal("QUALITY_OUT_OF_SPEC", hit.ExceptionTypeCode);
  231. Assert.Contains("超差", hit.Title);
  232. }
  233. [Fact]
  234. public void OutOfRange_EvaluateRows_WithinBounds_NoHit()
  235. {
  236. var rowSet = S8MonitoringRowSet.FromRows(new[]
  237. {
  238. S8MonitoringRow.FromValues(new Dictionary<string, object>
  239. {
  240. ["source_object_id"] = "ITEM-OK",
  241. ["related_object_code"] = "ITEM-OK",
  242. ["measured_value"] = 50m
  243. })
  244. });
  245. var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
  246. rowSet, Effective("OUT_OF_RANGE", upperBound: 100m, lowerBound: 10m),
  247. 1, DateTime.Now);
  248. Assert.Empty(hits);
  249. }
  250. [Fact]
  251. public void Shortage_EvaluateRows_WithoutAnySql()
  252. {
  253. var rowSet = S8MonitoringRowSet.FromRows(new[]
  254. {
  255. S8MonitoringRow.FromValues(new Dictionary<string, object>
  256. {
  257. ["related_object_code"] = "PO-1",
  258. ["target_qty"] = 100m,
  259. ["actual_qty"] = 40m
  260. })
  261. });
  262. var hits = S8ShortageRuleEvaluator.EvaluateRows(
  263. rowSet, Effective("SHORTAGE", exceptionTypeCode: "MATERIAL_SHORTAGE"),
  264. 11, DateTime.Now);
  265. var hit = Assert.Single(hits);
  266. Assert.Equal("PO-1", hit.SourceObjectId);
  267. Assert.Equal("MATERIAL_SHORTAGE", hit.ExceptionTypeCode);
  268. }
  269. [Fact]
  270. public void EvaluateRows_PayloadStillContainsAllSourceColumns()
  271. {
  272. // 迁移后 payload 仍须保留结果集原始列(下游排查依赖它)。
  273. var rowSet = S8MonitoringRowSet.FromRows(new[]
  274. {
  275. S8MonitoringRow.FromValues(new Dictionary<string, object>
  276. {
  277. ["source_object_id"] = "SO-P",
  278. ["related_object_code"] = "SO-P",
  279. ["due_at"] = new DateTime(2026, 1, 1),
  280. ["status"] = "RUNNING",
  281. ["extra_business_column"] = "must-survive"
  282. })
  283. });
  284. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  285. rowSet, Effective("TIMEOUT"), 1, new DateTime(2026, 6, 1));
  286. Assert.Contains("extra_business_column", hits[0].SourcePayload);
  287. Assert.Contains("must-survive", hits[0].SourcePayload);
  288. }
  289. // ============================================================
  290. // C. Tenant / Factory 显式传递
  291. // ============================================================
  292. [Fact]
  293. public void DataRequest_CarriesExplicitTenant_AndHasNoFactoryDimension()
  294. {
  295. var request = new S8MonitoringDataRequest
  296. {
  297. TenantId = 797403760988229,
  298. DatasetCode = "UT_DATASET",
  299. RuleId = 1,
  300. RuleCode = "UT",
  301. RuleType = "TIMEOUT"
  302. };
  303. Assert.Equal(797403760988229, request.TenantId);
  304. // S8-TENANT-ONLY-BATCH5:取数作用域里不得再有工厂这一维。
  305. // 唯一的 Provider 的 WHERE 从一开始就只有 tenant_id,工厂形参从来只是个
  306. // 不影响结果、却能把零工厂/多工厂租户挡在门外的形式门槛。
  307. Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId"));
  308. }
  309. /// <summary>
  310. /// 原用例 <c>DataRequest_RejectsNonPositiveTenantOrFactory</c> 的继任者。
  311. ///
  312. /// <para>租户缺失仍必须显式失败(禁止 Provider "猜"租户);
  313. /// 而「工厂为 0 也拒绝」那一半必须**反过来**——工厂已不是作用域,
  314. /// 不得再有任何取数请求因它被拒。这正是 Batch 2 供给出的
  315. /// <c>factory_id = 0</c> 运行策略此前被静默挡死的那道门。</para>
  316. /// </summary>
  317. [Fact]
  318. public void DataRequest_RejectsNonPositiveTenant_ButHasNoFactoryGate()
  319. {
  320. var ex = Assert.Throws<S8RuleEvaluatorException>(() =>
  321. new S8MonitoringDataRequest { TenantId = 0, DatasetCode = "D", RuleCode = "R", RuleType = "TIMEOUT" }.EnsureValid());
  322. Assert.Equal("dataset_scope_invalid", ex.Reason);
  323. Assert.DoesNotContain("Factory", ex.Message, StringComparison.OrdinalIgnoreCase);
  324. // 合法租户 → 通过;没有任何"工厂必须为正"的附加条件可以让它失败。
  325. new S8MonitoringDataRequest { TenantId = 1, DatasetCode = "D", RuleCode = "R", RuleType = "TIMEOUT" }
  326. .EnsureValid();
  327. }
  328. // ============================================================
  329. // Registry / Capability 校验
  330. // ============================================================
  331. private sealed class FakeDatasetProvider : IS8MonitoringDataProvider
  332. {
  333. public string DatasetCode => "UT_FAKE_DATASET";
  334. public S8DatasetCapabilities Capabilities => new() { SupportsTimeout = true };
  335. public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  336. {
  337. request.EnsureValid();
  338. return Task.FromResult(S8MonitoringRowSet.FromRows(new[]
  339. {
  340. S8MonitoringRow.FromValues(new Dictionary<string, object>
  341. {
  342. ["source_object_id"] = $"T{request.TenantId}",
  343. ["related_object_code"] = "R",
  344. ["due_at"] = new DateTime(2026, 1, 1),
  345. ["status"] = "RUNNING"
  346. })
  347. }));
  348. }
  349. }
  350. [Fact]
  351. public void Registry_ResolvesRegisteredProvider()
  352. {
  353. var registry = new S8MonitoringDataProviderRegistry(new[] { new FakeDatasetProvider() });
  354. Assert.True(registry.IsRegistered("UT_FAKE_DATASET"));
  355. Assert.Equal("UT_FAKE_DATASET", registry.Resolve("UT_FAKE_DATASET").DatasetCode);
  356. }
  357. [Fact]
  358. public void Registry_UnknownDatasetCode_Throws()
  359. {
  360. var registry = new S8MonitoringDataProviderRegistry(new[] { new FakeDatasetProvider() });
  361. Assert.False(registry.IsRegistered("NO_SUCH_DATASET"));
  362. var ex = Assert.Throws<S8RuleEvaluatorException>(() => registry.Resolve("NO_SUCH_DATASET"));
  363. Assert.Equal("dataset_provider_not_registered", ex.Reason);
  364. }
  365. [Fact]
  366. public void Capabilities_RejectRuleTypeTheDatasetCannotServe()
  367. {
  368. // TIMEOUT 规则挂到不提供 due_at 能力的 Dataset 上,必须在校验期被拒绝,
  369. // 而不是运行期解析不到列后静默不命中。
  370. var caps = new S8DatasetCapabilities { SupportsTimeout = false, SupportsOutOfRange = true };
  371. Assert.False(caps.Supports(S8TimeoutRuleEvaluator.RuleTypeCode));
  372. Assert.True(caps.Supports(S8OutOfRangeRuleEvaluator.RuleTypeCode));
  373. }
  374. [Fact]
  375. public async Task Provider_ReceivesTenant_NoImplicitContext()
  376. {
  377. var registry = new S8MonitoringDataProviderRegistry(new[] { new FakeDatasetProvider() });
  378. var provider = registry.Resolve("UT_FAKE_DATASET");
  379. var rowSet = await provider.LoadAsync(new S8MonitoringDataRequest
  380. {
  381. TenantId = 555,
  382. DatasetCode = "UT_FAKE_DATASET",
  383. RuleId = 1,
  384. RuleCode = "UT",
  385. RuleType = "TIMEOUT"
  386. });
  387. // 作用域必须来自请求本身,不得来自任何隐式上下文。
  388. Assert.Equal("T555", rowSet.Rows[0].GetString("source_object_id"));
  389. }
  390. [Fact]
  391. public async Task EndToEnd_StandardDataset_FeedsEvaluator_WithoutSql()
  392. {
  393. // 本批最终验收:Provider → Canonical Row → Evaluator,全程无 SQL 字符串。
  394. var registry = new S8MonitoringDataProviderRegistry(new[] { new FakeDatasetProvider() });
  395. var provider = registry.Resolve("UT_FAKE_DATASET");
  396. var rowSet = await provider.LoadAsync(new S8MonitoringDataRequest
  397. {
  398. TenantId = 11,
  399. DatasetCode = "UT_FAKE_DATASET",
  400. RuleId = 1,
  401. RuleCode = "UT_DATASET_RULE",
  402. RuleType = "TIMEOUT"
  403. });
  404. var hits = S8TimeoutRuleEvaluator.EvaluateRows(
  405. rowSet, Effective("TIMEOUT"), 11, new DateTime(2026, 6, 1));
  406. Assert.Single(hits);
  407. Assert.Equal("T11:RUT_DATASET_RULE:ORDER:T11", hits[0].DedupKey);
  408. }
  409. }