using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-CONFIG-CONSOLIDATION-P0C1:配置中心第一批瘦身。
///
/// 本批只动 UI 与文案,不动任何 Runtime Authority。
/// 因此这些守卫全部是源码级断言:既要证明「过时入口 / 假层级 / 技术细节确实从正常视图消失了」,
/// 也要证明「后端 fallback、责任池、权限强制点一个都没被顺手削掉」。
///
/// 为什么要有 T11/T12 这类保值断言:把一个字段从表单里删掉最容易犯的错,
/// 是提交时不再回传它 —— 于是保存别的字段就把历史值静默写成 NULL。
/// 字段可以退出界面,但数据不能因此蒸发。
///
public class S8ConfigConsolidationTests
{
private static readonly string PluginRoot =
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private static string WebFile(string relative)
{
var full = Path.GetFullPath(Path.Combine(PluginRoot, "../../../Web/src/", relative));
Assert.True(File.Exists(full), $"前端文件不存在,路径需同步更新:{full}");
return File.ReadAllText(full);
}
///
/// 只取最外层 <template> 段并去掉 HTML 注释 —— 用户看得见的只有这一块。
/// 必须以最后一个 </template> 收尾:切到文件末尾会把 <script> 里的
/// 说明性注释一并算成"页面文案",从而误判。
///
private static string VisibleTemplate(string relative)
{
var page = WebFile(relative);
var i = page.IndexOf("", StringComparison.Ordinal);
if (i < 0) return string.Empty;
var j = page.LastIndexOf("", StringComparison.Ordinal);
var t = j > i ? page[i..(j + "".Length)] : page[i..];
return System.Text.RegularExpressions.Regex.Replace(
t, "", string.Empty, System.Text.RegularExpressions.RegexOptions.Singleline);
}
/// 去掉 JS/TS 行注释与块注释。
private static string CodeOnly(string relative)
{
var s = WebFile(relative);
s = System.Text.RegularExpressions.Regex.Replace(
s, @"/\*.*?\*/", string.Empty, System.Text.RegularExpressions.RegexOptions.Singleline);
return string.Join('\n', s.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)));
}
private const string Hub = "views/aidop/s8/config/S8ConfigHubPage.vue";
private const string RulePage = "views/aidop/s8/config/S8WatchRuleConfigPage.vue";
private const string TypePage = "views/aidop/s8/config/S8ExceptionTypeConfigPage.vue";
private const string PermPage = "views/aidop/s8/config/S8ActionPermissionConfigPage.vue";
private const string MenuDisplay = "utils/aidopMenuDisplay.ts";
private const string ConfigApi = "views/aidop/s8/api/s8ConfigApi.ts";
// ══════════════════════ Hub ══════════════════════
/// T2/T3:Hub 不再有「操作员账号绑定」,该入口按真实页面改名为「异常操作权限」。
[Fact]
public void T2_T3_Hub_ReplacesOperatorBindingWithActionPermission()
{
var code = CodeOnly(Hub);
// 旧身份模型的名字必须消失 —— 它指向的页面早已换成异常操作权限
Assert.DoesNotContain("操作员账号绑定", code);
Assert.DoesNotContain("操作员与员工的绑定维护", code);
Assert.DoesNotContain("EmployeeMaster", code);
Assert.Contains("title: '异常操作权限'", code);
Assert.Contains("设置哪些角色可以查看、认领、转派、处理和复核异常。", code);
}
/// T4:Hub 不再有「通知分层」入口 —— 通知对象的 Authority 已在规则弹窗。
[Fact]
public void T4_Hub_HasNoNotificationLayerEntry()
{
var code = CodeOnly(Hub);
Assert.DoesNotContain("/aidop/s8/config/notifications", code);
Assert.DoesNotContain("title: '通知分层'", code);
}
/// T15:S9 KPI 目标值配置保留不动 —— 它不是 legacy,本批明确不碰。
[Fact]
public void T15_Hub_KeepsS9KpiTargetEntryUnchanged()
{
var code = CodeOnly(Hub);
Assert.Contains("/aidop/s8/config/kpi-targets", code);
Assert.Contains("S9 KPI 目标值配置", code);
Assert.Contains("运营指标目标值,支持按工厂覆盖平台默认", code);
}
/// Hub 保留的其余入口不得被误删。
[Fact]
public void Hub_KeepsAllStillNeededEntries()
{
var code = CodeOnly(Hub);
foreach (var path in new[]
{
"/aidop/s8/config/scenes",
"/aidop/s8/config/exception-types",
"/aidop/s8/config/watch-rules",
"/aidop/s8/config/roles",
"/aidop/s8/config/dashboard-cells",
"/aidop/s8/config/kpi-targets",
})
Assert.Contains(path, code);
}
// ══════════════════════ 通知分层入口退役 ══════════════════════
///
/// T5:通知分层页退出正常菜单 —— 但只隐藏入口,不删路由。
/// 删路由会让直达 URL 一起失效,排障与存量数据核对就没有入口了。
///
[Fact]
public void T5_NotificationLayerMenu_IsHiddenNotDeleted()
{
var code = CodeOnly(MenuDisplay);
Assert.Contains("S8_RETIRED_MENU_PATHS", code);
Assert.Contains("'/aidop/s8/config/notifications'", code);
Assert.Contains("hideRetiredAidopMenus", code);
Assert.Contains("isHide = true", code);
// 路由补齐逻辑仍在 —— 直达 URL 不能因为菜单隐藏而 404
Assert.Contains("S8_CONFIG_HIDDEN_ROUTES", code);
}
///
/// 后端定义保留,但 Runtime消费已归零。
///
/// 本守卫的判据随 S8-LEGACY-NOTIFY-DECOMMISSION-1 翻转了一半:
/// P0-C1 只退 UI 入口、后端 fallback 必须留着;P0-C2 明确决定切断 Runtime 回落。
/// 因此现在要同时守两件事——① 派发入口不得再读旧表(下一条断言);
/// ② Entity / Service / Controller / Seed 类仍在原地,
/// 物理删除属 backend dead code 清理,留给 P1-C3,不与 Runtime 退役混批。
///
[Fact]
public void NotificationLayer_BackendFallback_IsFullyKept()
{
var resolver = File.ReadAllText(Path.Combine(
PluginRoot, "Service", "S8", "S8NotificationLayerResolver.cs"));
// Runtime 消费归零
Assert.DoesNotContain("AdoS8NotificationLayer", resolver);
Assert.DoesNotContain("S8NotificationLayerMerge", resolver);
foreach (var kept in new[]
{
"Service/S8/S8NotificationLayerService.cs",
"Service/S8/S8NotificationLayerMerge.cs",
"Entity/S8/AdoS8NotificationLayer.cs",
"SeedData/S8NotificationLayerSeedData.cs",
"Controllers/S8/AdoS8ConfigNotificationLayersController.cs",
})
Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
$"后端 legacy 通知分层链路被误删:{kept}");
}
/// T16 之一:L1/L2/L3 不得出现在仍在正常菜单里的任何 S8 页面。
[Fact]
public void T16_LevelSemantics_AbsentFromNormallyReachablePages()
{
foreach (var page in new[] { Hub, RulePage, TypePage, PermPage })
{
var t = VisibleTemplate(page);
foreach (var banned in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR", "L1 操作员", "L2 主管", "L3 总监", "通知层级" })
Assert.DoesNotContain(banned, t);
}
}
// ══════════════════════ Rule Tab3 ══════════════════════
/// T6/T7:四页签仍在,责任分配页只表达处理 / 复核 / 升级三种责任。
[Fact]
public void T6_T7_RuleDialog_KeepsFourTabsAndThreeResponsibilities()
{
var page = WebFile(RulePage);
foreach (var tab in new[] { "规则信息", "运行设置", "责任分配", "通知设置" })
Assert.Contains($"label=\"{tab}\"", page);
Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(page, "
/// T8:假审批层级与流程诊断整块退出责任分配页。
///
/// 「班组长 / 部门负责人 / 厂级」实测在每个租户里审批人配置完全相同,
/// 是一层不存在的组织层级;而审批节点根本不决定谁能点通过 —— 那由 verifier_user_id 判定。
/// 把它们摆在责任分配页,会让管理员以为改节点就能改责任人。
///
[Fact]
public void T8_RuleTab3_DropsFakeApprovalHierarchyAndFlowDiagnostics()
{
var t = VisibleTemplate(RulePage);
foreach (var banned in new[]
{
"班组长", "部门负责人", "厂级", "负责人确认",
"ApproverType", "approverType", "ROLE_QC",
"本租户实际解析", "高级信息", "流程诊断", "审批节点",
})
Assert.DoesNotContain(banned, t);
// 诊断相关的前端状态与调用一并清掉,不留死代码
var code = CodeOnly(RulePage);
foreach (var dead in new[] { "reviewStages", "escalateRoles", "loadReviewSource", "goApprovalConfig", "reviewSource" })
Assert.DoesNotContain(dead, code);
}
/// ApprovalFlow 引擎与 S8 的流程回调一个都没动 —— 退役的是业务 UI,不是 Runtime。
[Fact]
public void RuleTab3Cleanup_DoesNotTouchApprovalFlowRuntime()
{
foreach (var kept in new[]
{
"Service/S8/ExceptionClosureBizHandler.cs",
"Service/S8/ExceptionEscalationBizHandler.cs",
"Service/S8/S8ReviewSourceService.cs",
})
Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
$"ApprovalFlow 相关服务被误删:{kept}");
// 后端只读诊断端点保留,供排障与后续诊断入口复用
var controller = File.ReadAllText(Path.Combine(
PluginRoot, "Controllers", "S8", "AdoS8ConfigWatchRulesController.cs"));
Assert.Contains("review-source", controller);
}
// ══════════════════════ 异常类型 ══════════════════════
/// T9/T10:责任角色 / 升级角色两个输入退出编辑界面。
[Fact]
public void T9_T10_ExceptionType_RemovesDeadRoleInputs()
{
var t = VisibleTemplate(TypePage);
Assert.DoesNotContain("label=\"责任角色\"", t);
Assert.DoesNotContain("label=\"升级角色\"", t);
Assert.DoesNotContain("form.ownerRoleCode", t);
Assert.DoesNotContain("form.escalateRoleCode", t);
}
///
/// T11/T12:字段退出界面,但历史值必须原样回传。
///
/// 断言完整链路:openEdit 从行数据填 form → payload 从 form 取值。
/// 少了任何一环,保存其它字段就会把 owner/escalate 静默写成 null。
///
[Fact]
public void T11_T12_ExceptionType_PreservesLegacyRoleValuesOnSave()
{
var code = CodeOnly(TypePage);
// ① 载入:编辑时从行数据回填(不是清空)
Assert.Contains("form.ownerRoleCode = row.ownerRoleCode ?? '';", code);
Assert.Contains("form.escalateRoleCode = row.escalateRoleCode ?? '';", code);
// ② 提交:仍在 payload 里,取自 form
Assert.Contains("ownerRoleCode: form.ownerRoleCode || null,", code);
Assert.Contains("escalateRoleCode: form.escalateRoleCode || null,", code);
// ③ 绝不硬写空 —— 但只在 **payload 构造块** 内判断:
// form 的初始声明本来就是 ownerRoleCode: ''(新建时无历史值),那是正常的,
// 不加界定会把它误判成"抹值"。
var pStart = code.IndexOf("const payload: S8ExceptionTypeConfigPayload = {", StringComparison.Ordinal);
Assert.True(pStart > 0, "未找到 payload 构造块");
var pEnd = code.IndexOf("\n\t};", pStart, StringComparison.Ordinal);
Assert.True(pEnd > pStart, "payload 构造块未正常闭合");
var payload = code[pStart..pEnd];
foreach (var wipe in new[]
{
"ownerRoleCode: null,", "escalateRoleCode: null,",
"ownerRoleCode: '',", "escalateRoleCode: '',",
"ownerRoleCode: undefined", "escalateRoleCode: undefined",
})
Assert.DoesNotContain(wipe, payload);
}
/// DB 字段与后端契约保留 —— 本批只动界面。
[Fact]
public void ExceptionType_BackendFieldsAreKept()
{
var entity = File.ReadAllText(Path.Combine(
PluginRoot, "Entity", "S8", "AdoS8ExceptionType.cs"));
Assert.Contains("owner_role_code", entity);
Assert.Contains("escalate_role_code", entity);
}
///
/// T8(§8):本页对 legacy /config/roles 的前端依赖归零。
/// 那两个下拉是它唯一的用途;输入没了,接口调用也不该留着。
///
[Fact]
public void ExceptionType_NoLongerCallsLegacyRolesApi()
{
var code = CodeOnly(TypePage);
Assert.DoesNotContain("/api/aidop/s8/config/roles", code);
Assert.DoesNotContain("roleOptions", code);
// 场景下拉必须还在 —— 它是本页真正需要的
Assert.Contains("s8ConfigApi.scenes()", code);
Assert.Contains("sceneOptions", code);
}
// ══════════════════════ 异常操作权限 ══════════════════════
/// T13/T14:页面保留全部授权能力,但不再暴露内部接口路径。
[Fact]
public void T13_T14_ActionPermission_KeepsCapabilityDropsApiHint()
{
var t = VisibleTemplate(PermPage);
// 能力保留
Assert.Contains("授权角色", t);
Assert.Contains("动作码", t);
Assert.Contains("保存", t);
// 技术细节退出
Assert.DoesNotContain("对应接口", t);
Assert.DoesNotContain("apiHint", t);
foreach (var tech in new[] { "Controller", "Attribute", "Authorizer", "/api/aidop" })
Assert.DoesNotContain(tech, t);
// 说明按业务语言收敛,且仍讲清 fail-closed 语义
Assert.Contains("设置哪些角色可以执行对应的异常操作;未授权的操作默认禁止。", t);
}
/// 权限强制链路一个都没动 —— 这是当前真实安全 Authority。
[Fact]
public void ActionPermission_EnforcementChainIsUntouched()
{
foreach (var kept in new[]
{
"Infrastructure/S8/S8ExceptionActionAttribute.cs",
"Infrastructure/S8/S8ExceptionActionAuthorizer.cs",
"Entity/S8/AdoS8ExceptionActionRole.cs",
})
Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
$"权限强制链路被误删:{kept}");
}
// ══════════════════════ Employee / Operator 残留 ══════════════════════
/// T16:正常 S8 UI 不再出现旧身份模型术语。
[Fact]
public void T16_NormalUi_HasNoOperatorBindingVocabulary()
{
foreach (var page in new[] { Hub, RulePage, TypePage, PermPage })
{
var t = VisibleTemplate(page);
foreach (var banned in new[] { "操作员账号绑定", "员工绑定", "EmployeeMaster", "绑定维护" })
Assert.DoesNotContain(banned, t);
}
}
/// 死 DTO 清理:操作员绑定页退役后遗留的行类型与接口封装。
[Fact]
public void FrontendDeadCode_OperatorBindingRemnantsAreGone()
{
var api = CodeOnly(ConfigApi);
Assert.DoesNotContain("alreadyBoundEmployeeId", api);
Assert.DoesNotContain("S8ConfigSysUserRow", api);
Assert.DoesNotContain("importRolesFromSys", api);
}
///
/// 410 墓碑接口保持原状:本批只确认「正常前端零调用」,不做后端兼容清理。
///
[Fact]
public void TombstoneApis_RemainAndHaveNoFrontendConsumer()
{
foreach (var kept in new[]
{
"Controllers/S8/AdoS8ConfigBindingsController.cs",
"Controllers/S8/AdoS8MasterDataController.cs",
})
Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
$"墓碑接口被误删:{kept}");
var webRoot = Path.GetFullPath(Path.Combine(PluginRoot, "../../../Web/src/views/aidop/s8"));
// 只看**真实调用**:注释里写「取代已退役的 master-data/employees」是交代历史,
// 不是消费者。把注释算进来会让"说明清楚"反而变成测试失败。
var all = string.Join('\n', Directory.GetFiles(webRoot, "*.*", SearchOption.AllDirectories)
.Where(f => f.EndsWith(".ts") || f.EndsWith(".vue"))
.SelectMany(f => File.ReadAllLines(f))
.Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("//", StringComparison.Ordinal)
&& !t.StartsWith("*", StringComparison.Ordinal)
&& !t.StartsWith("/*", StringComparison.Ordinal);
}));
Assert.DoesNotContain("master-data/employees", all);
Assert.DoesNotContain("config/bindings", all);
}
}