S8DatasetCatalogAndGateTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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. DataAccessMode = S8DataAccessMode.StandardDataset,
  113. DatasetCode = datasetCode
  114. };
  115. // ============================================================
  116. // A. Catalog ≠ Provider Registry
  117. // ============================================================
  118. [Fact]
  119. public void Catalog_KnowsDataset_EvenWhenProviderNotRegistered()
  120. {
  121. var catalog = Catalog();
  122. Assert.True(catalog.IsDefined(DefinedWithoutProvider));
  123. Assert.Equal("测试用未实现数据集", catalog.Find(DefinedWithoutProvider)!.DisplayName);
  124. Assert.False(EmptyRegistry().IsRegistered(DefinedWithoutProvider));
  125. }
  126. [Fact]
  127. public void DraftSave_Allowed_WhenDatasetDefinedButProviderMissing()
  128. {
  129. // 允许业务先把规则配好,等 Provider 上线再启用。
  130. var rule = StandardRule(DefinedWithoutProvider);
  131. S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog());
  132. }
  133. [Fact]
  134. public void Enable_Rejected_WhenProviderMissing_WithProviderReasonCode()
  135. {
  136. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  137. var result = gate.Check(StandardRule(DefinedWithoutProvider), tenantId: 1, factoryId: 1);
  138. Assert.False(result.Ok);
  139. Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
  140. }
  141. // ============================================================
  142. // B. Dataset 不存在,必须与 provider missing 区分
  143. // ============================================================
  144. [Fact]
  145. public void UndefinedDataset_Reports_NotDefined_NotProviderMissing()
  146. {
  147. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  148. var result = gate.Check(StandardRule("TOTALLY_UNKNOWN_DATASET"), 1, 1);
  149. Assert.False(result.Ok);
  150. Assert.Equal(S8DatasetReasonCode.NotDefined, result.ReasonCode);
  151. Assert.NotEqual(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
  152. }
  153. [Fact]
  154. public void DraftSave_Rejected_ForUndefinedDataset()
  155. {
  156. var ex = Assert.Throws<S8BizException>(() =>
  157. S8WatchRuleDataAccessValidator.ValidateForSave(StandardRule("TOTALLY_UNKNOWN_DATASET"), Catalog()));
  158. Assert.Contains("TOTALLY_UNKNOWN_DATASET", ex.Message);
  159. }
  160. // ============================================================
  161. // C. Capability mismatch
  162. // ============================================================
  163. [Fact]
  164. public void Enable_Rejected_WhenDatasetDoesNotSupportRuleType()
  165. {
  166. var registry = new S8MonitoringDataProviderRegistry(Array.Empty<IS8MonitoringDataProvider>());
  167. var gate = new S8DatasetEnableGate(Catalog(), registry);
  168. // OUT_OF_RANGE-only 数据集挂 TIMEOUT 规则。
  169. var result = gate.Check(StandardRule(OutOfRangeOnlyDataset, "TIMEOUT"), 1, 1);
  170. Assert.False(result.Ok);
  171. Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode);
  172. }
  173. // ============================================================
  174. // D. 必需 canonical 字段缺失(单一布尔位不可信)
  175. // ============================================================
  176. [Fact]
  177. public void Enable_Rejected_WhenMechanismClaimedButRequiredFieldMissing()
  178. {
  179. // SupportsTimeout=true 但 HasDueAt=false:配置自相矛盾,必须识别。
  180. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  181. var result = gate.Check(StandardRule(ContradictoryDataset), 1, 1);
  182. Assert.False(result.Ok);
  183. Assert.Equal(S8DatasetReasonCode.MissingRequiredField, result.ReasonCode);
  184. Assert.Contains(S8CanonicalColumns.DueAt, result.Message);
  185. }
  186. [Fact]
  187. public void RuleTypeRequirements_MatchRealEvaluatorNeeds()
  188. {
  189. Assert.Equal(
  190. new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.DueAt, S8CanonicalColumns.Status },
  191. S8RuleTypeFieldRequirements.For("TIMEOUT"));
  192. Assert.Equal(
  193. new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.MeasuredValue },
  194. S8RuleTypeFieldRequirements.For("OUT_OF_RANGE"));
  195. Assert.Equal(
  196. new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.TargetQty, S8CanonicalColumns.ActualQty },
  197. S8RuleTypeFieldRequirements.For("SHORTAGE"));
  198. }
  199. // ============================================================
  200. // Disabled dataset
  201. // ============================================================
  202. [Fact]
  203. public void Enable_Rejected_WhenDatasetLifecycleDisabled()
  204. {
  205. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  206. var result = gate.Check(StandardRule(DefinedButDisabled), 1, 1);
  207. Assert.False(result.Ok);
  208. Assert.Equal(S8DatasetReasonCode.NotReady, result.ReasonCode);
  209. }
  210. // ============================================================
  211. // G. Tenant / Factory scope
  212. // ============================================================
  213. [Fact]
  214. public void Enable_Rejected_OnInvalidTenantScope()
  215. {
  216. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  217. var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 0, factoryId: 1);
  218. Assert.False(result.Ok);
  219. Assert.Equal(S8DatasetReasonCode.InvalidTenantScope, result.ReasonCode);
  220. }
  221. [Fact]
  222. public void Enable_Rejected_OnInvalidFactoryScope()
  223. {
  224. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  225. var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 1, factoryId: 0);
  226. Assert.False(result.Ok);
  227. Assert.Equal(S8DatasetReasonCode.InvalidFactoryScope, result.ReasonCode);
  228. }
  229. [Fact]
  230. public void Enable_Passes_WhenEverythingSatisfied()
  231. {
  232. var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
  233. var result = gate.Check(StandardRule(DefinedWithProvider), 797403760988229, 1);
  234. Assert.True(result.Ok);
  235. Assert.Null(result.ReasonCode);
  236. }
  237. // ============================================================
  238. // F. Legacy 规则不经过 Dataset Gate
  239. // ============================================================
  240. [Fact]
  241. public void LegacyRule_NotSubjectToDatasetGate()
  242. {
  243. var legacy = new AdoS8WatchRule
  244. {
  245. RuleCode = "LEGACY",
  246. RuleType = "TIMEOUT",
  247. DataAccessMode = null,
  248. DatasetCode = null,
  249. Expression = "SELECT 1"
  250. };
  251. var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
  252. Assert.True(gate.Check(legacy, 1, 1).Ok);
  253. S8WatchRuleDataAccessValidator.ValidateForSave(legacy, Catalog());
  254. }
  255. // ============================================================
  256. // 诊断能力
  257. // ============================================================
  258. [Fact]
  259. public void Diagnostics_DistinguishesReadyFromProviderMissing()
  260. {
  261. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
  262. Assert.Equal(S8DatasetStatus.Ready, diagnostics.Describe(DefinedWithProvider)!.EffectiveStatus);
  263. Assert.Equal(S8DatasetStatus.ProviderMissing, diagnostics.Describe(DefinedWithoutProvider)!.EffectiveStatus);
  264. Assert.Equal(S8DatasetStatus.Disabled, diagnostics.Describe(DefinedButDisabled)!.EffectiveStatus);
  265. Assert.Null(diagnostics.Describe("TOTALLY_UNKNOWN_DATASET"));
  266. }
  267. [Fact]
  268. public void Diagnostics_ReportsUsableRuleTypes_ExcludingContradictoryOnes()
  269. {
  270. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider());
  271. Assert.Contains("TIMEOUT", diagnostics.Describe(DefinedWithProvider)!.UsableRuleTypes);
  272. // 声明支持 TIMEOUT 但缺 due_at:不得出现在可用列表里。
  273. Assert.DoesNotContain("TIMEOUT", diagnostics.Describe(ContradictoryDataset)!.UsableRuleTypes);
  274. Assert.Contains("OUT_OF_RANGE", diagnostics.Describe(OutOfRangeOnlyDataset)!.UsableRuleTypes);
  275. }
  276. [Fact]
  277. public void Diagnostics_ListsAllDefinedDatasets_RegardlessOfProvider()
  278. {
  279. var diagnostics = new S8DatasetDiagnosticsService(Catalog(), EmptyRegistry());
  280. var all = diagnostics.List();
  281. Assert.Equal(5, all.Count);
  282. Assert.All(all, d => Assert.False(d.ProviderRegistered));
  283. }
  284. }