| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-CONFIG-CONSOLIDATION-P0C1:配置中心第一批瘦身。
- ///
- /// <para><b>本批只动 UI 与文案,不动任何 Runtime Authority。</b>
- /// 因此这些守卫全部是源码级断言:既要证明「过时入口 / 假层级 / 技术细节确实从正常视图消失了」,
- /// 也要证明「后端 fallback、责任池、权限强制点一个都没被顺手削掉」。</para>
- ///
- /// <para><b>为什么要有 T11/T12 这类保值断言</b>:把一个字段从表单里删掉最容易犯的错,
- /// 是提交时不再回传它 —— 于是保存别的字段就把历史值静默写成 NULL。
- /// 字段可以退出界面,但数据不能因此蒸发。</para>
- /// </summary>
- 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);
- }
- /// <summary>
- /// 只取<b>最外层 <template> 段</b>并去掉 HTML 注释 —— 用户看得见的只有这一块。
- /// <para>必须以最后一个 <c></template></c> 收尾:切到文件末尾会把 <c><script></c> 里的
- /// 说明性注释一并算成"页面文案",从而误判。</para>
- /// </summary>
- private static string VisibleTemplate(string relative)
- {
- var page = WebFile(relative);
- var i = page.IndexOf("<template>", StringComparison.Ordinal);
- if (i < 0) return string.Empty;
- var j = page.LastIndexOf("</template>", StringComparison.Ordinal);
- var t = j > i ? page[i..(j + "</template>".Length)] : page[i..];
- return System.Text.RegularExpressions.Regex.Replace(
- t, "<!--.*?-->", string.Empty, System.Text.RegularExpressions.RegexOptions.Singleline);
- }
- /// <summary>去掉 JS/TS 行注释与块注释。</summary>
- 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 ══════════════════════
- /// <summary>T2/T3:Hub 不再有「操作员账号绑定」,该入口按真实页面改名为「异常操作权限」。</summary>
- [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);
- }
- /// <summary>T4:Hub 不再有「通知分层」入口 —— 通知对象的 Authority 已在规则弹窗。</summary>
- [Fact]
- public void T4_Hub_HasNoNotificationLayerEntry()
- {
- var code = CodeOnly(Hub);
- Assert.DoesNotContain("/aidop/s8/config/notifications", code);
- Assert.DoesNotContain("title: '通知分层'", code);
- }
- /// <summary>T15:S9 KPI 目标值配置<b>保留不动</b> —— 它不是 legacy,本批明确不碰。</summary>
- [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);
- }
- /// <summary>Hub 保留的其余入口不得被误删。</summary>
- [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);
- }
- // ══════════════════════ 通知分层入口退役 ══════════════════════
- /// <summary>
- /// T5:通知分层页退出正常菜单 —— 但<b>只隐藏入口,不删路由</b>。
- /// <para>删路由会让直达 URL 一起失效,排障与存量数据核对就没有入口了。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 后端<b>定义</b>保留,但 Runtime<b>消费</b>已归零。
- ///
- /// <para><b>本守卫的判据随 S8-LEGACY-NOTIFY-DECOMMISSION-1 翻转了一半</b>:
- /// P0-C1 只退 UI 入口、后端 fallback 必须留着;P0-C2 明确决定切断 Runtime 回落。
- /// 因此现在要同时守两件事——① 派发入口不得再读旧表(下一条断言);
- /// ② Entity / Service / Controller / Seed 类<b>仍在原地</b>,
- /// 物理删除属 backend dead code 清理,留给 P1-C3,不与 Runtime 退役混批。</para>
- /// </summary>
- [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}");
- }
- /// <summary>T16 之一:L1/L2/L3 不得出现在<b>仍在正常菜单里</b>的任何 S8 页面。</summary>
- [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 ══════════════════════
- /// <summary>T6/T7:四页签仍在,责任分配页只表达处理 / 复核 / 升级三种责任。</summary>
- [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, "<el-tab-pane ").Count);
- var code = CodeOnly(RulePage);
- foreach (var pool in new[] { "'HANDLER'", "'REVIEWER'", "'ESCALATION'" })
- Assert.Contains(pool, code);
- foreach (var title in new[] { "处理人员", "复核人员", "升级人员" })
- Assert.Contains(title, code);
- }
- /// <summary>
- /// T8:假审批层级与流程诊断整块退出责任分配页。
- ///
- /// <para>「班组长 / 部门负责人 / 厂级」实测在每个租户里审批人配置完全相同,
- /// 是一层不存在的组织层级;而审批节点根本不决定谁能点通过 —— 那由 verifier_user_id 判定。
- /// 把它们摆在责任分配页,会让管理员以为改节点就能改责任人。</para>
- /// </summary>
- [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);
- }
- /// <summary>ApprovalFlow 引擎与 S8 的流程回调一个都没动 —— 退役的是业务 UI,不是 Runtime。</summary>
- [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);
- }
- // ══════════════════════ 异常类型 ══════════════════════
- /// <summary>T9/T10:责任角色 / 升级角色两个输入退出编辑界面。</summary>
- [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);
- }
- /// <summary>
- /// T11/T12:<b>字段退出界面,但历史值必须原样回传</b>。
- ///
- /// <para>断言完整链路:<c>openEdit</c> 从行数据填 form → <c>payload</c> 从 form 取值。
- /// 少了任何一环,保存其它字段就会把 owner/escalate 静默写成 null。</para>
- /// </summary>
- [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);
- }
- /// <summary>DB 字段与后端契约保留 —— 本批只动界面。</summary>
- [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);
- }
- /// <summary>
- /// T8(§8):本页对 legacy <c>/config/roles</c> 的前端依赖归零。
- /// <para>那两个下拉是它唯一的用途;输入没了,接口调用也不该留着。</para>
- /// </summary>
- [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);
- }
- // ══════════════════════ 异常操作权限 ══════════════════════
- /// <summary>T13/T14:页面保留全部授权能力,但不再暴露内部接口路径。</summary>
- [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);
- }
- /// <summary>权限强制链路一个都没动 —— 这是当前真实安全 Authority。</summary>
- [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 残留 ══════════════════════
- /// <summary>T16:正常 S8 UI 不再出现旧身份模型术语。</summary>
- [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);
- }
- }
- /// <summary>死 DTO 清理:操作员绑定页退役后遗留的行类型与接口封装。</summary>
- [Fact]
- public void FrontendDeadCode_OperatorBindingRemnantsAreGone()
- {
- var api = CodeOnly(ConfigApi);
- Assert.DoesNotContain("alreadyBoundEmployeeId", api);
- Assert.DoesNotContain("S8ConfigSysUserRow", api);
- Assert.DoesNotContain("importRolesFromSys", api);
- }
- /// <summary>
- /// 410 墓碑接口保持原状:本批只确认「正常前端零调用」,不做后端兼容清理。
- /// </summary>
- [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);
- }
- }
|