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); 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); } }