using Admin.NET.Plugin.AiDOP.Service.S8;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-SYSTEM-RECOVERED-TIMELINE-1:恢复要在时间线上留痕。
///
/// 补的是什么:恢复此前已经写 recovered_at、写 RECOVERED detection_log、
/// 发恢复通知,唯独用户能看见的那条时间线没有。于是页面上异常"自己不见了",
/// 没人能分辨是系统判定恢复、还是有人做了什么。
///
/// 刻意不动的语义:恢复不自动关闭异常;状态机不变;
/// recovered_at 与 RECOVERED detection_log 原样保留。
///
public class S8SystemRecoveredTimelineTests
{
private static readonly string PluginRoot =
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private static string CodeOnly(string relativePath)
{
var full = Path.Combine(PluginRoot, relativePath.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("Service/S8/S8WatchSchedulerService.cs");
/// 动作码是稳定常量,不是散落各处的字符串字面量。
[Fact]
public void ActionCode_IsAStableConstant()
{
Assert.Equal("SYSTEM_RECOVERED", S8WatchSchedulerService.SystemRecoveredActionCode);
}
///
/// 阈值未达到时不得写时间线 —— 抗抖期间每次未命中都留一条,
/// 时间线会被刷屏,而且每一条都不代表真的恢复了。
///
[Fact]
public void BelowThreshold_WritesNoTimeline()
{
var code = Scheduler();
// 未达阈值那一支必须在写时间线之前就 continue
var pendingIdx = code.IndexOf("antiflap_pending_recovery", StringComparison.Ordinal);
var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal);
Assert.True(pendingIdx > 0 && timelineIdx > 0);
Assert.True(pendingIdx < timelineIdx,
"未达恢复阈值的分支必须早于时间线写入,否则抗抖期间每次未命中都会留痕");
Assert.Contains("if (newMissCount < recoverRequired)", code);
}
///
/// 阈值达到时:recovered_at 已写、RECOVERED detection_log 已写,之后才写时间线。
/// 顺序重要 —— 时间线是"已经判定恢复"的结果,不能先于判定出现。
///
[Fact]
public void AtThreshold_WritesTimelineAfterStateAndDetectionLog()
{
var code = Scheduler();
var recoveredAtIdx = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal);
var detectionLogIdx = code.IndexOf("DetectResult = DetectResultRecovered,", StringComparison.Ordinal);
var timelineIdx = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(c.Id", StringComparison.Ordinal);
Assert.True(recoveredAtIdx > 0 && detectionLogIdx > 0 && timelineIdx > 0);
Assert.True(recoveredAtIdx < timelineIdx, "recovered_at 必须先落库");
Assert.True(detectionLogIdx < timelineIdx, "RECOVERED detection_log 必须先落库");
}
///
/// 恢复不自动关闭异常,也不改状态机 ——
/// 恢复只表示"规则不再命中",是否闭环仍由人判断。
///
[Fact]
public void Recovery_DoesNotAutoCloseOrChangeStatus()
{
var code = Scheduler();
// 恢复那一段不得出现状态改写
var start = code.IndexOf("RecoveredAt = now,", StringComparison.Ordinal);
var end = code.IndexOf("TryDispatchRecoveredLayerNotificationAsync", StringComparison.Ordinal);
Assert.True(start > 0 && end > start);
var recoverySegment = code[start..end];
Assert.DoesNotContain("Status = \"CLOSED\"", recoverySegment);
Assert.DoesNotContain("Status = \"RECOVERED\"", recoverySegment);
Assert.DoesNotContain("ClosedAt", recoverySegment);
// 时间线本身也不写状态迁移
var writer = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
Assert.Contains("ToStatus = null,", writer);
}
///
/// operator_user_id 必须是 NULL,绝不伪造。
///
/// 时间线的操作人列刚在 S8-SYSUSER-ONLY-1 里从"混装 EmployeeMaster.RecID 与
/// SysUser.Id 两套空间"收拾干净(实测 CLAIM 19 行是员工 Id、其余是账号 Id)。
/// 系统动作往里塞 1 或任意账号 Id,等于把刚修好的东西重新弄脏。
///
[Fact]
public void SystemAction_LeavesOperatorNullNeverFabricated()
{
var code = Scheduler();
var start = code.IndexOf("ActionCode = SystemRecoveredActionCode,", StringComparison.Ordinal);
Assert.True(start > 0);
var block = code[start..(start + 700)];
Assert.Contains("OperatorUserId = null,", block);
Assert.Contains("OperatorName = null,", block);
Assert.DoesNotContain("OperatorUserId = 1", block);
Assert.DoesNotContain("EmployeeId", block);
}
///
/// 幂等:同一恢复周期只写一条;调度器重跑不重复追加;
/// 但复发后真实的新恢复周期要能再写一条。
///
/// 判据用既有列表达:不存在 created_at 晚于 last_detected_at 的
/// SYSTEM_RECOVERED 行。复发时 RefreshDetectionAsync 会把 last_detected_at
/// 推到旧恢复行之后,于是新周期自然放行 —— 不新增状态字段、不靠"最后一条是什么"
/// (调度器根本不写其它时间线,那种判据会把第二次真实恢复也挡掉)。
///
[Fact]
public void Idempotent_PerRecoveryCycle_ButAllowsGenuineRerecovery()
{
var code = Scheduler();
Assert.Contains("t.ActionCode == SystemRecoveredActionCode", code);
Assert.Contains("t.CreatedAt > lastDetectedAt!.Value", code);
Assert.Contains("already_recorded_for_current_cycle", code);
// 复发确实会推进 last_detected_at 并清空 recovered_at —— 幂等判据依赖这一点
Assert.Contains("LastDetectedAt = hit.DetectedAt,", code);
Assert.Contains("RecoveredAt = null,", code);
}
///
/// 时间线写失败不得回滚已判定的恢复状态 —— 但必须落日志。
/// 静默吞掉会让"恢复了却没有痕迹"变成无从查起的问题。
///
[Fact]
public void TimelineFailure_DoesNotRollbackRecovery()
{
var code = Scheduler();
Assert.Contains("system_recovered_timeline_failed", code);
Assert.Contains("system_recovered_timeline_written", code);
}
///
/// 前端把 NULL 操作人显示为「系统」,不是整行不渲染。
///
/// 原实现 v-if="item.operatorName" 在 NULL 时整行消失,
/// 系统恢复那条看起来像"谁都没做过",用户无从判断是自动恢复还是有人动过。
///
[Fact]
public void Frontend_ShowsSystemInsteadOfBlankOperator()
{
var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue")));
Assert.Contains("function operatorLabel(", page);
Assert.Contains("'系统'", page);
Assert.Contains("operatorLabel(item)", page);
Assert.DoesNotContain("v-if=\"item.operatorName\" class=\"muted\"", page);
}
///
/// 租户隔离不受本批影响:恢复处理仍在按租户取候选的循环里,
/// 时间线只按 exceptionId 写,不跨租户。
///
[Fact]
public void TenantIsolation_Preserved()
{
var code = Scheduler();
Assert.Contains("x.TenantId == tenantId", code);
var start = code.IndexOf("TryWriteSystemRecoveredTimelineAsync(long exceptionId", StringComparison.Ordinal);
Assert.True(start > 0);
var method = code[start..(start + 1600)];
// 时间线写入只按主键定位,不引入任何工厂维度
Assert.Contains("t.ExceptionId == exceptionId", method);
Assert.DoesNotContain("FactoryId", method);
}
}