using System.Reflection;
using Admin.NET.Plugin.AiDOP.Service.S8;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-AUTHORITY-HEALTH-GATE:门禁在调度器里的接线位置与爆炸半径。
///
/// 为什么是源码扫描而不是行为测试:
/// 的构造参数里有 8 个 SqlSugarRepository<T>,其无参构造读取
/// SqlSugarSetup.ITenant 静态字段,没有 Furion 启动就是 NRE ——
/// 本仓从未、也无法在测试里实例化调度器(grep "new S8WatchSchedulerService" 全仓 0 命中)。
/// 既有的 S8SchedulerScopeGuardTests / S8SystemRecoveredTimelineTests
/// 走的都是同一条路。
///
/// 判定逻辑不在这里,在 (纯函数、可断言)。
/// 本文件只回答一个问题:门禁挡住的到底是哪一段。
/// 挡多了,正面观测停摆;挡少了,中台停更时照样假恢复。
///
public class S8RecoveryGateWiringTests
{
private const string SchedulerSrc = "Service/S8/S8WatchSchedulerService.cs";
// ───────────────── helpers ─────────────────
/// 读取源码并剥掉注释行 —— 断言不应被注释里的字面量满足或破坏。
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);
}));
}
private static string Scheduler() => CodeOnly(SchedulerSrc);
private static int At(string code, string anchor)
{
var i = code.IndexOf(anchor, StringComparison.Ordinal);
Assert.True(i >= 0, $"未找到锚点:{anchor}");
return i;
}
private static string Slice(string code, string from, string to)
{
var a = At(code, from);
var b = code.IndexOf(to, a, StringComparison.Ordinal);
return b > a ? code[a..b] : code[a..];
}
private static string ProcessBody() => Slice(Scheduler(),
"private async Task> ProcessSingleRuleAsync",
"public async Task OnRuleCompletedAsync");
private static string ReconcileBody() => Slice(Scheduler(),
"private async Task> ReconcileRecoveriesForRuleAsync",
"private static AdoS8DetectionLog BuildHitLog");
private static string RunSingleBody() => Slice(Scheduler(),
"public async Task RunSingleRuleAsync",
"private async Task> ProcessSingleRuleAsync");
///
/// 计算 处的花括号嵌套深度。
/// 只在单个方法体切片内使用:日志模板里的 {RuleCode} 之类占位符成对出现、
/// 净增为 0,不影响相对深度比较。若将来该方法引入 raw string SQL,本助手需先剥离字符串字面量。
///
private static int DepthAt(string code, int index)
{
var d = 0;
for (var i = 0; i < index; i++)
{
if (code[i] == '{') d++;
else if (code[i] == '}') d--;
}
return d;
}
private static int Count(string code, string needle)
{
int n = 0, i = 0;
while ((i = code.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
return n;
}
// ───────────────── 接线基本形态 ─────────────────
/// 调度器必须依赖端口接口,且不得自己知道运行日志表名。
[Fact]
public void Scheduler_DependsOnTheResolverSeamInterface()
{
var ctor = typeof(S8WatchSchedulerService).GetConstructors().Single();
Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(IS8AuthorityHealthResolver));
var code = Scheduler();
Assert.DoesNotContain("new S8MdpAuthorityHealthResolver", code);
// 物理表名属适配层的知识;调度器只传 DatasetCode。
Assert.DoesNotContain("mdp_transform_run_log", code);
Assert.DoesNotContain("dwd_requirement_examine_detail", code);
Assert.DoesNotContain("mdp_std_so", code);
}
/// 门禁必须在 reconcile 之前,且位于 ProcessSingleRuleAsync 内。
[Fact]
public void Gate_RunsBeforeRecoveryReconcile()
{
var body = ProcessBody();
Assert.True(At(body, "_authorityHealthResolver.ResolveAsync") < At(body, "health.AllowsRecovery"));
Assert.True(At(body, "health.AllowsRecovery") < At(body, "ReconcileRecoveriesForRuleAsync("));
}
// ───────────────── CASE B(结构侧):空集不得成为拦截理由 ─────────────────
///
/// CASE B:门禁与「本轮有没有命中」完全无关。
/// 取数与门禁之间不得出现任何对结果规模的判断 ——
/// 一旦有人补上 if (hits.Count == 0),真实恢复会永远不再发生,
/// 而日志上每一轮都是成功。
///
[Fact]
public void B_GateIsNotConditionedOnHitCount()
{
var body = ProcessBody();
var window = body[At(body, "await evaluator.EvaluateAsync")..At(body, "ReconcileRecoveriesForRuleAsync(")];
Assert.DoesNotContain("hits.Count ==", window);
Assert.DoesNotContain("hits.Count >", window);
Assert.DoesNotContain("hits.Any(", window);
Assert.DoesNotContain("!hits.Any", window);
// reconcile 自身也不得新增空集早退 —— 空 hits 正是「全部恢复」的正常输入。
var reconcile = ReconcileBody();
Assert.DoesNotContain("hits.Count == 0", reconcile);
Assert.DoesNotContain("!hits.Any()", reconcile);
}
// ───────────────── CASE I:抗抖计数不得被拦截路径触碰 ─────────────────
///
/// CASE I:门禁拦截时 ConsecutiveMissCount 既不加也不减。
///
/// 实现方式必须是「整段跳过 reconcile」,而不是在 reconcile 内部某处 return ——
/// 后者会让候选查询与 miss 自增的相对顺序变成定时炸弹:今天写在自增之前,
/// 明天挪到之后,就变成「拦截期间照样累计 miss,中台一恢复立刻集体假恢复」。
///
[Fact]
public void I_BlockedPath_LeavesConsecutiveMissCountUntouched()
{
var code = Scheduler();
var reconcile = ReconcileBody();
const string inc = "ConsecutiveMissCount = x.ConsecutiveMissCount + 1";
Assert.Equal(2, Count(code, inc));
Assert.Equal(2, Count(reconcile, inc)); // 两处自增必须全部落在 reconcile 内
// 门禁绝不能被搬进 reconcile —— 那样自增与拦截的先后无法再被静态保证。
Assert.DoesNotContain("_authorityHealthResolver", reconcile);
Assert.DoesNotContain("AllowsRecovery", reconcile);
Assert.DoesNotContain("S8AuthorityHealth", reconcile);
// 拦截分支自身不碰任何抗抖计数与恢复标记。
var body = ProcessBody();
var blocked = body[At(body, "if (!health.AllowsRecovery)")..At(body, "ReconcileRecoveriesForRuleAsync(")];
Assert.DoesNotContain("ConsecutiveMissCount", blocked);
Assert.DoesNotContain("ConsecutiveHitCount", blocked);
Assert.DoesNotContain("RecoveredAt", blocked);
}
// ───────────────── CASE J:正面观测不受影响 ─────────────────
///
/// CASE J:门禁拦住的只有恢复,建单与刷新照常。
///
/// 用花括号深度证明 foreach (var hit in hits) 与方法首行同级 ——
/// 它没有被裹进门禁的 if/else 里。这比 Assert.Contains 强:
/// 把 foreach 缩进到 else 分支内,字符串照样能匹配到,深度不会。
///
/// 正面观测停摆是比假恢复更早被发现、但同样严重的失效:
/// 中台停更期间新异常一条也建不出来,而页面上一片安静。
///
[Fact]
public void J_BlockedPath_StillRunsCreateAndRefresh()
{
var body = ProcessBody();
var baseline = At(body, "var results = new List();");
var gate = At(body, "if (!health.AllowsRecovery)");
var loop = At(body, "foreach (var hit in hits)");
Assert.True(loop > gate, "hit 循环必须在门禁之后");
Assert.Equal(DepthAt(body, baseline), DepthAt(body, loop));
}
// ───────────────── CASE K:不判失败、不触发自动暂停 ─────────────────
///
/// CASE K:门禁拦截不是一次失败的 run。
///
/// 若拦截走 Success = false,规则完成回调会累计 ConsecutiveFailureCount,
/// 达到阈值即写 paused_until 把规则自动暂停一小时。
/// 于是中台跑一次批,监控就睡一小时 —— 而页面上只显示「最近结果:失败」,
/// 没人知道是数据源的问题。
///
[Fact]
public void K_BlockedPath_DoesNotFailTheRunNorAutoPause()
{
var body = ProcessBody();
var blocked = body[At(body, "if (!health.AllowsRecovery)")..At(body, "foreach (var hit in hits)")];
Assert.DoesNotContain("throw", blocked);
Assert.DoesNotContain("Success = false", blocked);
Assert.DoesNotContain("S8RuleRunResult", blocked);
Assert.DoesNotContain("Enabled = false", blocked);
Assert.DoesNotContain("PausedUntil", blocked);
// 门禁不得上移到 RunSingleRuleAsync —— 那一层的失败语义就是整条 run 失败。
Assert.DoesNotContain("_authorityHealthResolver", RunSingleBody());
}
///
/// CASE K':探测自身失败必须降级为 UNKNOWN,不得让异常穿到上层变成整条 run 失败 ——
/// 那等于绕过本条守卫达成同一个后果。该兜底落在适配实现里,此处断言它确实存在。
///
[Fact]
public void K2_ResolverFailure_DegradesToUnknown_NotRunFailure()
{
var resolver = CodeOnly("Service/S8/Rules/Health/S8MdpAuthorityHealthResolver.cs");
var probe = resolver[At(resolver, "catch (Exception ex)")..];
Assert.Contains("S8AuthorityHealthState.Unknown", probe);
Assert.Contains("S8AuthorityHealthReason.ResolverFailed", probe);
Assert.DoesNotContain("throw;", probe);
}
// ───────────────── 观测性:拦截必须留痕 ─────────────────
///
/// 拦截期间异常只增不减,这件事必须能被查到。
/// 只写日志不够 —— 日志会滚走,而 detection_log 是可查的。
/// 原因码要落 failure_reason,否则只知道「被拦了」、不知道为什么。
///
[Fact]
public void BlockedPath_WritesRecoveryBlockedDetectionLog()
{
var body = ProcessBody();
var blocked = body[At(body, "if (!health.AllowsRecovery)")..At(body, "foreach (var hit in hits)")];
Assert.Contains("WriteDetectionLogAsync", blocked);
Assert.Contains("DetectResult = DetectResultRecoveryBlocked", blocked);
Assert.Contains("FailureReason = health.ReasonCode", blocked);
Assert.Contains("PayloadSnapshot", blocked);
// detect_result 列是 varchar(32),常量不得超长。
var f = typeof(S8WatchSchedulerService).GetField("DetectResultRecoveryBlocked",
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
Assert.NotNull(f);
var v = (string)f!.GetRawConstantValue()!;
Assert.Equal("RECOVERY_BLOCKED", v);
Assert.True(v.Length <= 32);
}
// ───────────────── 适配层的租户隔离 ─────────────────
///
/// 适配层的租户谓词必须是严格相等。
///
/// 仓内 MdpMonitorService.BuildMdpRunLogTenantWhere 用的是
/// (tenant_id = @TenantId OR tenant_id = 0) —— 本类不得复用:
/// 该表确实存在 tenant_id = 0 的平台行(实测 S1 有 111 条,
/// 且 S5_PURCHASE_RECEIPT_MDP_SYNC 有 325 条平台级 SUCCESS),
/// 一条这样的行会让所有租户同时被判成健康。
///
[Fact]
public void Resolver_UsesStrictTenantEquality()
{
var resolver = CodeOnly("Service/S8/Rules/Health/S8MdpAuthorityHealthResolver.cs");
Assert.DoesNotContain("OR tenant_id = 0", resolver);
Assert.DoesNotContain("BuildMdpRunLogTenantWhere", resolver);
Assert.Equal(3, Count(resolver, "r.tenant_id = @TenantId")); // 三条运行日志查询各一次
// 必须显式过滤终态,否则会取到永不回收的孤儿 RUNNING 行。
Assert.Contains("r.status = 'SUCCESS'", resolver);
Assert.Contains("r.end_time IS NOT NULL", resolver);
// 回溯窗口不是可选优化:无界回溯在长期停摆的租户上实测单次 146ms。
Assert.Contains("r.start_time >= @LookbackFrom", resolver);
}
}