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