|
|
@@ -0,0 +1,328 @@
|
|
|
+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;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// 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
|
|
|
+/// </summary>
|
|
|
+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<S8DatasetDefinition> 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<S8MonitoringRowSet> 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<S8MonitoringRowSet> 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<IS8MonitoringDataProvider>());
|
|
|
+
|
|
|
+ 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<S8BizException>(() =>
|
|
|
+ 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<IS8MonitoringDataProvider>());
|
|
|
+ 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));
|
|
|
+ }
|
|
|
+}
|