using System.Reflection;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-AUTHORITY-HEALTH-GATE:恢复放行门禁的判定逻辑。
///
/// 本文件同时守两种相反的失效,两边都会静默:
///
/// - 门禁太松 —— 中台断供时取数变空,恢复判定会把全部在办异常判成「已恢复」。
/// 没有报错、没有通知,异常就消失了。
/// - 门禁太紧 —— 若有人把门禁写成「取数为空就拦」,真实恢复将永不发生,
/// 异常在列表里堆到天荒地老,而调度器每轮都报成功。
/// 就是为这一侧准备的反向守卫。
///
///
/// 判定是纯函数(),
/// 不接数据库、不接 DI,因此 A–H 全部可在普通单测里跑。
/// 「怎么把运行日志查出来」是 S8MdpAuthorityHealthResolver 的事,不在此处断言。
///
public class S8AuthorityHealthGateTests
{
private const long TenantA = 838257186181189L;
private const string Dataset = "ORDER_DELIVERY_RISK";
/// 固定「现在」,让所有时间边界可逐分钟锁定。
private static readonly DateTime Now = new(2026, 9, 8, 12, 0, 0);
private static readonly TimeSpan Stale = TimeSpan.FromHours(1);
private static S8AuthorityObservation Obs(
DateTime? lastSuccessEndAt = null,
bool specFound = true,
bool producerTrusted = true,
bool newerFailure = false,
string? newerFailureStatus = null,
DateTime? inFlightStartAt = null,
string snapshot = S8SnapshotInvariant.NotApplicable,
string? snapshotDetail = null) => new()
{
DatasetCode = Dataset,
TenantId = TenantA,
ObservedAt = Now,
SpecFound = specFound,
ProducerTrusted = producerTrusted,
LastSuccessEndAt = lastSuccessEndAt,
HasNewerTerminalFailure = newerFailure,
NewerTerminalFailureStatus = newerFailureStatus,
OldestNewerInFlightStartAt = inFlightStartAt,
SnapshotInvariant = snapshot,
SnapshotDetail = snapshotDetail,
StaleWindow = Stale
};
/// 一次完全健康的观测:30 分钟前成功,早于 1 小时的新鲜度窗口。
private static S8AuthorityObservation Healthy() => Obs(lastSuccessEndAt: Now.AddMinutes(-30));
// ══════════════════ CASE A / B:放行侧 ══════════════════
/// CASE A:Authority 可信 → 恢复正常放行。
[Fact]
public void A_Healthy_AllowsRecovery()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Healthy());
Assert.Equal(S8AuthorityHealthState.Healthy, r.State);
Assert.Equal(S8AuthorityHealthReason.Healthy, r.ReasonCode);
Assert.Equal(Now.AddMinutes(-30), r.LastSuccessAt);
Assert.Equal(Now.AddMinutes(-30) + Stale, r.StaleAfter);
Assert.True(r.AllowsRecovery);
}
///
/// CASE B(本文件最重要的一条):Authority 可信但本轮取数为空 → 恢复仍然放行。
///
/// 「空集」是恢复的正常输入而非异常信号 —— 全部业务对象都合法退场时,
/// 本来就该看到 0 行。把门禁写成「空集就拦」,恢复将永不发生且没有任何报错。
///
/// 断言分两层:行为上放行;结构上根本拿不到行数 ——
/// 判定的入参类型里不存在任何与行数/命中数相关的字段,
/// 于是「顺手加个空集判断」在编译期就做不到。这一层比行为断言更耐改。
///
[Fact]
public void B_HealthyEmpty_StillAllowsRecovery()
{
// 行为层:与 CASE A 走的是同一组判据 —— 判定里根本没有「空/非空」这个分支。
Assert.True(S8AuthorityHealthEvaluator.Evaluate(Healthy()).AllowsRecovery);
// 结构层一:判定入参里不得出现任何行数概念。
var banned = new[] { "hit", "row", "count", "empty", "any", "size", "length" };
foreach (var p in typeof(S8AuthorityObservation).GetProperties())
{
foreach (var b in banned)
{
Assert.False(p.Name.Contains(b, StringComparison.OrdinalIgnoreCase),
$"S8AuthorityObservation.{p.Name} 疑似把行数引入了健康判定");
}
}
// 结构层二:放行判定是结果对象上的无参属性,没有任何可供传入行数的入口。
var allows = typeof(S8AuthorityHealthResult).GetProperty(nameof(S8AuthorityHealthResult.AllowsRecovery));
Assert.NotNull(allows);
Assert.Empty(allows!.GetMethod!.GetParameters());
// 结构层三:Evaluate 只有一个入参,就是观测值本身。
var evaluate = typeof(S8AuthorityHealthEvaluator).GetMethod(
nameof(S8AuthorityHealthEvaluator.Evaluate), BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(evaluate);
Assert.Single(evaluate!.GetParameters());
Assert.Equal(typeof(S8AuthorityObservation), evaluate.GetParameters()[0].ParameterType);
}
// ══════════════════ CASE C–H:拦截侧 ══════════════════
/// CASE C:回溯窗口内一次成功都没有 → UNKNOWN。「查不到」不等于「没问题」。
[Fact]
public void C_NoSuccessfulRun_IsUnknownAndBlocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Obs(lastSuccessEndAt: null));
Assert.Equal(S8AuthorityHealthState.Unknown, r.State);
Assert.Equal(S8AuthorityHealthReason.NoSuccessfulRun, r.ReasonCode);
Assert.Null(r.LastSuccessAt);
Assert.Null(r.StaleAfter);
Assert.False(r.AllowsRecovery);
}
/// CASE C':数据集压根没声明 AuthoritySpec → UNKNOWN,绝不默认健康。
[Fact]
public void C2_NoAuthoritySpec_IsUnknownAndBlocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Obs(specFound: false, lastSuccessEndAt: Now.AddMinutes(-1)));
Assert.Equal(S8AuthorityHealthState.Unknown, r.State);
Assert.Equal(S8AuthorityHealthReason.NoAuthoritySpec, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
/// CASE D:最近一次成功已超过新鲜度窗口 → DEGRADED / STALE。
[Fact]
public void D_SuccessTooOld_IsStaleAndBlocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Obs(lastSuccessEndAt: Now.AddMinutes(-90)));
Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
Assert.Equal(S8AuthorityHealthReason.Stale, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
/// CASE D':恰好落在窗口边界上仍算健康 —— 阈值是「超过」才降级。
[Fact]
public void D2_ExactlyAtStaleBoundary_IsStillHealthy()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Obs(lastSuccessEndAt: Now - Stale));
Assert.Equal(S8AuthorityHealthState.Healthy, r.State);
Assert.True(r.AllowsRecovery);
}
///
/// CASE E:成功之后又开了一轮、还没跑完 → IN_FLIGHT。
/// 此刻 Authority 可能只写了一半,恢复判定不能在这个瞬间做。
///
[Fact]
public void E_NewerRunInFlight_Blocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(
Obs(lastSuccessEndAt: Now.AddMinutes(-40), inFlightStartAt: Now.AddMinutes(-5)));
Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
Assert.Equal(S8AuthorityHealthReason.InFlight, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
///
/// CASE E':在途运行的年龄已超过新鲜度窗口 → STUCK_RUN,与 IN_FLIGHT 分开报。
///
/// 运行日志里的 RUNNING 行没有任何应用侧回收机制(失败路径会吞掉自己的写入异常,
/// 注释里写的是「遗留 RUNNING 行可由运维手动清理」,而并不存在这样的清理作业)。
/// 实测该表有 298 条永不终态的 RUNNING 行,S1 占 42 条、最老已 570 小时。
/// 若与「正在正常跑」混为一谈,运维分不清该等还是该查 —— 两者都拦,但排障动作不同。
///
[Fact]
public void E2_StuckRun_IsReportedSeparately()
{
var r = S8AuthorityHealthEvaluator.Evaluate(
Obs(lastSuccessEndAt: Now.AddHours(-5), inFlightStartAt: Now.AddHours(-4)));
Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
Assert.Equal(S8AuthorityHealthReason.StuckRun, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
/// CASE F:成功之后的那一轮失败了 → LATEST_FAILED。
[Fact]
public void F_NewerRunFailed_Blocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(
Obs(lastSuccessEndAt: Now.AddMinutes(-40), newerFailure: true, newerFailureStatus: "FAILED"));
Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
Assert.Equal(S8AuthorityHealthReason.LatestFailed, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
///
/// CASE F':失败发生在最近一次成功之前 → 不影响判定。
/// 历史失败是常态;若一律降级,门禁会永久拦死。
/// (观测层已按「锚点之后」过滤,此处等价于 newerFailure = false。)
///
[Fact]
public void F2_OlderFailure_DoesNotDegrade()
{
var r = S8AuthorityHealthEvaluator.Evaluate(
Obs(lastSuccessEndAt: Now.AddMinutes(-20), newerFailure: false));
Assert.Equal(S8AuthorityHealthState.Healthy, r.State);
Assert.True(r.AllowsRecovery);
}
/// CASE G:成功之后出现终态 PARTIAL → LATEST_PARTIAL。部分成功的快照不足以支撑恢复判定。
[Fact]
public void G_PartialRun_Blocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(
Obs(lastSuccessEndAt: Now.AddMinutes(-40), newerFailure: true, newerFailureStatus: "PARTIAL"));
Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
Assert.Equal(S8AuthorityHealthReason.LatestPartial, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
///
/// CASE G':未知的终态状态一律按失败处置,不得被当成健康放行。
/// 该表的 status 值域实测还包含 SUCCESS_WITH_WARNING 与 CANCELED
/// (由 S0 维度刷新写出,当前不出现在 S1 上)。采集层用的是
/// status <> 'SUCCESS' 而非白名单,正是为了让将来新增的终态状态
/// 默认落到「失败」而不是「健康」这一侧。
///
[Fact]
public void G2_UnknownTerminalStatus_IsTreatedAsFailure()
{
var r = S8AuthorityHealthEvaluator.Evaluate(
Obs(lastSuccessEndAt: Now.AddMinutes(-40), newerFailure: true, newerFailureStatus: "SUCCESS_WITH_WARNING"));
Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
Assert.Equal(S8AuthorityHealthReason.LatestFailed, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
/// CASE G'':快照不变量被破坏 → 即使生产运行成功也降级。
[Fact]
public void G3_SnapshotInvariantViolated_Blocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Obs(
lastSuccessEndAt: Now.AddMinutes(-10),
snapshot: S8SnapshotInvariant.Violated,
snapshotDetail: "同时存在 2 个当前批次"));
Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
Assert.Equal(S8AuthorityHealthReason.SnapshotInvariantFailed, r.ReasonCode);
Assert.Contains("2 个当前批次", r.Reason);
Assert.False(r.AllowsRecovery);
}
///
/// CASE G''':快照发布无法确认 → UNKNOWN,不猜。
///
/// 这是 Rule03 今天的真实处境:生产侧的发布语句在本轮零行时命中 0 行、
/// 在库里不留任何批次标记,于是「源侧本轮确实没有数据」与「发布压根没跑成」字面等同。
/// 猜成健康的代价是把真实缺料预警整体判成已恢复,因此保守拦截。
/// 消除它需要生产侧补一个发布标记 —— 那是下一批的工作,不在本轮范围。
///
[Fact]
public void G4_SnapshotPublicationUnconfirmed_IsUnknownAndBlocks()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Obs(
lastSuccessEndAt: Now.AddMinutes(-10),
snapshot: S8SnapshotInvariant.Unconfirmed,
snapshotDetail: "当前批次数为 0"));
Assert.Equal(S8AuthorityHealthState.Unknown, r.State);
Assert.Equal(S8AuthorityHealthReason.SnapshotPublicationUnconfirmed, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
///
/// CASE H:Authority 有多个写入方且部分不留运行日志 → UNKNOWN,
/// 且必须压过一切健康信号。
///
/// 本用例刻意喂进一次「一分钟前刚成功、快照不变量也成立」的完美观测,
/// 它不得把结论洗白 —— 因为问题不在于生产跑没跑成,
/// 而在于运行日志根本证明不了表里最终的数据出自谁。
///
[Fact]
public void H_MultiWriterAuthority_IsUnknownRegardlessOfRunHealth()
{
var r = S8AuthorityHealthEvaluator.Evaluate(Obs(
producerTrusted: false,
lastSuccessEndAt: Now.AddMinutes(-1),
snapshot: S8SnapshotInvariant.Satisfied));
Assert.Equal(S8AuthorityHealthState.Unknown, r.State);
Assert.Equal(S8AuthorityHealthReason.Rule01MultiWriterUntrusted, r.ReasonCode);
Assert.False(r.AllowsRecovery);
}
// ══════════════════ 穷尽性与词表封闭 ══════════════════
public static IEnumerable