using System.Reflection; using System.Text.Json; using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-RULE-READINESS-1:启用就绪门禁。 /// /// 要消灭的失败形态:Rule 01 的数据集 dwd_supplier_delivery 没有部门列, /// 命中行带不出 occurrence / responsible,建单时部门只能来自运行参数。未配置时的实际表现是 /// tick_rule_done status=SUCCESS hits=28 created=0 failed=1 —— /// 规则显示「已启用 / 最近结果:成功」,异常一条都没有,页面上看不出任何原因。 /// 每一层都报成功,正是最难被发现的那类缺陷。 /// /// 本文件守两件事:① 三条真实执行入口(Enable / RunNow / Scheduler) /// 共用同一门禁,不得有旁路;② 该要求是逐规则声明的,不是 S8 全局规则 —— /// 未声明的规则必须完全不受影响。 /// public class S8RuleEnableReadinessTests { private const long TenantA = 838257186181189L; private const long TenantB = 838257212780613L; private const long DeptInA = 245L; private const long DeptInB = 252L; private static readonly string Rule01Code = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode; private static S8RuleDefinition Rule01() => new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }) .GetRequired(Rule01Code); /// 不声明部门必需的规则(代表"数据集自带部门"的那一类)。 private static S8RuleDefinition RuleWithoutDepartmentRequirement() => new() { RuleCode = "UT_RULE_NO_DEPT_REQUIRED", DisplayName = "单测规则", DatasetCode = "UT_DATASET", RuleType = "TIMEOUT", SourceObjectType = "ORDER", SceneCode = "S4", ExceptionTypeCode = "UT_TYPE", Timeout = new S8TimeoutSemantics { CompletedStates = new[] { "COMPLETED" } }, Parameters = new S8RuleParameterPolicy() // RequiresDepartmentDefaultsForEnable 默认 false }; private static AdoS8WatchRule Row(long tenantId, long? occ, long? resp) => new() { Id = 1, TenantId = tenantId, FactoryId = 0, RuleCode = Rule01Code, Enabled = false, PollIntervalSeconds = 300, TriggerCountRequired = 1, RecoverCountRequired = 2, Severity = "SERIOUS", ParamsJson = BuildParams(occ, resp) }; private static string BuildParams(long? occ, long? resp) { var payload = new Dictionary { ["graceMinutes"] = 0 }; if (occ.HasValue) payload["defaultOccurrenceDeptId"] = occ.Value; if (resp.HasValue) payload["defaultResponsibleDeptId"] = resp.Value; return JsonSerializer.Serialize(payload); } /// /// 用假的部门校验器驱动门禁:真实实现要连库,而本文件要验的是门禁的判定逻辑。 /// 「部门属于哪个租户」的真实判据由 独占, /// 其口径由 S8TenantOnlyExceptionScopeTests 的源码扫描守(tenant_id + IsActive,无 factory)。 /// private sealed class FakeDeptValidator : IS8DepartmentScopeValidator { private readonly Dictionary _deptTenant; public FakeDeptValidator(Dictionary deptTenant) => _deptTenant = deptTenant; public Task ExistsInTenantAsync(long? deptId, long tenantId) => Task.FromResult(deptId.HasValue && _deptTenant.TryGetValue(deptId.Value, out var t) && t == tenantId); } /// /// S8-HANDLER-POOL-1:责任池替身。 /// 为该租户下**当前有效**的池成员数; /// 默认给 1,让既有的部门用例继续只考察部门那一维 —— 一个测试同时压两个门禁, /// 失败时无法分辨是哪一维出的问题。 /// private sealed class FakePool : IS8RuleResponsibilityReader { private readonly int _valid; private readonly int _total; private readonly Dictionary _perType; /// 默认各类责任池的有效成员数。 /// 默认各类责任池的成员总数。 /// /// 按责任类型覆盖。只覆盖被考察的那一维,其余维保持默认的"有人" —— /// 一个测试同时压两个门禁,失败时无法分辨是哪一维出的问题。 /// public FakePool(int valid, int total, Dictionary? perType = null) { _valid = valid; _total = total; _perType = perType?.ToDictionary(k => k.Key, v => (v.Value.Item1, v.Value.Item2), StringComparer.OrdinalIgnoreCase) ?? new(StringComparer.OrdinalIgnoreCase); } private (int Valid, int Total) For(string type) => _perType.TryGetValue(type, out var v) ? v : (_valid, _total); public Task> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType) { var (valid, total) = For(responsibilityType); return Task.FromResult(Enumerable.Range(0, total) .Select(i => new S8HandlerUserDto { UserId = 1000 + i, Valid = i < valid }) .ToList()); } public Task> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) => Task.FromResult((IReadOnlyList)Enumerable.Range(0, For(responsibilityType).Total) .Select(i => (long)(1000 + i)).ToList()); public Task> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) => Task.FromResult((IReadOnlyList)Enumerable.Range(0, For(responsibilityType).Valid) .Select(i => (long)(1000 + i)).ToList()); public Task IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId) => Task.FromResult(For(responsibilityType).Valid > 0); } /// 门禁实例:DeptInA 属租户 A,DeptInB 属租户 B;三类责任池默认各有 1 名有效成员。 private static S8RuleReadinessGate Gate(int validMembers = 1, int totalMembers = 1, Dictionary? perType = null) => new(new FakeDeptValidator(new Dictionary { [DeptInA] = TenantA, [DeptInB] = TenantB }), new FakePool(validMembers, totalMembers, perType)); private static Task Check(long tenantId, long? occ, long? resp, S8RuleDefinition? def = null) { var definition = def ?? Rule01(); return Gate().CheckAsync(S8EffectiveRule.Resolve(Row(tenantId, occ, resp), definition), tenantId); } // ───────────────── T1–T6:就绪判定 ───────────────── /// T1:两个部门都为空 → 拒绝,且指名缺的是"发生部门"。 [Fact] public async Task T1_BothDepartmentsMissing_IsRejected() { var r = await Check(TenantA, null, null); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.RequiredParameterMissing, r.ReasonCode); Assert.Contains("默认发生部门", r.Message); } /// T2:只配了发生部门 → 仍拒绝,且指名缺的是"责任部门"(不能笼统报"参数缺失")。 [Fact] public async Task T2_OnlyOccurrence_IsRejected() { var r = await Check(TenantA, DeptInA, null); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.RequiredParameterMissing, r.ReasonCode); Assert.Contains("默认责任部门", r.Message); } /// T3:只配了责任部门 → 拒绝。 [Fact] public async Task T3_OnlyResponsible_IsRejected() { var r = await Check(TenantA, null, DeptInA); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.RequiredParameterMissing, r.ReasonCode); Assert.Contains("默认发生部门", r.Message); } /// T4:两者齐备且属本租户 → 放行。 [Fact] public async Task T4_BothPresentAndInTenant_Passes() { var r = await Check(TenantA, DeptInA, DeptInA); Assert.True(r.Ok); Assert.Null(r.ReasonCode); } /// T5:发生部门属别的租户 → 拒绝,理由是"不属本租户"而非"缺失"。 [Fact] public async Task T5_OccurrenceFromOtherTenant_IsRejected() { var r = await Check(TenantA, DeptInB, DeptInA); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.DepartmentNotInTenant, r.ReasonCode); Assert.Contains("默认发生部门", r.Message); } /// T6:责任部门属别的租户 → 拒绝。 [Fact] public async Task T6_ResponsibleFromOtherTenant_IsRejected() { var r = await Check(TenantA, DeptInA, DeptInB); Assert.False(r.Ok); Assert.Equal(S8RuleReadinessReasonCode.DepartmentNotInTenant, r.ReasonCode); Assert.Contains("默认责任部门", r.Message); } /// /// T7:工厂元数据不参与部门有效性。 /// 同一条规则换任意 factory_id,判定结果必须逐字相同 —— 工厂在真库里横跨租户 /// (factory_ref_id = 1000 同属 824585161322565 与 797403760988229),一旦参与判定就是跨租户放行。 /// [Fact] public async Task T7_FactoryMetadata_DoesNotAffectDepartmentValidity() { var gate = Gate(); var def = Rule01(); var rowFactoryZero = Row(TenantA, DeptInA, DeptInA); rowFactoryZero.FactoryId = 0; var rowFactoryOther = Row(TenantA, DeptInA, DeptInA); rowFactoryOther.FactoryId = 999_999_999L; var a = await gate.CheckAsync(S8EffectiveRule.Resolve(rowFactoryZero, def), TenantA); var b = await gate.CheckAsync(S8EffectiveRule.Resolve(rowFactoryOther, def), TenantA); Assert.True(a.Ok); Assert.True(b.Ok); // 门禁签名里不得出现工厂形参。 foreach (var m in typeof(S8RuleReadinessGate).GetMethods(BindingFlags.Instance | BindingFlags.Public) .Where(x => x.Name is "CheckAsync" or "EnsureReadyAsync")) { Assert.DoesNotContain(m.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)); } } // ───────────────── T8–T9:Preview 与 RunNow 的分野 ───────────────── /// /// T8:Preview 不受门禁约束。 /// /// 预演只做只读试算、不建任何异常,部门缺失不影响它的正确性; /// 反过来说,管理员本来就该「先预演看命中,再配部门,再启用」。 /// 若把门禁接到 Preview 上,等于逼人在看不到命中的情况下先猜配置。 /// [Fact] public void T8_PreviewPath_DoesNotConsultReadinessGate() { var previewSource = CodeOnly("Service/S8/S8WatchRulePreviewService.cs"); Assert.DoesNotContain("S8RuleReadinessGate", previewSource); Assert.DoesNotContain("EnsureReadyAsync", previewSource); } /// /// T9:RunNow 会真实建单,必须复核与 Enable 同一门禁。 /// 只在 Enable 处挡是不够的:规则可能启用后部门被停用 / 删除 / 改到别的租户。 /// [Fact] public void T9_RunNowPath_ConsultsReadinessGate() { var code = CodeOnly("Service/S8/S8WatchRuleService.cs"); var runNowBody = Slice(code, "public async Task RunNowAsync", "public async Task PauseAsync"); Assert.Contains("_readinessGate.EnsureReadyAsync", runNowBody); } // ───────────────── T10–T11:供给与作用范围 ───────────────── /// T10:供给出来的 Rule01 不猜部门、不自动启用。 [Fact] public void T10_Provisioning_LeavesDepartmentsUnsetAndDisabled() { var code = CodeOnly("Service/S8/S8RuleProvisioningService.cs"); Assert.Contains("Enabled = false", code); // 供给不得写入任何默认部门 —— "猜一个部门"比"拒绝启用"糟得多: // 异常会带着错误的责任部门流转,且没人会发现。 Assert.DoesNotContain("DefaultOccurrenceDeptId", code); Assert.DoesNotContain("DefaultResponsibleDeptId", code); Assert.DoesNotContain("defaultOccurrenceDeptId", code); } /// /// T11:未声明该要求的规则完全不受影响。 /// 这条防的是"顺手把要求提成全局"——数据集自带部门的规则会被一起误伤。 /// [Fact] public async Task T11_RuleWithoutRequirement_IsNotAffected() { var def = RuleWithoutDepartmentRequirement(); Assert.False(def.Parameters!.RequiresDepartmentDefaultsForEnable); var r = await Check(TenantA, null, null, def); Assert.True(r.Ok); Assert.Null(r.ReasonCode); } // ───────────────── T12–T13:Scheduler 与三路径同源 ───────────────── /// /// T12:已启用规则的部门事后失效 → Scheduler 在 evaluator 之前 fail-fast。 /// /// 判定层面:部门被改到别的租户后,门禁必须返回 not-ok(即 Scheduler 会拿到 false)。 /// 接线层面:该检查必须位于 ProcessSingleRuleAsync 调用之前, /// 且失败时 return 而不是继续 —— 否则就变成"先建单失败再记录", /// 正是本轮要消灭的 create_failed。同时不得在此自动停用规则。 /// [Fact] public async Task T12_SchedulerFailsFast_WhenDepartmentBecomesInvalid() { // ① 判定:启用时合法的配置,在部门被移出本租户后必须变为不就绪。 var stillValid = await Check(TenantA, DeptInA, DeptInA); Assert.True(stillValid.Ok); var nowInvalid = await Check(TenantA, DeptInB, DeptInB); // 部门已属别的租户 Assert.False(nowInvalid.Ok); Assert.Equal(S8RuleReadinessReasonCode.DepartmentNotInTenant, nowInvalid.ReasonCode); // ② 接线:readiness 检查必须早于 evaluator 分派与 ProcessSingleRuleAsync。 var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs"); var body = Slice(code, "public async Task RunSingleRuleAsync", "private async Task> ProcessSingleRuleAsync"); var gateAt = body.IndexOf("_readinessGate.CheckAsync", StringComparison.Ordinal); var evaluatorAt = body.IndexOf("IS8RuleEvaluator? evaluator", StringComparison.Ordinal); var processAt = body.IndexOf("ProcessSingleRuleAsync(", StringComparison.Ordinal); Assert.True(gateAt >= 0, "Scheduler 未接入 readiness 门禁"); Assert.True(evaluatorAt > gateAt, "readiness 检查必须早于 evaluator 分派"); Assert.True(processAt > gateAt, "readiness 检查必须早于 ProcessSingleRuleAsync"); // 失败即 return,不落到下游。 var afterGate = body[gateAt..]; var returnAt = afterGate.IndexOf("return new S8RuleRunResult", StringComparison.Ordinal); Assert.True(returnAt >= 0 && returnAt < afterGate.IndexOf("ProcessSingleRuleAsync(", StringComparison.Ordinal), "readiness 失败必须直接返回失败结果,不得继续执行"); // 不自动停用:门禁失败路径里不得出现 Enabled = false。 var failureBranch = afterGate[..afterGate.IndexOf("ProcessSingleRuleAsync(", StringComparison.Ordinal)]; Assert.DoesNotContain("Enabled = false", failureBranch); } /// /// T13:Enable / RunNow / Scheduler 三条入口共用同一 readiness policy。 /// /// 防的是未来分叉:任何一条自己写一套"要不要部门"的判断,就会出现 /// 「启用被挡但 RunNow 能跑」或「启用通过但调度器拒绝」这类互相矛盾的结论。 /// 因此三处都必须走 ,且都不得自行解析 params_json。 /// [Fact] public void T13_ThreeExecutionEntryPoints_ShareOneReadinessPolicy() { var ruleService = CodeOnly("Service/S8/S8WatchRuleService.cs"); var scheduler = CodeOnly("Service/S8/S8WatchSchedulerService.cs"); var enableBody = Slice(ruleService, "public async Task EnableAsync", "public async Task DisableAsync"); var runNowBody = Slice(ruleService, "public async Task RunNowAsync", "public async Task PauseAsync"); var schedulerBody = Slice(scheduler, "public async Task RunSingleRuleAsync", "private async Task> ProcessSingleRuleAsync"); foreach (var (name, body) in new[] { ("EnableAsync", enableBody), ("RunNowAsync", runNowBody), ("RunSingleRuleAsync", schedulerBody) }) { Assert.True(body.Contains("_readinessGate.", StringComparison.Ordinal), $"{name} 未接入 S8RuleReadinessGate"); // 三处都必须把已解析的生效形态交给门禁,而不是自己再解一次 params_json。 // 门禁看到的参数与 evaluator 用的参数必须同源,否则是另一种静默背离。 Assert.True(body.Contains("S8EffectiveRule.Resolve", StringComparison.Ordinal), $"{name} 未传入 S8EffectiveRule"); Assert.DoesNotContain("JsonDocument.Parse", body); Assert.DoesNotContain("defaultOccurrenceDeptId", body); } // 门禁自身也不得重新解析 params_json。 var gateCode = CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs"); Assert.DoesNotContain("JsonDocument", gateCode); Assert.DoesNotContain("ParamsJson", gateCode); } /// 部门合法性只能有一份实现:ManualReport 侧必须委派给共享校验器。 [Fact] public void DepartmentValidity_HasSingleImplementation() { var manualReport = CodeOnly("Service/S8/S8ManualReportService.cs"); Assert.Contains("_deptValidator.ExistsInTenantAsync", manualReport); // 被抽走的那份实现(按主键校验部门归属)必须消失,否则两份判据迟早分叉。 // 只钉这一种形状:同文件里按 Department 编码 / SysUserId 查询的语句是**别的用途**, // 用宽泛断言会把它们一起误伤。 Assert.DoesNotContain("x.Id == deptId.Value && x.TenantId == tenantId", manualReport); Assert.DoesNotContain("x.Id == deptId", manualReport); } // ───────────────── helpers ───────────────── private static string Slice(string code, string from, string to) { var a = code.IndexOf(from, StringComparison.Ordinal); Assert.True(a >= 0, $"未找到锚点:{from}"); var b = code.IndexOf(to, a, StringComparison.Ordinal); return b > a ? code[a..b] : code[a..]; } private static string CodeOnly(string relative) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP"))) dir = dir.Parent; Assert.NotNull(dir); var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", relative.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return string.Join('\n', File.ReadAllLines(full) .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); } // ═══════════════════════════════════════════════════════════════════════ // S8-HANDLER-POOL-1:责任池维度 // ═══════════════════════════════════════════════════════════════════════ /// /// T14:部门配齐但责任池为空 → 仍然不可启用。 /// /// 一条没人负责的规则会源源不断建出异常、静静躺在列表里没人认领, /// 而调度器每一轮都报成功 —— 与「配了部门但建不出单」是同一类静默失败。 /// [Fact] public async Task T14_Enable_Rejected_WhenHandlerPoolEmpty() { var result = await Gate(validMembers: 0, totalMembers: 0) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA); Assert.False(result.Ok); Assert.Equal(S8RuleReadinessReasonCode.HandlerPoolEmpty, result.ReasonCode); Assert.Contains("请先配置至少一名处理账号", result.Message); } /// /// T15:池里有人、但全部已失效 → 同样不可启用,且提示与"没配过"不同。 /// /// 只数行数会让一条实际上没人能处理的规则通过检查。两种情况给同一句提示 /// 也不行:管理员要么去加人、要么去查为什么这些账号不可用,处置动作完全不同。 /// [Fact] public async Task T15_Enable_Rejected_WhenAllHandlersInvalid() { var result = await Gate(validMembers: 0, totalMembers: 2) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA); Assert.False(result.Ok); Assert.Equal(S8RuleReadinessReasonCode.HandlerPoolEmpty, result.ReasonCode); Assert.Contains("均已停用或不在当前租户", result.Message); } /// T16:部门配齐 + 至少一名有效处理账号 → 放行。 [Fact] public async Task T16_Enable_Passes_WithDepartmentsAndValidHandler() { var result = await Gate(validMembers: 1, totalMembers: 3) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA); Assert.True(result.Ok); } /// /// T17:什么都没声明的规则,四条判据一条都不跑 —— /// 该门禁整体是逐规则声明的,不是 S8 全局规则。 /// /// S8-RESPONSIBILITY-POOL-1 之后这条更名副其实:原先处理池判据写在部门分支的 /// 提前返回之后,"不检查处理池"是搭部门那趟车的副作用;现在四条判据各自独立声明, /// 不声明就是真的不检查。 /// [Fact] public async Task T17_RuleDeclaringNothing_SkipsEveryCheck() { var result = await Gate(validMembers: 0, totalMembers: 0) .CheckAsync( S8EffectiveRule.Resolve(Row(TenantA, null, null), RuleWithoutDepartmentRequirement()), TenantA); Assert.True(result.Ok); } // ───────────────── T18–T21:复核池 / 升级池就绪(S8-RESPONSIBILITY-POOL-1)───────────────── /// /// T18:声明了包含复核环节的规则,复核池为空时不得启用。 /// /// 没有复核人的后果不是报错,而是处理人做完点「提交复核」发现候选人列表是空的, /// 单据卡在 IN_PROGRESS 出不去 —— 而规则一路显示「已启用 / 最近结果:成功」。 /// [Fact] public async Task T18_Enable_Rejected_WhenReviewerPoolEmpty() { var result = await Gate(perType: new() { [S8ResponsibilityType.Reviewer] = (0, 0) }) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA); Assert.False(result.Ok); Assert.Equal(S8RuleReadinessReasonCode.ReviewerPoolEmpty, result.ReasonCode); Assert.Contains("复核账号", result.Message); } /// T19:复核池配了人但全部失效,与"一个都没配"分开报 —— 管理员的下一步动作不同。 [Fact] public async Task T19_ReviewerPool_AllInvalid_ReportsDistinctMessage() { var result = await Gate(perType: new() { [S8ResponsibilityType.Reviewer] = (0, 2) }) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA); Assert.False(result.Ok); Assert.Equal(S8RuleReadinessReasonCode.ReviewerPoolEmpty, result.ReasonCode); Assert.Contains("已停用或不在当前租户", result.Message); } /// T20:声明了参与超时升级的规则,升级池为空时不得启用。 [Fact] public async Task T20_Enable_Rejected_WhenEscalationPoolEmpty() { var result = await Gate(perType: new() { [S8ResponsibilityType.Escalation] = (0, 0) }) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA); Assert.False(result.Ok); Assert.Equal(S8RuleReadinessReasonCode.EscalationPoolEmpty, result.ReasonCode); Assert.Contains("升级账号", result.Message); } /// /// T21:未声明复核 / 升级的规则,即便两个池都是空的也照常启用。 /// 强制一条"处理完即闭环"的规则配复核人,只会逼出一个占位账号 —— /// 那比没有配置更危险。 /// [Fact] public async Task T21_RuleNotDeclaringVerificationOrEscalation_IgnoresThosePools() { var def = RuleWithoutDepartmentRequirement(); def = new S8RuleDefinition { RuleCode = def.RuleCode, DisplayName = def.DisplayName, DatasetCode = def.DatasetCode, RuleType = def.RuleType, SourceObjectType = def.SourceObjectType, SceneCode = def.SceneCode, ExceptionTypeCode = def.ExceptionTypeCode, Timeout = def.Timeout, // 只要求处理池,不要求复核 / 升级 Parameters = new S8RuleParameterPolicy { RequiresHandlerPoolForEnable = true } }; var result = await Gate(perType: new() { [S8ResponsibilityType.Reviewer] = (0, 0), [S8ResponsibilityType.Escalation] = (0, 0), }) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, null, null), def), TenantA); Assert.True(result.Ok); } /// /// T22:三个池都空时,先报处理池 —— 判据顺序稳定,管理员按提示逐个补齐即可, /// 不会出现"修好一个又蹦出另一个、每次报的还不一样"。 /// [Fact] public async Task T22_AllPoolsEmpty_ReportsHandlerFirstDeterministically() { var result = await Gate(validMembers: 0, totalMembers: 0) .CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, DeptInA, DeptInA), Rule01()), TenantA); Assert.False(result.Ok); Assert.Equal(S8RuleReadinessReasonCode.HandlerPoolEmpty, result.ReasonCode); } }