S8DatasetCatalogAndGateTests.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  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, factoryId: 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, 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, 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, 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, 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, factoryId: 1);
  217. Assert.False(result.Ok);
  218. Assert.Equal(S8DatasetReasonCode.InvalidTenantScope, result.ReasonCode);
  219. }
  220. [Fact]
  221. public void Enable_Rejected_OnInvalidFactoryScope()
  222. {
  223. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  224. var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 1, factoryId: 0);
  225. Assert.False(result.Ok);
  226. Assert.Equal(S8DatasetReasonCode.InvalidFactoryScope, result.ReasonCode);
  227. }
  228. [Fact]
  229. public void Enable_Passes_WhenEverythingSatisfied()
  230. {
  231. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  232. var result = gate.Check(StandardRule(DefinedWithProvider), 797403760988229, 1);
  233. Assert.True(result.Ok);
  234. Assert.Null(result.ReasonCode);
  235. }
  236. // ============================================================
  237. // F. 无数据集的规则不得通过 Gate
  238. // ============================================================
  239. //
  240. // 原用例 LegacyRule_NotSubjectToDatasetGate 断言「mode=NULL + expression 的规则
  241. // 直接 Pass Gate」——那时 Legacy 是一条合法的平行路径,Gate 只管 Dataset 侧。
  242. // S8-STANDARD-DATASET-HARD-CUTOVER-1 之后不再有平行路径:
  243. // 一条没有 dataset_code 的规则不是"走另一条路",而是**根本无法取数**,必须被拦下。
  244. [Fact]
  245. public void RuleWithoutDatasetCode_FailsGate()
  246. {
  247. var orphan = new AdoS8WatchRule
  248. {
  249. RuleCode = "NO_DATASET",
  250. RuleType = "TIMEOUT",
  251. DatasetCode = null
  252. };
  253. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  254. var result = gate.Check(orphan, 1, 1);
  255. Assert.False(result.Ok);
  256. Assert.Equal(S8DatasetReasonCode.CodeMissing, result.ReasonCode);
  257. // 保存级同样拦下:没有数据集的规则连草稿都不该存成"看起来可运行"的样子。
  258. Assert.Throws<S8BizException>(() =>
  259. S8WatchRuleDataAccessValidator.ValidateForSave(orphan, Catalog()));
  260. }
  261. // ============================================================
  262. // 诊断能力
  263. // ============================================================
  264. [Fact]
  265. public void Diagnostics_DistinguishesReadyFromProviderMissing()
  266. {
  267. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
  268. Assert.Equal(S8DatasetStatus.Ready, diagnostics.Describe(DefinedWithProvider)!.EffectiveStatus);
  269. Assert.Equal(S8DatasetStatus.ProviderMissing, diagnostics.Describe(DefinedWithoutProvider)!.EffectiveStatus);
  270. Assert.Equal(S8DatasetStatus.Disabled, diagnostics.Describe(DefinedButDisabled)!.EffectiveStatus);
  271. Assert.Null(diagnostics.Describe("TOTALLY_UNKNOWN_DATASET"));
  272. }
  273. [Fact]
  274. public void Diagnostics_ReportsUsableRuleTypes_ExcludingContradictoryOnes()
  275. {
  276. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
  277. Assert.Contains("TIMEOUT", diagnostics.Describe(DefinedWithProvider)!.UsableRuleTypes);
  278. // 声明支持 TIMEOUT 但缺 due_at:不得出现在可用列表里。
  279. Assert.DoesNotContain("TIMEOUT", diagnostics.Describe(ContradictoryDataset)!.UsableRuleTypes);
  280. Assert.Contains("OUT_OF_RANGE", diagnostics.Describe(OutOfRangeOnlyDataset)!.UsableRuleTypes);
  281. }
  282. [Fact]
  283. public void Diagnostics_ListsAllDefinedDatasets_RegardlessOfProvider()
  284. {
  285. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), EmptyRegistry());
  286. var all = diagnostics.List();
  287. Assert.Equal(5, all.Count);
  288. Assert.All(all, d => Assert.False(d.ProviderRegistered));
  289. }
  290. }