using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess; using Xunit; namespace Admin.NET.Test.S8; /// /// S8-DATASET-FOUNDATION-HARDENING-2:Dataset Catalog 与 Provider Registry 解耦 + 统一 Enable Gate。 /// /// 本批要锁定的核心区分: /// · Dataset 根本不存在 → dataset_not_defined /// · Dataset 已定义但无 Provider → dataset_provider_not_registered(草稿可存,不可启用) /// · Dataset 声明支持某机制但缺必需字段 → dataset_missing_required_field(配置自相矛盾) /// · STANDARD_DATASET 取数失败绝不回落 Legacy SQL /// public class S8DatasetCatalogAndGateTests { private const string DefinedWithProvider = "FAKE_TEST_DATASET_READY"; private const string DefinedWithoutProvider = "FAKE_TEST_DATASET_NO_PROVIDER"; private const string DefinedButDisabled = "FAKE_TEST_DATASET_DISABLED"; private const string ContradictoryDataset = "FAKE_TEST_DATASET_CONTRADICTORY"; private const string OutOfRangeOnlyDataset = "FAKE_TEST_DATASET_OOR_ONLY"; private static S8DatasetCapabilities TimeoutCapable() => new() { SupportsTimeout = true, HasSourceObjectId = true, HasRelatedObjectCode = true, HasDueAt = true, HasStatus = true }; private sealed class FakeDefinitionSource : IS8DatasetDefinitionSource { public IEnumerable GetDefinitions() => new[] { new S8DatasetDefinition { DatasetCode = DefinedWithProvider, DisplayName = "测试用就绪数据集", Kind = S8DatasetKind.Object, Capabilities = TimeoutCapable() }, new S8DatasetDefinition { DatasetCode = DefinedWithoutProvider, DisplayName = "测试用未实现数据集", Kind = S8DatasetKind.Object, Capabilities = TimeoutCapable() }, new S8DatasetDefinition { DatasetCode = DefinedButDisabled, DisplayName = "测试用停用数据集", Kind = S8DatasetKind.Metric, Capabilities = TimeoutCapable(), Status = S8DatasetStatus.Disabled }, new S8DatasetDefinition { // 自相矛盾:声明支持 TIMEOUT,却没有 due_at 能力。 DatasetCode = ContradictoryDataset, DisplayName = "测试用矛盾数据集", Kind = S8DatasetKind.Object, Capabilities = new S8DatasetCapabilities { SupportsTimeout = true, HasSourceObjectId = true, HasRelatedObjectCode = true, HasDueAt = false, HasStatus = true } }, new S8DatasetDefinition { DatasetCode = OutOfRangeOnlyDataset, DisplayName = "测试用仅超差数据集", Kind = S8DatasetKind.Metric, Capabilities = new S8DatasetCapabilities { SupportsOutOfRange = true, HasSourceObjectId = true, HasRelatedObjectCode = true, HasMeasuredValue = true } } }; } private sealed class ReadyProvider : IS8MonitoringDataProvider { public string DatasetCode => DefinedWithProvider; public S8DatasetCapabilities Capabilities => TimeoutCapable(); public Task LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default) { request.EnsureValid(); return Task.FromResult(S8MonitoringRowSet.Empty); } } private sealed class ThrowingProvider : IS8MonitoringDataProvider { public string DatasetCode => DefinedWithProvider; public S8DatasetCapabilities Capabilities => TimeoutCapable(); public Task LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default) => throw new InvalidOperationException("provider blew up"); } private static S8DatasetCatalog Catalog() => new(new[] { new FakeDefinitionSource() }); private static S8MonitoringDataProviderRegistry RegistryWithProvider() => new(new[] { new ReadyProvider() }); private static S8MonitoringDataProviderRegistry EmptyRegistry() => new(Array.Empty()); private static AdoS8WatchRule StandardRule(string datasetCode, string ruleType = "TIMEOUT") => new() { RuleCode = "UT_RULE", RuleType = ruleType, DataAccessMode = S8DataAccessMode.StandardDataset, DatasetCode = datasetCode }; // ============================================================ // A. Catalog ≠ Provider Registry // ============================================================ [Fact] public void Catalog_KnowsDataset_EvenWhenProviderNotRegistered() { var catalog = Catalog(); Assert.True(catalog.IsDefined(DefinedWithoutProvider)); Assert.Equal("测试用未实现数据集", catalog.Find(DefinedWithoutProvider)!.DisplayName); Assert.False(EmptyRegistry().IsRegistered(DefinedWithoutProvider)); } [Fact] public void DraftSave_Allowed_WhenDatasetDefinedButProviderMissing() { // 允许业务先把规则配好,等 Provider 上线再启用。 var rule = StandardRule(DefinedWithoutProvider); S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog()); } [Fact] public void Enable_Rejected_WhenProviderMissing_WithProviderReasonCode() { var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry()); var result = gate.Check(StandardRule(DefinedWithoutProvider), tenantId: 1, factoryId: 1); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode); } // ============================================================ // B. Dataset 不存在,必须与 provider missing 区分 // ============================================================ [Fact] public void UndefinedDataset_Reports_NotDefined_NotProviderMissing() { var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry()); var result = gate.Check(StandardRule("TOTALLY_UNKNOWN_DATASET"), 1, 1); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.NotDefined, result.ReasonCode); Assert.NotEqual(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode); } [Fact] public void DraftSave_Rejected_ForUndefinedDataset() { var ex = Assert.Throws(() => S8WatchRuleDataAccessValidator.ValidateForSave(StandardRule("TOTALLY_UNKNOWN_DATASET"), Catalog())); Assert.Contains("TOTALLY_UNKNOWN_DATASET", ex.Message); } // ============================================================ // C. Capability mismatch // ============================================================ [Fact] public void Enable_Rejected_WhenDatasetDoesNotSupportRuleType() { var registry = new S8MonitoringDataProviderRegistry(Array.Empty()); var gate = new S8DatasetEnableGate(Catalog(), registry); // OUT_OF_RANGE-only 数据集挂 TIMEOUT 规则。 var result = gate.Check(StandardRule(OutOfRangeOnlyDataset, "TIMEOUT"), 1, 1); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode); } // ============================================================ // D. 必需 canonical 字段缺失(单一布尔位不可信) // ============================================================ [Fact] public void Enable_Rejected_WhenMechanismClaimedButRequiredFieldMissing() { // SupportsTimeout=true 但 HasDueAt=false:配置自相矛盾,必须识别。 var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry()); var result = gate.Check(StandardRule(ContradictoryDataset), 1, 1); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.MissingRequiredField, result.ReasonCode); Assert.Contains(S8CanonicalColumns.DueAt, result.Message); } [Fact] public void RuleTypeRequirements_MatchRealEvaluatorNeeds() { Assert.Equal( new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.DueAt, S8CanonicalColumns.Status }, S8RuleTypeFieldRequirements.For("TIMEOUT")); Assert.Equal( new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.MeasuredValue }, S8RuleTypeFieldRequirements.For("OUT_OF_RANGE")); Assert.Equal( new[] { S8CanonicalColumns.SourceObjectId, S8CanonicalColumns.RelatedObjectCode, S8CanonicalColumns.TargetQty, S8CanonicalColumns.ActualQty }, S8RuleTypeFieldRequirements.For("SHORTAGE")); } // ============================================================ // Disabled dataset // ============================================================ [Fact] public void Enable_Rejected_WhenDatasetLifecycleDisabled() { var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider()); var result = gate.Check(StandardRule(DefinedButDisabled), 1, 1); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.NotReady, result.ReasonCode); } // ============================================================ // G. Tenant / Factory scope // ============================================================ [Fact] public void Enable_Rejected_OnInvalidTenantScope() { var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider()); var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 0, factoryId: 1); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.InvalidTenantScope, result.ReasonCode); } [Fact] public void Enable_Rejected_OnInvalidFactoryScope() { var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider()); var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 1, factoryId: 0); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.InvalidFactoryScope, result.ReasonCode); } [Fact] public void Enable_Passes_WhenEverythingSatisfied() { var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider()); var result = gate.Check(StandardRule(DefinedWithProvider), 797403760988229, 1); Assert.True(result.Ok); Assert.Null(result.ReasonCode); } // ============================================================ // F. Legacy 规则不经过 Dataset Gate // ============================================================ [Fact] public void LegacyRule_NotSubjectToDatasetGate() { var legacy = new AdoS8WatchRule { RuleCode = "LEGACY", RuleType = "TIMEOUT", DataAccessMode = null, DatasetCode = null, Expression = "SELECT 1" }; var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry()); Assert.True(gate.Check(legacy, 1, 1).Ok); S8WatchRuleDataAccessValidator.ValidateForSave(legacy, Catalog()); } // ============================================================ // 诊断能力 // ============================================================ [Fact] public void Diagnostics_DistinguishesReadyFromProviderMissing() { var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider()); Assert.Equal(S8DatasetStatus.Ready, diagnostics.Describe(DefinedWithProvider)!.EffectiveStatus); Assert.Equal(S8DatasetStatus.ProviderMissing, diagnostics.Describe(DefinedWithoutProvider)!.EffectiveStatus); Assert.Equal(S8DatasetStatus.Disabled, diagnostics.Describe(DefinedButDisabled)!.EffectiveStatus); Assert.Null(diagnostics.Describe("TOTALLY_UNKNOWN_DATASET")); } [Fact] public void Diagnostics_ReportsUsableRuleTypes_ExcludingContradictoryOnes() { var diagnostics = new S8DatasetDiagnosticsService(Catalog(), RegistryWithProvider()); Assert.Contains("TIMEOUT", diagnostics.Describe(DefinedWithProvider)!.UsableRuleTypes); // 声明支持 TIMEOUT 但缺 due_at:不得出现在可用列表里。 Assert.DoesNotContain("TIMEOUT", diagnostics.Describe(ContradictoryDataset)!.UsableRuleTypes); Assert.Contains("OUT_OF_RANGE", diagnostics.Describe(OutOfRangeOnlyDataset)!.UsableRuleTypes); } [Fact] public void Diagnostics_ListsAllDefinedDatasets_RegardlessOfProvider() { var diagnostics = new S8DatasetDiagnosticsService(Catalog(), EmptyRegistry()); var all = diagnostics.List(); Assert.Equal(5, all.Count); Assert.All(all, d => Assert.False(d.ProviderRegistered)); } }