using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
using Admin.NET.Plugin.AiDOP.Job;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-P0-2-PER-JOB-KILL-SWITCH-1:三个 S8 后台 Job 的独立开关。
///
/// 修复前三个 Job 读**完全相同**的两个键,「只跑 Watch」在配置层不可表达。
/// 2026-09-02 受控启动时打开总开关同时放行了另外两个 Job,其中
/// ActiveFlowStuckScan(RunOnStart = true)在重启瞬间写入 499 行告警日志;
/// 当时唯一的隔离手段「先暂停 trigger 再重启」实测**跨重启不可靠**(两次结果相反)。
///
/// 本测试用真实 覆盖原 Prompt §九 的四组合真值表。
/// 这是 Gate 2 的代码侧证据;runtime 侧(重启后第一拍)留待统一窗口。
///
public class S8JobSwitchTests
{
private static IConfiguration Config(bool? environmentEnabled, bool master, bool watch, bool timeout, bool stuck)
{
var dict = new Dictionary
{
["S8:Scheduler:Enabled"] = master.ToString(),
["S8:WatchScheduler:Enabled"] = watch.ToString(),
["S8:TimeoutAutoEscalation:Enabled"] = timeout.ToString(),
["S8:ActiveFlowStuckScan:Enabled"] = stuck.ToString(),
};
if (environmentEnabled.HasValue) dict["Scheduler:Enabled"] = environmentEnabled.Value.ToString();
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
}
private static (bool Watch, bool Timeout, bool Stuck) Evaluate(IConfiguration cfg) => (
S8JobSwitch.Evaluate(cfg, S8BackgroundJob.WatchScheduler).Enabled,
S8JobSwitch.Evaluate(cfg, S8BackgroundJob.TimeoutAutoEscalation).Enabled,
S8JobSwitch.Evaluate(cfg, S8BackgroundJob.ActiveFlowStuckScan).Enabled);
// ───────────────────────── 原 Prompt §九 四组合 ─────────────────────────
/// Case A:Master=false,三个 Job 各自 true → 三个都不运行。
[Fact]
public void CaseA_MasterOff_DisablesAllThreeJobs()
{
var (watch, timeout, stuck) = Evaluate(Config(true, master: false, watch: true, timeout: true, stuck: true));
Assert.False(watch);
Assert.False(timeout);
Assert.False(stuck);
}
/// Case B:Master=true,只有 Watch=true → 只有 Watch 运行。
[Fact]
public void CaseB_OnlyWatchScheduler()
{
var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: true, timeout: false, stuck: false));
Assert.True(watch);
Assert.False(timeout);
Assert.False(stuck);
}
/// Case C:Master=true,只有 Timeout=true → 只有 Timeout 运行。
[Fact]
public void CaseC_OnlyTimeoutAutoEscalation()
{
var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: true, stuck: false));
Assert.False(watch);
Assert.True(timeout);
Assert.False(stuck);
}
/// Case D:Master=true,只有 Stuck=true → 只有 StuckScan 运行。
[Fact]
public void CaseD_OnlyActiveFlowStuckScan()
{
var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: false, stuck: true));
Assert.False(watch);
Assert.False(timeout);
Assert.True(stuck);
}
// ───────────────────────── 边界与默认 ─────────────────────────
/// 环境级 master kill switch 优先于一切:Scheduler:Enabled=false → 全停。
[Fact]
public void EnvironmentKillSwitch_OverridesEverything()
{
var (watch, timeout, stuck) = Evaluate(Config(false, master: true, watch: true, timeout: true, stuck: true));
Assert.False(watch);
Assert.False(timeout);
Assert.False(stuck);
}
///
/// 单 Job 键缺省时必须为 false(fail-safe):只配 Master=true 不足以让任何 Job 跑起来。
/// 宁可「本该跑却没跑」也不要「本不该跑却跑了」。
///
[Fact]
public void MissingPerJobKey_DefaultsToDisabled()
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
{
["Scheduler:Enabled"] = "true",
["S8:Scheduler:Enabled"] = "true",
}).Build();
var (watch, timeout, stuck) = Evaluate(cfg);
Assert.False(watch);
Assert.False(timeout);
Assert.False(stuck);
}
/// 环境级键缺省为 true,保持既有部署语义不变。
[Fact]
public void MissingEnvironmentKey_DefaultsToEnabled()
{
var d = S8JobSwitch.Evaluate(
Config(null, master: true, watch: true, timeout: false, stuck: false),
S8BackgroundJob.WatchScheduler);
Assert.True(d.EnvironmentEnabled);
Assert.True(d.Enabled);
}
/// 三级与的每一级都要能单独否决。
[Theory]
[InlineData(false, true, true, false)]
[InlineData(true, false, true, false)]
[InlineData(true, true, false, false)]
[InlineData(true, true, true, true)]
public void EffectiveIsConjunctionOfThreeLevels(bool env, bool master, bool own, bool expected)
{
var d = new S8JobSwitchDecision(env, master, own, null);
Assert.Equal(expected, d.Enabled);
}
/// 键名与 env 名固化,防止后续改名导致部署侧静默失配。
[Theory]
[InlineData(S8BackgroundJob.WatchScheduler, "S8:WatchScheduler:Enabled", "AIDOP_S8_WATCH_SCHEDULER_ENABLED")]
[InlineData(S8BackgroundJob.TimeoutAutoEscalation, "S8:TimeoutAutoEscalation:Enabled", "AIDOP_S8_TIMEOUT_AUTO_ESCALATION_ENABLED")]
[InlineData(S8BackgroundJob.ActiveFlowStuckScan, "S8:ActiveFlowStuckScan:Enabled", "AIDOP_S8_ACTIVE_FLOW_STUCK_SCAN_ENABLED")]
public void ConfigKeysAndEnvNamesArePinned(S8BackgroundJob job, string key, string env)
{
Assert.Equal(key, S8JobSwitch.JobEnabledKey(job));
Assert.Equal(env, S8JobSwitch.JobEnvName(job));
}
/// env 解析失败必须保留 JSON 值并回报 env 名(不吞、不静默改判)。
[Fact]
public void UnparsableEnvOverride_KeepsJsonValueAndReportsName()
{
var name = S8JobSwitch.JobEnvName(S8BackgroundJob.WatchScheduler);
var original = Environment.GetEnvironmentVariable(name);
try
{
Environment.SetEnvironmentVariable(name, "not-a-bool");
var d = S8JobSwitch.Evaluate(
Config(true, master: true, watch: true, timeout: false, stuck: false),
S8BackgroundJob.WatchScheduler);
Assert.True(d.JobEnabled); // 沿用 JSON 的 true
Assert.Equal(name, d.ParseFailEnvName); // 但必须回报解析失败
}
finally { Environment.SetEnvironmentVariable(name, original); }
}
// ───────────────────────── RunOnStart 硬门 ─────────────────────────
///
/// ActiveFlowStuckScan 带 RunOnStart = true,启动瞬间必然触发一次;
/// 因此三个 Job 的 ExecuteAsync 都必须**先求值开关再做任何事**。
/// 断言:门禁调用出现在方法体内、且早于任何 CreateScope()(即拿 service 之前)。
///
[Theory]
[InlineData(typeof(S8WatchSchedulerJob), "Job/S8WatchSchedulerJob.cs")]
[InlineData(typeof(S8TimeoutAutoEscalationJob), "Job/S8TimeoutAutoEscalationJob.cs")]
[InlineData(typeof(S8ActiveFlowStuckScanJob), "Job/S8ActiveFlowStuckScanJob.cs")]
public void Gate_IsEvaluatedBeforeAnyServiceResolution(Type jobType, string relativePath)
{
_ = jobType;
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
var full = Path.Combine(root, relativePath);
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
var src = File.ReadAllText(full);
var gateIdx = src.IndexOf("S8JobSwitch.Evaluate(_configuration", StringComparison.Ordinal);
var scopeIdx = src.IndexOf("_scopeFactory.CreateScope()", StringComparison.Ordinal);
Assert.True(gateIdx > 0, "未找到 S8JobSwitch 门禁调用");
Assert.True(scopeIdx > 0, "未找到 CreateScope(Job 结构可能已变)");
Assert.True(gateIdx < scopeIdx, "开关求值必须早于任何 service 解析 / 业务扫描");
// 旧的双键写法必须彻底消失,避免有人只改一个 Job 造成三处漂移。
Assert.DoesNotContain("GetValue(\"S8:Scheduler:Enabled\"", src);
Assert.DoesNotContain("GetValue(\"Scheduler:Enabled\"", src);
}
}