using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers; using Xunit; namespace Admin.NET.Test.S8; /// /// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 是 S8 第一个真实业务数据集, /// 本文件锁定它在**目录层**的契约。锁定项与失败后果: /// /// A. 数据集必须在 Catalog 中定义 → 否则规则连保存都过不了(dataset_not_defined), /// 而这个失败只在创建规则时才暴露,届时已在配置界面上。 /// B. TIMEOUT 能力必须机制层 + 字段层齐备 → 只写 SupportsTimeout 而漏 HasDueAt 的配置能存下来, /// 运行期表现为"永不命中"的静默失效,最难排查。 /// C. SHORTAGE / OUT_OF_RANGE 必须被拒 → remaining_qty 已证不可信(781/784 恒等于 order_qty); /// 若声明数量能力,Enable Gate 会放行 SHORTAGE 规则, /// 把不可信数据变成可启用能力。 /// D. 目录与 Provider 的能力必须同源 → 各写一份字面量会漂移,而漂移只在启用那一刻才报 /// dataset_capability_mismatch。 /// E. 重复 DatasetCode 必须抛异常 → Catalog 是 AddTransient,重复登记的失败形态是 /// "首次解析时 500",**不是启动崩溃**。启动日志干净 ≠ 目录健康。 /// /// 不接 DB、不接 DI、不触发任何真实规则。 /// public class S8PurchaseDeliveryDatasetTests { private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode; private static IS8DatasetCatalog Catalog() => new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }); private static AdoS8WatchRule StandardRule(string ruleType = "TIMEOUT") => new() { RuleCode = "UT_PURCHASE_DELIVERY", RuleType = ruleType, DataAccessMode = S8DataAccessMode.StandardDataset, DatasetCode = PurchaseDelivery }; // ============================================================ // A. 数据集定义 // ============================================================ [Fact] public void T1_Catalog_Defines_PurchaseDelivery() { var catalog = Catalog(); Assert.True(catalog.IsDefined(PurchaseDelivery)); var definition = catalog.Find(PurchaseDelivery); Assert.NotNull(definition); Assert.Equal(S8DatasetKind.Object, definition.Kind); Assert.Equal(S8DatasetStatus.Defined, definition.Status); Assert.True(definition.IsActive); // Description 必须写明 UAT 映射身份,避免后人把 due_at 当成生产业务合同。 Assert.Contains("UAT_EXECUTION_MAPPING", definition.Description); } [Fact] public void T1b_Catalog_DatasetCode_IsResolvedCaseInsensitively() { // Catalog 用 OrdinalIgnoreCase;规则里写小写不应变成"数据集不存在"。 Assert.True(Catalog().IsDefined("purchase_delivery")); } // ============================================================ // B/C. 能力声明:正反两面都必须覆盖 // ============================================================ [Fact] public void T3_Capabilities_Satisfy_Timeout() { var capabilities = Catalog().Find(PurchaseDelivery).Capabilities; Assert.True(capabilities.Supports("TIMEOUT")); Assert.Empty(capabilities.MissingFieldsFor("TIMEOUT")); Assert.True(capabilities.CanServe("TIMEOUT")); } [Fact] public void T3b_Capabilities_Reject_ShortageAndOutOfRange() { var capabilities = Catalog().Find(PurchaseDelivery).Capabilities; // 机制层就不支持,因此连字段层都不必看。 Assert.False(capabilities.Supports("SHORTAGE")); Assert.False(capabilities.Supports("OUT_OF_RANGE")); Assert.False(capabilities.CanServe("SHORTAGE")); Assert.False(capabilities.CanServe("OUT_OF_RANGE")); } [Fact] public void T3c_Capabilities_DoNotClaimQuantityFields() { // 物理表有 order_qty / remaining_qty,但 remaining_qty 已证不可信。 // Capability = "当前可可信用于机制判断",不是"表里有这一列"。 var capabilities = Catalog().Find(PurchaseDelivery).Capabilities; Assert.False(capabilities.HasTargetQty); Assert.False(capabilities.HasActualQty); Assert.False(capabilities.HasMeasuredValue); } [Fact] public void T3d_Definition_And_Provider_ShareSameCapabilityInstance() { // 同一实例而非同值:值相等只能证明"此刻没漂",同实例才能保证"不会漂"。 var definition = Catalog().Find(PurchaseDelivery); Assert.Same(S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities, definition.Capabilities); } // ============================================================ // E. 重复登记 // ============================================================ [Fact] public void DuplicateDatasetCode_ThrowsOnCatalogConstruction() { // 两个 source 给出同一个 code。注意:Catalog 注册为 AddTransient, // 因此现实中这会表现为"首次解析该服务的请求 500",而不是应用启动失败。 var ex = Assert.Throws(() => new S8DatasetCatalog( new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions(), new S8BusinessDatasetDefinitions() })); Assert.Contains(PurchaseDelivery, ex.Message); } [Fact] public void Provider_MustNotSelfRegisterViaITransient() { // 实测事故(Batch A 运行态复现):Provider 同时带 ITransient 与 Startup 显式注册时, // Furion 自动扫描会按接口再注册一份。Registry 以 IEnumerable // 注入,于是同一类型出现两次 → "数据集 PURCHASE_DELIVERY 注册了多个 Provider"。 // 因两者均为 Transient,应用启动毫无异常,直到第一个碰它的请求返回 500。 // 单元测试无法覆盖 DI 容器装配,因此在类型层面钉死这条约束。 Assert.DoesNotContain( typeof(S8PurchaseDeliveryDataProvider).GetInterfaces(), i => i.Name is "ITransient" or "IScoped" or "ISingleton"); } // ============================================================ // F. Enable Gate:目录 + 注册表联合判定 // ============================================================ [Fact] public void T2_Registry_Resolves_PurchaseDeliveryProvider() { var registry = new S8MonitoringDataProviderRegistry( new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() }); Assert.True(registry.IsRegistered(PurchaseDelivery)); // Gate ⑧ 会逐字比对 Provider 自报编码与目录定义,必须完全一致。 Assert.Equal(PurchaseDelivery, registry.Resolve(PurchaseDelivery).DatasetCode); } [Fact] public void EnableGate_Passes_WhenCatalogAndProviderBothPresent() { var gate = new S8DatasetEnableGate( Catalog(), new S8MonitoringDataProviderRegistry( new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() })); var result = gate.Check(StandardRule(), tenantId: 838257186181189, factoryId: 838257186320453); Assert.True(result.Ok); Assert.Null(result.ReasonCode); } [Fact] public void EnableGate_Rejects_WhenProviderNotRegistered() { // 数据集已定义但 Provider 未上线:允许存草稿,不允许启用。 var gate = new S8DatasetEnableGate( Catalog(), new S8MonitoringDataProviderRegistry(Array.Empty())); var result = gate.Check(StandardRule(), 838257186181189, 838257186320453); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode); } [Fact] public void EnableGate_Rejects_ShortageRule_WithCapabilityMismatch() { // 目录层就能判定的问题必须先于 Provider 层报出: // 报 provider_not_registered 会误导配置方以为"等实现就行",但这个数据集永远做不了 SHORTAGE。 var gate = new S8DatasetEnableGate( Catalog(), new S8MonitoringDataProviderRegistry( new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() })); var result = gate.Check(StandardRule("SHORTAGE"), 838257186181189, 838257186320453); Assert.False(result.Ok); Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode); Assert.NotEqual(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode); } [Fact] public void DraftSave_Allowed_ForStandardRule_ReferencingPurchaseDelivery() { // 不抛异常本身即断言。 S8WatchRuleDataAccessValidator.ValidateForSave(StandardRule(), Catalog()); } [Fact] public void DraftSave_Rejected_WhenDatasetCodeMisspelled() { var rule = StandardRule(); rule.DatasetCode = "PURCHASE_DELIVERY_TYPO"; var ex = Assert.Throws(() => S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog())); Assert.Contains(S8DatasetReasonCode.NotDefined, ex.Message); } [Fact] public void StandardDatasetRule_DoesNotRequireDataSource() { // dataSourceId = 0 之所以合法,靠的就是这一条;若回归成 true,创建规则会因"数据源不存在"失败。 Assert.False(S8WatchRuleDataAccessValidator.RequiresDataSource(StandardRule())); } /// /// 目录层测试用的最小 Provider:只回答编码与能力,不取数。 /// 取数与映射由 S8PurchaseDeliveryProviderMappingTests 覆盖。 /// private sealed class StubPurchaseDeliveryProvider : IS8MonitoringDataProvider { public string DatasetCode => S8BusinessDatasetDefinitions.PurchaseDeliveryCode; public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities; public Task LoadAsync( S8MonitoringDataRequest request, CancellationToken cancellationToken = default) { request.EnsureValid(); return Task.FromResult(S8MonitoringRowSet.Empty); } } }