S8PurchaseDeliveryDatasetTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Service.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  5. using Xunit;
  6. namespace Admin.NET.Test.S8;
  7. /// <summary>
  8. /// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 是 S8 第一个真实业务数据集,
  9. /// 本文件锁定它在**目录层**的契约。锁定项与失败后果:
  10. ///
  11. /// A. 数据集必须在 Catalog 中定义 → 否则规则连保存都过不了(dataset_not_defined),
  12. /// 而这个失败只在创建规则时才暴露,届时已在配置界面上。
  13. /// B. TIMEOUT 能力必须机制层 + 字段层齐备 → 只写 SupportsTimeout 而漏 HasDueAt 的配置能存下来,
  14. /// 运行期表现为"永不命中"的静默失效,最难排查。
  15. /// C. SHORTAGE / OUT_OF_RANGE 必须被拒 → remaining_qty 已证不可信(781/784 恒等于 order_qty);
  16. /// 若声明数量能力,Enable Gate 会放行 SHORTAGE 规则,
  17. /// 把不可信数据变成可启用能力。
  18. /// D. 目录与 Provider 的能力必须同源 → 各写一份字面量会漂移,而漂移只在启用那一刻才报
  19. /// dataset_capability_mismatch。
  20. /// E. 重复 DatasetCode 必须抛异常 → Catalog 是 AddTransient,重复登记的失败形态是
  21. /// "首次解析时 500",**不是启动崩溃**。启动日志干净 ≠ 目录健康。
  22. ///
  23. /// 不接 DB、不接 DI、不触发任何真实规则。
  24. /// </summary>
  25. public class S8PurchaseDeliveryDatasetTests
  26. {
  27. private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
  28. private static IS8DatasetCatalog Catalog() =>
  29. new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() });
  30. private static AdoS8WatchRule StandardRule(string ruleType = "TIMEOUT") => new()
  31. {
  32. RuleCode = "UT_PURCHASE_DELIVERY",
  33. RuleType = ruleType,
  34. DatasetCode = PurchaseDelivery
  35. };
  36. // ============================================================
  37. // A. 数据集定义
  38. // ============================================================
  39. [Fact]
  40. public void T1_Catalog_Defines_PurchaseDelivery()
  41. {
  42. var catalog = Catalog();
  43. Assert.True(catalog.IsDefined(PurchaseDelivery));
  44. var definition = catalog.Find(PurchaseDelivery);
  45. Assert.NotNull(definition);
  46. Assert.Equal(S8DatasetKind.Object, definition.Kind);
  47. Assert.Equal(S8DatasetStatus.Defined, definition.Status);
  48. Assert.True(definition.IsActive);
  49. // Description 必须同时钉住「已证」与「未证」两面,否则 due_at 会被误当成交付合同依据。
  50. //
  51. // S8-RULE01-PURCHASE-DELIVERY-CLOSURE-1:原断言锁的是 UAT_EXECUTION_MAPPING 标记,
  52. // 语义是「due_at 是 UAT 期的执行映射,业务语义待确认」。本批完成血缘取证后,
  53. // 该标记只对了一半,故拆成两条分别断言:
  54. //
  55. // ① 已证:Provider 的 COALESCE(need_date, due_date) 与中台自身 DELAYED 判据
  56. // COALESCE(ds.need_date, po.need_date, po.due_date) 逐字等价
  57. // (S3MdpSyncTransformService 的 dwd_supplier_delivery INSERT)。
  58. // ② 仍未证:need_date 是否等同于「与供应商约定的承诺交期」——
  59. // 这要追到源系统 PurOrdDetail.NeedDate 并由业务确认。
  60. //
  61. // 只留 ① 会让后人以为语义已经全部拍板;只留 ② 则丢掉了本批真正建立的证据。
  62. Assert.Contains("同源等价", definition.Description);
  63. Assert.Contains("UNPROVEN_CONTRACT_SEMANTICS", definition.Description);
  64. }
  65. [Fact]
  66. public void T1b_Catalog_DatasetCode_IsResolvedCaseInsensitively()
  67. {
  68. // Catalog 用 OrdinalIgnoreCase;规则里写小写不应变成"数据集不存在"。
  69. Assert.True(Catalog().IsDefined("purchase_delivery"));
  70. }
  71. // ============================================================
  72. // B/C. 能力声明:正反两面都必须覆盖
  73. // ============================================================
  74. [Fact]
  75. public void T3_Capabilities_Satisfy_Timeout()
  76. {
  77. var capabilities = Catalog().Find(PurchaseDelivery).Capabilities;
  78. Assert.True(capabilities.Supports("TIMEOUT"));
  79. Assert.Empty(capabilities.MissingFieldsFor("TIMEOUT"));
  80. Assert.True(capabilities.CanServe("TIMEOUT"));
  81. }
  82. [Fact]
  83. public void T3b_Capabilities_Reject_ShortageAndOutOfRange()
  84. {
  85. var capabilities = Catalog().Find(PurchaseDelivery).Capabilities;
  86. // 机制层就不支持,因此连字段层都不必看。
  87. Assert.False(capabilities.Supports("SHORTAGE"));
  88. Assert.False(capabilities.Supports("OUT_OF_RANGE"));
  89. Assert.False(capabilities.CanServe("SHORTAGE"));
  90. Assert.False(capabilities.CanServe("OUT_OF_RANGE"));
  91. }
  92. [Fact]
  93. public void T3c_Capabilities_DoNotClaimQuantityFields()
  94. {
  95. // 物理表有 order_qty / remaining_qty,但 remaining_qty 已证不可信。
  96. // Capability = "当前可可信用于机制判断",不是"表里有这一列"。
  97. var capabilities = Catalog().Find(PurchaseDelivery).Capabilities;
  98. Assert.False(capabilities.HasTargetQty);
  99. Assert.False(capabilities.HasActualQty);
  100. Assert.False(capabilities.HasMeasuredValue);
  101. }
  102. [Fact]
  103. public void T3d_Definition_And_Provider_ShareSameCapabilityInstance()
  104. {
  105. // 同一实例而非同值:值相等只能证明"此刻没漂",同实例才能保证"不会漂"。
  106. var definition = Catalog().Find(PurchaseDelivery);
  107. Assert.Same(S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities, definition.Capabilities);
  108. }
  109. // ============================================================
  110. // E. 重复登记
  111. // ============================================================
  112. [Fact]
  113. public void DuplicateDatasetCode_ThrowsOnCatalogConstruction()
  114. {
  115. // 两个 source 给出同一个 code。注意:Catalog 注册为 AddTransient,
  116. // 因此现实中这会表现为"首次解析该服务的请求 500",而不是应用启动失败。
  117. var ex = Assert.Throws<InvalidOperationException>(() => new S8DatasetCatalog(
  118. new IS8DatasetDefinitionSource[]
  119. {
  120. new S8BusinessDatasetDefinitions(),
  121. new S8BusinessDatasetDefinitions()
  122. }));
  123. Assert.Contains(PurchaseDelivery, ex.Message);
  124. }
  125. [Fact]
  126. public void Provider_MustNotSelfRegisterViaITransient()
  127. {
  128. // 实测事故(Batch A 运行态复现):Provider 同时带 ITransient 与 Startup 显式注册时,
  129. // Furion 自动扫描会按接口再注册一份。Registry 以 IEnumerable<IS8MonitoringDataProvider>
  130. // 注入,于是同一类型出现两次 → "数据集 PURCHASE_DELIVERY 注册了多个 Provider"。
  131. // 因两者均为 Transient,应用启动毫无异常,直到第一个碰它的请求返回 500。
  132. // 单元测试无法覆盖 DI 容器装配,因此在类型层面钉死这条约束。
  133. Assert.DoesNotContain(
  134. typeof(S8PurchaseDeliveryDataProvider).GetInterfaces(),
  135. i => i.Name is "ITransient" or "IScoped" or "ISingleton");
  136. }
  137. // ============================================================
  138. // F. Enable Gate:目录 + 注册表联合判定
  139. // ============================================================
  140. [Fact]
  141. public void T2_Registry_Resolves_PurchaseDeliveryProvider()
  142. {
  143. var registry = new S8MonitoringDataProviderRegistry(
  144. new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() });
  145. Assert.True(registry.IsRegistered(PurchaseDelivery));
  146. // Gate ⑧ 会逐字比对 Provider 自报编码与目录定义,必须完全一致。
  147. Assert.Equal(PurchaseDelivery, registry.Resolve(PurchaseDelivery).DatasetCode);
  148. }
  149. [Fact]
  150. public void EnableGate_Passes_WhenCatalogAndProviderBothPresent()
  151. {
  152. var gate = new S8DatasetEnableGate(
  153. Catalog(),
  154. new S8MonitoringDataProviderRegistry(
  155. new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() }));
  156. var result = gate.Check(StandardRule(), tenantId: 838257186181189);
  157. Assert.True(result.Ok);
  158. Assert.Null(result.ReasonCode);
  159. }
  160. [Fact]
  161. public void EnableGate_Rejects_WhenProviderNotRegistered()
  162. {
  163. // 数据集已定义但 Provider 未上线:允许存草稿,不允许启用。
  164. var gate = new S8DatasetEnableGate(
  165. Catalog(),
  166. new S8MonitoringDataProviderRegistry(Array.Empty<IS8MonitoringDataProvider>()));
  167. var result = gate.Check(StandardRule(), 838257186181189);
  168. Assert.False(result.Ok);
  169. Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
  170. }
  171. [Fact]
  172. public void EnableGate_Rejects_ShortageRule_WithCapabilityMismatch()
  173. {
  174. // 目录层就能判定的问题必须先于 Provider 层报出:
  175. // 报 provider_not_registered 会误导配置方以为"等实现就行",但这个数据集永远做不了 SHORTAGE。
  176. var gate = new S8DatasetEnableGate(
  177. Catalog(),
  178. new S8MonitoringDataProviderRegistry(
  179. new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() }));
  180. var result = gate.Check(StandardRule("SHORTAGE"), 838257186181189);
  181. Assert.False(result.Ok);
  182. Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode);
  183. Assert.NotEqual(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
  184. }
  185. [Fact]
  186. public void DraftSave_Allowed_ForStandardRule_ReferencingPurchaseDelivery()
  187. {
  188. // 不抛异常本身即断言。
  189. S8WatchRuleDataAccessValidator.ValidateForSave(StandardRule(), Catalog());
  190. }
  191. [Fact]
  192. public void DraftSave_Rejected_WhenDatasetCodeMisspelled()
  193. {
  194. var rule = StandardRule();
  195. rule.DatasetCode = "PURCHASE_DELIVERY_TYPO";
  196. var ex = Assert.Throws<S8BizException>(() =>
  197. S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog()));
  198. Assert.Contains(S8DatasetReasonCode.NotDefined, ex.Message);
  199. }
  200. [Fact]
  201. public void DataSourceRequirement_NoLongerExists()
  202. {
  203. // 原用例断言 RequiresDataSource(StandardRule()) == false ——
  204. // 当时 Legacy 规则仍需绑定物理数据源,这条是"本数据集不受该约束"的护栏。
  205. // S8-STANDARD-DATASET-HARD-CUTOVER-1 删除了整个物理数据源概念,
  206. // 该判定方法随之消失。用反射断言它不会被加回来。
  207. Assert.Null(typeof(S8WatchRuleDataAccessValidator).GetMethod("RequiresDataSource"));
  208. }
  209. /// <summary>
  210. /// 目录层测试用的最小 Provider:只回答编码与能力,不取数。
  211. /// 取数与映射由 S8PurchaseDeliveryProviderMappingTests 覆盖。
  212. /// </summary>
  213. private sealed class StubPurchaseDeliveryProvider : IS8MonitoringDataProvider
  214. {
  215. public string DatasetCode => S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
  216. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  217. public Task<S8MonitoringRowSet> LoadAsync(
  218. S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  219. {
  220. request.EnsureValid();
  221. return Task.FromResult(S8MonitoringRowSet.Empty);
  222. }
  223. }
  224. }