S8DatasetCatalogAndGateTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  5. using Xunit;
  6. namespace Admin.NET.Test.S8;
  7. /// <summary>
  8. /// S8-DATASET-FOUNDATION-HARDENING-2:Dataset Catalog 与 Provider Registry 解耦 + 统一 Enable Gate。
  9. ///
  10. /// 本批要锁定的核心区分:
  11. /// · Dataset 根本不存在 → dataset_not_defined
  12. /// · Dataset 已定义但无 Provider → dataset_provider_not_registered(草稿可存,不可启用)
  13. /// · Dataset 声明支持某机制但缺必需字段 → dataset_missing_required_field(配置自相矛盾)
  14. /// · STANDARD_DATASET 取数失败绝不回落 Legacy SQL
  15. /// </summary>
  16. public class S8DatasetCatalogAndGateTests
  17. {
  18. private const string DefinedWithProvider = "FAKE_TEST_DATASET_READY";
  19. private const string DefinedWithoutProvider = "FAKE_TEST_DATASET_NO_PROVIDER";
  20. private const string DefinedButDisabled = "FAKE_TEST_DATASET_DISABLED";
  21. private const string ContradictoryDataset = "FAKE_TEST_DATASET_CONTRADICTORY";
  22. private const string OutOfRangeOnlyDataset = "FAKE_TEST_DATASET_OOR_ONLY";
  23. private static S8DatasetCapabilities TimeoutCapable() => new()
  24. {
  25. SupportsTimeout = true,
  26. HasSourceObjectId = true,
  27. HasRelatedObjectCode = true,
  28. HasDueAt = true,
  29. HasStatus = true
  30. };
  31. private sealed class FakeDefinitionSource : IS8DatasetDefinitionSource
  32. {
  33. public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
  34. {
  35. new S8DatasetDefinition
  36. {
  37. DatasetCode = DefinedWithProvider,
  38. DisplayName = "测试用就绪数据集",
  39. Kind = S8DatasetKind.Object,
  40. Capabilities = TimeoutCapable()
  41. },
  42. new S8DatasetDefinition
  43. {
  44. DatasetCode = DefinedWithoutProvider,
  45. DisplayName = "测试用未实现数据集",
  46. Kind = S8DatasetKind.Object,
  47. Capabilities = TimeoutCapable()
  48. },
  49. new S8DatasetDefinition
  50. {
  51. DatasetCode = DefinedButDisabled,
  52. DisplayName = "测试用停用数据集",
  53. Kind = S8DatasetKind.Metric,
  54. Capabilities = TimeoutCapable(),
  55. Status = S8DatasetStatus.Disabled
  56. },
  57. new S8DatasetDefinition
  58. {
  59. // 自相矛盾:声明支持 TIMEOUT,却没有 due_at 能力。
  60. DatasetCode = ContradictoryDataset,
  61. DisplayName = "测试用矛盾数据集",
  62. Kind = S8DatasetKind.Object,
  63. Capabilities = new S8DatasetCapabilities
  64. {
  65. SupportsTimeout = true,
  66. HasSourceObjectId = true,
  67. HasRelatedObjectCode = true,
  68. HasDueAt = false,
  69. HasStatus = true
  70. }
  71. },
  72. new S8DatasetDefinition
  73. {
  74. DatasetCode = OutOfRangeOnlyDataset,
  75. DisplayName = "测试用仅超差数据集",
  76. Kind = S8DatasetKind.Metric,
  77. Capabilities = new S8DatasetCapabilities
  78. {
  79. SupportsOutOfRange = true,
  80. HasSourceObjectId = true,
  81. HasRelatedObjectCode = true,
  82. HasMeasuredValue = true
  83. }
  84. }
  85. };
  86. }
  87. private sealed class ReadyProvider : IS8MonitoringDataProvider
  88. {
  89. public string DatasetCode => DefinedWithProvider;
  90. public S8DatasetCapabilities Capabilities => TimeoutCapable();
  91. public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  92. {
  93. request.EnsureValid();
  94. return Task.FromResult(S8MonitoringRowSet.Empty);
  95. }
  96. }
  97. private sealed class ThrowingProvider : IS8MonitoringDataProvider
  98. {
  99. public string DatasetCode => DefinedWithProvider;
  100. public S8DatasetCapabilities Capabilities => TimeoutCapable();
  101. public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  102. => throw new InvalidOperationException("provider blew up");
  103. }
  104. private static S8DatasetCatalog Catalog() => new(new[] { new FakeDefinitionSource() });
  105. private static S8MonitoringDataProviderRegistry RegistryWithProvider() => new(new[] { new ReadyProvider() });
  106. private static S8MonitoringDataProviderRegistry EmptyRegistry() =>
  107. new(Array.Empty<IS8MonitoringDataProvider>());
  108. private static AdoS8WatchRule StandardRule(string datasetCode, string ruleType = "TIMEOUT") => new()
  109. {
  110. RuleCode = "UT_RULE",
  111. RuleType = ruleType,
  112. DatasetCode = datasetCode
  113. };
  114. // ============================================================
  115. // A. Catalog ≠ Provider Registry
  116. // ============================================================
  117. [Fact]
  118. public void Catalog_KnowsDataset_EvenWhenProviderNotRegistered()
  119. {
  120. var catalog = Catalog();
  121. Assert.True(catalog.IsDefined(DefinedWithoutProvider));
  122. Assert.Equal("测试用未实现数据集", catalog.Find(DefinedWithoutProvider)!.DisplayName);
  123. Assert.False(EmptyRegistry().IsRegistered(DefinedWithoutProvider));
  124. }
  125. [Fact]
  126. public void DraftSave_Allowed_WhenDatasetDefinedButProviderMissing()
  127. {
  128. // 允许业务先把规则配好,等 Provider 上线再启用。
  129. var rule = StandardRule(DefinedWithoutProvider);
  130. S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog());
  131. }
  132. [Fact]
  133. public void Enable_Rejected_WhenProviderMissing_WithProviderReasonCode()
  134. {
  135. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  136. var result = gate.Check(StandardRule(DefinedWithoutProvider), tenantId: 1);
  137. Assert.False(result.Ok);
  138. Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
  139. }
  140. // ============================================================
  141. // B. Dataset 不存在,必须与 provider missing 区分
  142. // ============================================================
  143. [Fact]
  144. public void UndefinedDataset_Reports_NotDefined_NotProviderMissing()
  145. {
  146. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  147. var result = gate.Check(StandardRule("TOTALLY_UNKNOWN_DATASET"), 1);
  148. Assert.False(result.Ok);
  149. Assert.Equal(S8DatasetReasonCode.NotDefined, result.ReasonCode);
  150. Assert.NotEqual(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
  151. }
  152. [Fact]
  153. public void DraftSave_Rejected_ForUndefinedDataset()
  154. {
  155. var ex = Assert.Throws<S8BizException>(() =>
  156. S8WatchRuleDataAccessValidator.ValidateForSave(StandardRule("TOTALLY_UNKNOWN_DATASET"), Catalog()));
  157. Assert.Contains("TOTALLY_UNKNOWN_DATASET", ex.Message);
  158. }
  159. // ============================================================
  160. // C. Capability mismatch
  161. // ============================================================
  162. [Fact]
  163. public void Enable_Rejected_WhenDatasetDoesNotSupportRuleType()
  164. {
  165. var registry = new S8MonitoringDataProviderRegistry(Array.Empty<IS8MonitoringDataProvider>());
  166. var gate = new S8DatasetEnableGate(Catalog(), registry);
  167. // OUT_OF_RANGE-only 数据集挂 TIMEOUT 规则。
  168. var result = gate.Check(StandardRule(OutOfRangeOnlyDataset, "TIMEOUT"), 1);
  169. Assert.False(result.Ok);
  170. Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode);
  171. }
  172. // ============================================================
  173. // D. 必需 canonical 字段缺失(单一布尔位不可信)
  174. // ============================================================
  175. [Fact]
  176. public void Enable_Rejected_WhenMechanismClaimedButRequiredFieldMissing()
  177. {
  178. // SupportsTimeout=true 但 HasDueAt=false:配置自相矛盾,必须识别。
  179. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  180. var result = gate.Check(StandardRule(ContradictoryDataset), 1);
  181. Assert.False(result.Ok);
  182. Assert.Equal(S8DatasetReasonCode.MissingRequiredField, result.ReasonCode);
  183. Assert.Contains(S8CanonicalColumns.DueAt, result.Message);
  184. }
  185. [Fact]
  186. public void RuleTypeRequirements_MatchRealEvaluatorNeeds()
  187. {
  188. Assert.Equal(
  189. new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.DueAt, S8CanonicalColumns.Status },
  190. S8RuleTypeFieldRequirements.For("TIMEOUT"));
  191. Assert.Equal(
  192. new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.MeasuredValue },
  193. S8RuleTypeFieldRequirements.For("OUT_OF_RANGE"));
  194. Assert.Equal(
  195. new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.TargetQty, S8CanonicalColumns.ActualQty },
  196. S8RuleTypeFieldRequirements.For("SHORTAGE"));
  197. }
  198. // ============================================================
  199. // Disabled dataset
  200. // ============================================================
  201. [Fact]
  202. public void Enable_Rejected_WhenDatasetLifecycleDisabled()
  203. {
  204. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  205. var result = gate.Check(StandardRule(DefinedButDisabled), 1);
  206. Assert.False(result.Ok);
  207. Assert.Equal(S8DatasetReasonCode.NotReady, result.ReasonCode);
  208. }
  209. // ============================================================
  210. // G. Tenant / Factory scope
  211. // ============================================================
  212. [Fact]
  213. public void Enable_Rejected_OnInvalidTenantScope()
  214. {
  215. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  216. var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 0);
  217. Assert.False(result.Ok);
  218. Assert.Equal(S8DatasetReasonCode.InvalidTenantScope, result.ReasonCode);
  219. }
  220. /// <summary>
  221. /// S8-TENANT-ONLY-BATCH5 反向守卫(原 <c>Enable_Rejected_OnInvalidFactoryScope</c> 的继任者)。
  222. ///
  223. /// <para>原用例断言「factory 作用域非法 → 拒绝启用」。工厂不再是规则执行的作用域之后,
  224. /// 这条断言必须**反过来**:Gate 里不得再有任何工厂形参,也不得再产生
  225. /// <c>invalid_factory_scope</c>。删掉用例不行——那样等于把「工厂重新变回门禁」这件事放行了。</para>
  226. ///
  227. /// <para>Batch 2 供给出来的运行策略 <c>factory_id</c> 恒为 0;一旦有人把工厂门禁加回来,
  228. /// 全部租户的规则都会在启用这一步被拒,且理由是一个业务上无意义的作用域错误。</para>
  229. /// </summary>
  230. [Fact]
  231. public void Enable_NotRejected_ByFactoryScope_BecauseFactoryIsNotAScope()
  232. {
  233. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  234. // 只给租户即可通过:调用点连传工厂的地方都没有。
  235. var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 1);
  236. Assert.True(result.Ok);
  237. Assert.Null(result.ReasonCode);
  238. // Check 的两个重载都不得再出现工厂形参。
  239. foreach (var m in typeof(S8DatasetEnableGate).GetMethods().Where(x => x.Name == "Check"))
  240. {
  241. Assert.DoesNotContain(m.GetParameters(), p =>
  242. p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
  243. }
  244. // invalid_factory_scope 常量仍在(历史 reason code 不删),但生产代码不得再产生它。
  245. // 该断言由 S8TenantOnlyMainChainTests 的源码扫描配合,此处只锁 Gate 自身行为。
  246. Assert.NotEqual(S8DatasetReasonCode.InvalidFactoryScope, result.ReasonCode);
  247. }
  248. [Fact]
  249. public void Enable_Passes_WhenEverythingSatisfied()
  250. {
  251. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  252. var result = gate.Check(StandardRule(DefinedWithProvider), 797403760988229);
  253. Assert.True(result.Ok);
  254. Assert.Null(result.ReasonCode);
  255. }
  256. // ============================================================
  257. // F. 无数据集的规则不得通过 Gate
  258. // ============================================================
  259. //
  260. // 原用例 LegacyRule_NotSubjectToDatasetGate 断言「mode=NULL + expression 的规则
  261. // 直接 Pass Gate」——那时 Legacy 是一条合法的平行路径,Gate 只管 Dataset 侧。
  262. // S8-STANDARD-DATASET-HARD-CUTOVER-1 之后不再有平行路径:
  263. // 一条没有 dataset_code 的规则不是"走另一条路",而是**根本无法取数**,必须被拦下。
  264. [Fact]
  265. public void RuleWithoutDatasetCode_FailsGate()
  266. {
  267. var orphan = new AdoS8WatchRule
  268. {
  269. RuleCode = "NO_DATASET",
  270. RuleType = "TIMEOUT",
  271. DatasetCode = null
  272. };
  273. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  274. var result = gate.Check(orphan, 1);
  275. Assert.False(result.Ok);
  276. Assert.Equal(S8DatasetReasonCode.CodeMissing, result.ReasonCode);
  277. // 保存级同样拦下:没有数据集的规则连草稿都不该存成"看起来可运行"的样子。
  278. Assert.Throws<S8BizException>(() =>
  279. S8WatchRuleDataAccessValidator.ValidateForSave(orphan, Catalog()));
  280. }
  281. // ============================================================
  282. // 诊断能力
  283. // ============================================================
  284. [Fact]
  285. public void Diagnostics_DistinguishesReadyFromProviderMissing()
  286. {
  287. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
  288. Assert.Equal(S8DatasetStatus.Ready, diagnostics.Describe(DefinedWithProvider)!.EffectiveStatus);
  289. Assert.Equal(S8DatasetStatus.ProviderMissing, diagnostics.Describe(DefinedWithoutProvider)!.EffectiveStatus);
  290. Assert.Equal(S8DatasetStatus.Disabled, diagnostics.Describe(DefinedButDisabled)!.EffectiveStatus);
  291. Assert.Null(diagnostics.Describe("TOTALLY_UNKNOWN_DATASET"));
  292. }
  293. [Fact]
  294. public void Diagnostics_ReportsUsableRuleTypes_ExcludingContradictoryOnes()
  295. {
  296. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
  297. Assert.Contains("TIMEOUT", diagnostics.Describe(DefinedWithProvider)!.UsableRuleTypes);
  298. // 声明支持 TIMEOUT 但缺 due_at:不得出现在可用列表里。
  299. Assert.DoesNotContain("TIMEOUT", diagnostics.Describe(ContradictoryDataset)!.UsableRuleTypes);
  300. Assert.Contains("OUT_OF_RANGE", diagnostics.Describe(OutOfRangeOnlyDataset)!.UsableRuleTypes);
  301. }
  302. [Fact]
  303. public void Diagnostics_ListsAllDefinedDatasets_RegardlessOfProvider()
  304. {
  305. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), EmptyRegistry());
  306. var all = diagnostics.List();
  307. Assert.Equal(5, all.Count);
  308. Assert.All(all, d => Assert.False(d.ProviderRegistered));
  309. }
  310. }