using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Xunit; namespace Admin.NET.Test.S8; /// /// TIMEOUT evaluator 纯逻辑回归:运行参数解析 + dedup_key 生成稳定性。 /// 不接 DB / 不接 DI。 /// /// S8-RULE-GOVERNANCE-BATCH1 的变化:本文件原先覆盖的是 /// S8TimeoutParams.Parse —— 那个类同时解析判定语义(dueAtField / statusField / /// completedStates / objectIdField / exceptionTypeCode)与运行参数(graceMinutes)。 /// 判定语义已迁入代码定义,对它的断言移至 S8RuleDefinitionContractTests; /// 本文件改为覆盖剩下的那一半: /// /// dedup_key 相关断言**逐字保留** —— 那部分语义本批未改。 /// public class S8TimeoutEvaluatorTests { private static S8RuleDefinition Rule01Definition() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }) .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode); private static AdoS8WatchRule Rule(string? paramsJson, string severity = "SERIOUS") => new() { RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode, Severity = severity, PollIntervalSeconds = 300, TriggerCountRequired = 1, RecoverCountRequired = 2, ParamsJson = paramsJson }; // ───────────────── 运行参数解析(原 params 解析用例的继承者) ───────────────── [Fact] public void RuntimeParameters_Parse_FullRuntimeSpec() { var json = """ { "graceMinutes": 5, "defaultOccurrenceDeptId": 245, "defaultResponsibleDeptId": 246 } """; var p = S8RuleRuntimeParameters.Resolve(Rule(json), Rule01Definition()); Assert.Equal(5, p.GraceMinutes); Assert.Equal(245L, p.DefaultOccurrenceDeptId); Assert.Equal(246L, p.DefaultResponsibleDeptId); Assert.Equal(300, p.PollIntervalSeconds); Assert.Equal(1, p.TriggerCountRequired); Assert.Equal(2, p.RecoverCountRequired); Assert.Equal(S8SeverityCode.Serious, p.Severity); } [Fact] public void RuntimeParameters_Tolerates_MissingOptionalFields() { var p = S8RuleRuntimeParameters.Resolve(Rule("""{ }"""), Rule01Definition()); Assert.Equal(0, p.GraceMinutes); // 定义声明的默认值 Assert.Null(p.DefaultOccurrenceDeptId); Assert.Null(p.DefaultResponsibleDeptId); } /// /// 关键回归:旧 A 类字段读到也必须忽略。 /// 这是「用户不能通过 params_json 改判定口径」在参数解析这一层的守卫; /// 判定结果层面的守卫在 S8RuleDefinitionContractTests.T5_*。 /// [Fact] public void RuntimeParameters_Ignores_LegacyDefinitionFields() { var json = """ { "dueAtField": "evil_field", "statusField": "evil_status", "completedStates": ["OPEN"], "objectIdField": "evil_id", "exceptionTypeCode": "EVIL", "graceMinutes": 7 } """; var p = S8RuleRuntimeParameters.Resolve(Rule(json), Rule01Definition()); // B 类照常生效 Assert.Equal(7, p.GraceMinutes); // A 类不会以任何形式出现在回写的 params_json 里 var written = p.ToParamsJson(); Assert.DoesNotContain("evil", written, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("completedStates", written, StringComparison.Ordinal); } [Fact] public void RuntimeParameters_DoesNotThrow_OnInvalidJson() { // 旧实现在这里抛 JsonException,由 evaluator 捕获后把整条规则标 params_parse_failed。 // 现在语义在代码里,一份读不懂的运行参数不该让规则停摆 —— 回落默认值即可。 var p = S8RuleRuntimeParameters.Resolve(Rule("not-a-json"), Rule01Definition()); Assert.Equal(0, p.GraceMinutes); } /// /// severity 非法值必须回落**定义声明的默认值**,而不是被 S8SeverityCode.Normalize /// 静默降级成 FOLLOW —— 库里 severity='HIGH' 的那一行当年正是被那条兜底吞进来的。 /// [Theory] [InlineData("HIGH")] [InlineData("LOW")] [InlineData("")] [InlineData(null)] public void RuntimeParameters_InvalidSeverity_FallsBackToDefinitionDefault(string? severity) { var p = S8RuleRuntimeParameters.Resolve(Rule("{}", severity!), Rule01Definition()); Assert.Equal(S8SeverityCode.Serious, p.Severity); // Rule 01 的定义默认值 } [Theory] [InlineData(S8SeverityCode.Follow)] [InlineData(S8SeverityCode.Serious)] public void RuntimeParameters_ValidSeverity_IsHonoured(string severity) { var p = S8RuleRuntimeParameters.Resolve(Rule("{}", severity), Rule01Definition()); Assert.Equal(severity, p.Severity); } [Fact] public void RuntimeParameters_OutOfRangeColumnValues_FallBackToDefaults() { var rule = Rule("{}"); rule.PollIntervalSeconds = 5; // < 60,越界 rule.TriggerCountRequired = 99; // > 10,越界 var p = S8RuleRuntimeParameters.Resolve(rule, Rule01Definition()); Assert.Equal(300, p.PollIntervalSeconds); Assert.Equal(1, p.TriggerCountRequired); } // ───────────────── dedup_key(S8-TENANT-ONLY-BATCH5 后的身份口径) ───────────────── [Fact] public void BuildDedupKey_IsStable_SameInputsSameOutput() { var k1 = S8TimeoutRuleEvaluator.BuildDedupKey(1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01"); var k2 = S8TimeoutRuleEvaluator.BuildDedupKey(1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01"); Assert.Equal(k1, k2); Assert.Equal("T1:RG01_TEST_TIMEOUT:ORDER:ORDER-TIMEOUT-01", k1); } /// /// 去重身份的四个维度:租户 / 规则 / 对象类型 / 对象 ID。 /// /// 原用例名为 ..._OnTenantFactoryRuleObject,其中一条断言是 /// 「同租户、不同工厂 → 不同 key」。那条断言现在必须消失,而且不能靠删掉了事: /// 工厂段一旦回到 key 里,同一个业务对象会在不同工厂元数据下被当成两个异常, /// 而历史 key 与新 key 也会对不上,调度器会一边把旧异常当作"已恢复"、 /// 一边按新 key 重复建单。故此处改为**正面锁定四维身份 + 反向禁止工厂段**。 /// [Fact] public void BuildDedupKey_Differentiates_OnTenantRuleObject_NotOnFactory() { var baseKey = S8TimeoutRuleEvaluator.BuildDedupKey(1, "R", "ORDER", "ID"); // 四个身份维度各自区分。 Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(2, "R", "ORDER", "ID")); Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, "R2", "ORDER", "ID")); Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, "R", "TASK", "ID")); Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, "R", "ORDER", "ID2")); // 工厂不再是身份的一部分:签名里根本没有该形参,key 里也不得出现 :F 段。 foreach (var m in typeof(S8TimeoutRuleEvaluator) .GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public) .Where(x => x.Name == "BuildDedupKey")) { Assert.DoesNotContain(m.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)); } Assert.DoesNotMatch(@":F\d", baseKey); } /// /// 同一业务对象在同一租户内只有一个身份 —— 这正是「工厂元数据不参与去重」的可观测后果。 /// 两次调用之间没有任何工厂输入可供改变,故必然相等。 /// [Fact] public void BuildDedupKey_SameTenantSameObject_IsSingleIdentity() { Assert.Equal( S8TimeoutRuleEvaluator.BuildDedupKey(838257186181189, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1"), S8TimeoutRuleEvaluator.BuildDedupKey(838257186181189, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1")); } /// 不同租户下的同一来源对象必须是两个身份,杜绝跨租户去重合并。 [Fact] public void BuildDedupKey_DifferentTenantSameSource_AreDistinct() { Assert.NotEqual( S8TimeoutRuleEvaluator.BuildDedupKey(838257186181189, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1"), S8TimeoutRuleEvaluator.BuildDedupKey(797403760988229, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1")); } [Fact] public void BuildDedupKey_Length_FitsVarchar128() { // 实体定义 dedup_key VARCHAR(128)。本断言锁定常规标识符不溢出列长度。 var k = S8TimeoutRuleEvaluator.BuildDedupKey(9999999, "VERY_LONG_RULE_CODE_FOR_PADDING_64____", "EXTREMELY_LONG_OBJECT_TYPE______", "EXTREMELY_LONG_OBJECT_ID________"); Assert.True(k.Length <= 128, $"dedup_key length {k.Length} exceeds 128: {k}"); } }