S8ConfigConsolidationTests.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  3. /// <summary>
  4. /// S8-CONFIG-CONSOLIDATION-P0C1:配置中心第一批瘦身。
  5. ///
  6. /// <para><b>本批只动 UI 与文案,不动任何 Runtime Authority。</b>
  7. /// 因此这些守卫全部是源码级断言:既要证明「过时入口 / 假层级 / 技术细节确实从正常视图消失了」,
  8. /// 也要证明「后端 fallback、责任池、权限强制点一个都没被顺手削掉」。</para>
  9. ///
  10. /// <para><b>为什么要有 T11/T12 这类保值断言</b>:把一个字段从表单里删掉最容易犯的错,
  11. /// 是提交时不再回传它 —— 于是保存别的字段就把历史值静默写成 NULL。
  12. /// 字段可以退出界面,但数据不能因此蒸发。</para>
  13. /// </summary>
  14. public class S8ConfigConsolidationTests
  15. {
  16. private static readonly string PluginRoot =
  17. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  18. private static string WebFile(string relative)
  19. {
  20. var full = Path.GetFullPath(Path.Combine(PluginRoot, "../../../Web/src/", relative));
  21. Assert.True(File.Exists(full), $"前端文件不存在,路径需同步更新:{full}");
  22. return File.ReadAllText(full);
  23. }
  24. /// <summary>
  25. /// 只取<b>最外层 &lt;template&gt; 段</b>并去掉 HTML 注释 —— 用户看得见的只有这一块。
  26. /// <para>必须以最后一个 <c>&lt;/template&gt;</c> 收尾:切到文件末尾会把 <c>&lt;script&gt;</c> 里的
  27. /// 说明性注释一并算成"页面文案",从而误判。</para>
  28. /// </summary>
  29. private static string VisibleTemplate(string relative)
  30. {
  31. var page = WebFile(relative);
  32. var i = page.IndexOf("<template>", StringComparison.Ordinal);
  33. if (i < 0) return string.Empty;
  34. var j = page.LastIndexOf("</template>", StringComparison.Ordinal);
  35. var t = j > i ? page[i..(j + "</template>".Length)] : page[i..];
  36. return System.Text.RegularExpressions.Regex.Replace(
  37. t, "<!--.*?-->", string.Empty, System.Text.RegularExpressions.RegexOptions.Singleline);
  38. }
  39. /// <summary>去掉 JS/TS 行注释与块注释。</summary>
  40. private static string CodeOnly(string relative)
  41. {
  42. var s = WebFile(relative);
  43. s = System.Text.RegularExpressions.Regex.Replace(
  44. s, @"/\*.*?\*/", string.Empty, System.Text.RegularExpressions.RegexOptions.Singleline);
  45. return string.Join('\n', s.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)));
  46. }
  47. private const string Hub = "views/aidop/s8/config/S8ConfigHubPage.vue";
  48. private const string RulePage = "views/aidop/s8/config/S8WatchRuleConfigPage.vue";
  49. private const string TypePage = "views/aidop/s8/config/S8ExceptionTypeConfigPage.vue";
  50. private const string PermPage = "views/aidop/s8/config/S8ActionPermissionConfigPage.vue";
  51. private const string MenuDisplay = "utils/aidopMenuDisplay.ts";
  52. private const string ConfigApi = "views/aidop/s8/api/s8ConfigApi.ts";
  53. // ══════════════════════ Hub ══════════════════════
  54. /// <summary>T2/T3:Hub 不再有「操作员账号绑定」,该入口按真实页面改名为「异常操作权限」。</summary>
  55. [Fact]
  56. public void T2_T3_Hub_ReplacesOperatorBindingWithActionPermission()
  57. {
  58. var code = CodeOnly(Hub);
  59. // 旧身份模型的名字必须消失 —— 它指向的页面早已换成异常操作权限
  60. Assert.DoesNotContain("操作员账号绑定", code);
  61. Assert.DoesNotContain("操作员与员工的绑定维护", code);
  62. Assert.DoesNotContain("EmployeeMaster", code);
  63. Assert.Contains("title: '异常操作权限'", code);
  64. Assert.Contains("设置哪些角色可以查看、认领、转派、处理和复核异常。", code);
  65. }
  66. /// <summary>T4:Hub 不再有「通知分层」入口 —— 通知对象的 Authority 已在规则弹窗。</summary>
  67. [Fact]
  68. public void T4_Hub_HasNoNotificationLayerEntry()
  69. {
  70. var code = CodeOnly(Hub);
  71. Assert.DoesNotContain("/aidop/s8/config/notifications", code);
  72. Assert.DoesNotContain("title: '通知分层'", code);
  73. }
  74. /// <summary>T15:S9 KPI 目标值配置<b>保留不动</b> —— 它不是 legacy,本批明确不碰。</summary>
  75. [Fact]
  76. public void T15_Hub_KeepsS9KpiTargetEntryUnchanged()
  77. {
  78. var code = CodeOnly(Hub);
  79. Assert.Contains("/aidop/s8/config/kpi-targets", code);
  80. Assert.Contains("S9 KPI 目标值配置", code);
  81. Assert.Contains("运营指标目标值,支持按工厂覆盖平台默认", code);
  82. }
  83. /// <summary>Hub 保留的其余入口不得被误删。</summary>
  84. [Fact]
  85. public void Hub_KeepsAllStillNeededEntries()
  86. {
  87. var code = CodeOnly(Hub);
  88. foreach (var path in new[]
  89. {
  90. "/aidop/s8/config/scenes",
  91. "/aidop/s8/config/exception-types",
  92. "/aidop/s8/config/watch-rules",
  93. "/aidop/s8/config/roles",
  94. "/aidop/s8/config/dashboard-cells",
  95. "/aidop/s8/config/kpi-targets",
  96. })
  97. Assert.Contains(path, code);
  98. }
  99. // ══════════════════════ 通知分层入口退役 ══════════════════════
  100. /// <summary>
  101. /// T5:通知分层页退出正常菜单 —— 但<b>只隐藏入口,不删路由</b>。
  102. /// <para>删路由会让直达 URL 一起失效,排障与存量数据核对就没有入口了。</para>
  103. /// </summary>
  104. [Fact]
  105. public void T5_NotificationLayerMenu_IsHiddenNotDeleted()
  106. {
  107. var code = CodeOnly(MenuDisplay);
  108. Assert.Contains("S8_RETIRED_MENU_PATHS", code);
  109. Assert.Contains("'/aidop/s8/config/notifications'", code);
  110. Assert.Contains("hideRetiredAidopMenus", code);
  111. Assert.Contains("isHide = true", code);
  112. // 路由补齐逻辑仍在 —— 直达 URL 不能因为菜单隐藏而 404
  113. Assert.Contains("S8_CONFIG_HIDDEN_ROUTES", code);
  114. }
  115. /// <summary>
  116. /// 后端<b>定义</b>保留,但 Runtime<b>消费</b>已归零。
  117. ///
  118. /// <para><b>本守卫的判据随 S8-LEGACY-NOTIFY-DECOMMISSION-1 翻转了一半</b>:
  119. /// P0-C1 只退 UI 入口、后端 fallback 必须留着;P0-C2 明确决定切断 Runtime 回落。
  120. /// 因此现在要同时守两件事——① 派发入口不得再读旧表(下一条断言);
  121. /// ② Entity / Service / Controller / Seed 类<b>仍在原地</b>,
  122. /// 物理删除属 backend dead code 清理,留给 P1-C3,不与 Runtime 退役混批。</para>
  123. /// </summary>
  124. [Fact]
  125. public void NotificationLayer_BackendFallback_IsFullyKept()
  126. {
  127. var resolver = File.ReadAllText(Path.Combine(
  128. PluginRoot, "Service", "S8", "S8NotificationLayerResolver.cs"));
  129. // Runtime 消费归零
  130. Assert.DoesNotContain("AdoS8NotificationLayer", resolver);
  131. Assert.DoesNotContain("S8NotificationLayerMerge", resolver);
  132. foreach (var kept in new[]
  133. {
  134. "Service/S8/S8NotificationLayerService.cs",
  135. "Service/S8/S8NotificationLayerMerge.cs",
  136. "Entity/S8/AdoS8NotificationLayer.cs",
  137. "SeedData/S8NotificationLayerSeedData.cs",
  138. "Controllers/S8/AdoS8ConfigNotificationLayersController.cs",
  139. })
  140. Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
  141. $"后端 legacy 通知分层链路被误删:{kept}");
  142. }
  143. /// <summary>T16 之一:L1/L2/L3 不得出现在<b>仍在正常菜单里</b>的任何 S8 页面。</summary>
  144. [Fact]
  145. public void T16_LevelSemantics_AbsentFromNormallyReachablePages()
  146. {
  147. foreach (var page in new[] { Hub, RulePage, TypePage, PermPage })
  148. {
  149. var t = VisibleTemplate(page);
  150. foreach (var banned in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR", "L1 操作员", "L2 主管", "L3 总监", "通知层级" })
  151. Assert.DoesNotContain(banned, t);
  152. }
  153. }
  154. // ══════════════════════ Rule Tab3 ══════════════════════
  155. /// <summary>T6/T7:四页签仍在,责任分配页只表达处理 / 复核 / 升级三种责任。</summary>
  156. [Fact]
  157. public void T6_T7_RuleDialog_KeepsFourTabsAndThreeResponsibilities()
  158. {
  159. var page = WebFile(RulePage);
  160. foreach (var tab in new[] { "规则信息", "运行设置", "责任分配", "通知设置" })
  161. Assert.Contains($"label=\"{tab}\"", page);
  162. Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(page, "<el-tab-pane ").Count);
  163. var code = CodeOnly(RulePage);
  164. foreach (var pool in new[] { "'HANDLER'", "'REVIEWER'", "'ESCALATION'" })
  165. Assert.Contains(pool, code);
  166. foreach (var title in new[] { "处理人员", "复核人员", "升级人员" })
  167. Assert.Contains(title, code);
  168. }
  169. /// <summary>
  170. /// T8:假审批层级与流程诊断整块退出责任分配页。
  171. ///
  172. /// <para>「班组长 / 部门负责人 / 厂级」实测在每个租户里审批人配置完全相同,
  173. /// 是一层不存在的组织层级;而审批节点根本不决定谁能点通过 —— 那由 verifier_user_id 判定。
  174. /// 把它们摆在责任分配页,会让管理员以为改节点就能改责任人。</para>
  175. /// </summary>
  176. [Fact]
  177. public void T8_RuleTab3_DropsFakeApprovalHierarchyAndFlowDiagnostics()
  178. {
  179. var t = VisibleTemplate(RulePage);
  180. foreach (var banned in new[]
  181. {
  182. "班组长", "部门负责人", "厂级", "负责人确认",
  183. "ApproverType", "approverType", "ROLE_QC",
  184. "本租户实际解析", "高级信息", "流程诊断", "审批节点",
  185. })
  186. Assert.DoesNotContain(banned, t);
  187. // 诊断相关的前端状态与调用一并清掉,不留死代码
  188. var code = CodeOnly(RulePage);
  189. foreach (var dead in new[] { "reviewStages", "escalateRoles", "loadReviewSource", "goApprovalConfig", "reviewSource" })
  190. Assert.DoesNotContain(dead, code);
  191. }
  192. /// <summary>ApprovalFlow 引擎与 S8 的流程回调一个都没动 —— 退役的是业务 UI,不是 Runtime。</summary>
  193. [Fact]
  194. public void RuleTab3Cleanup_DoesNotTouchApprovalFlowRuntime()
  195. {
  196. foreach (var kept in new[]
  197. {
  198. "Service/S8/ExceptionClosureBizHandler.cs",
  199. "Service/S8/ExceptionEscalationBizHandler.cs",
  200. "Service/S8/S8ReviewSourceService.cs",
  201. })
  202. Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
  203. $"ApprovalFlow 相关服务被误删:{kept}");
  204. // 后端只读诊断端点保留,供排障与后续诊断入口复用
  205. var controller = File.ReadAllText(Path.Combine(
  206. PluginRoot, "Controllers", "S8", "AdoS8ConfigWatchRulesController.cs"));
  207. Assert.Contains("review-source", controller);
  208. }
  209. // ══════════════════════ 异常类型 ══════════════════════
  210. /// <summary>T9/T10:责任角色 / 升级角色两个输入退出编辑界面。</summary>
  211. [Fact]
  212. public void T9_T10_ExceptionType_RemovesDeadRoleInputs()
  213. {
  214. var t = VisibleTemplate(TypePage);
  215. Assert.DoesNotContain("label=\"责任角色\"", t);
  216. Assert.DoesNotContain("label=\"升级角色\"", t);
  217. Assert.DoesNotContain("form.ownerRoleCode", t);
  218. Assert.DoesNotContain("form.escalateRoleCode", t);
  219. }
  220. /// <summary>
  221. /// T11/T12:<b>字段退出界面,但历史值必须原样回传</b>。
  222. ///
  223. /// <para>断言完整链路:<c>openEdit</c> 从行数据填 form → <c>payload</c> 从 form 取值。
  224. /// 少了任何一环,保存其它字段就会把 owner/escalate 静默写成 null。</para>
  225. /// </summary>
  226. [Fact]
  227. public void T11_T12_ExceptionType_PreservesLegacyRoleValuesOnSave()
  228. {
  229. var code = CodeOnly(TypePage);
  230. // ① 载入:编辑时从行数据回填(不是清空)
  231. Assert.Contains("form.ownerRoleCode = row.ownerRoleCode ?? '';", code);
  232. Assert.Contains("form.escalateRoleCode = row.escalateRoleCode ?? '';", code);
  233. // ② 提交:仍在 payload 里,取自 form
  234. Assert.Contains("ownerRoleCode: form.ownerRoleCode || null,", code);
  235. Assert.Contains("escalateRoleCode: form.escalateRoleCode || null,", code);
  236. // ③ 绝不硬写空 —— 但只在 **payload 构造块** 内判断:
  237. // form 的初始声明本来就是 ownerRoleCode: ''(新建时无历史值),那是正常的,
  238. // 不加界定会把它误判成"抹值"。
  239. var pStart = code.IndexOf("const payload: S8ExceptionTypeConfigPayload = {", StringComparison.Ordinal);
  240. Assert.True(pStart > 0, "未找到 payload 构造块");
  241. var pEnd = code.IndexOf("\n\t};", pStart, StringComparison.Ordinal);
  242. Assert.True(pEnd > pStart, "payload 构造块未正常闭合");
  243. var payload = code[pStart..pEnd];
  244. foreach (var wipe in new[]
  245. {
  246. "ownerRoleCode: null,", "escalateRoleCode: null,",
  247. "ownerRoleCode: '',", "escalateRoleCode: '',",
  248. "ownerRoleCode: undefined", "escalateRoleCode: undefined",
  249. })
  250. Assert.DoesNotContain(wipe, payload);
  251. }
  252. /// <summary>DB 字段与后端契约保留 —— 本批只动界面。</summary>
  253. [Fact]
  254. public void ExceptionType_BackendFieldsAreKept()
  255. {
  256. var entity = File.ReadAllText(Path.Combine(
  257. PluginRoot, "Entity", "S8", "AdoS8ExceptionType.cs"));
  258. Assert.Contains("owner_role_code", entity);
  259. Assert.Contains("escalate_role_code", entity);
  260. }
  261. /// <summary>
  262. /// T8(§8):本页对 legacy <c>/config/roles</c> 的前端依赖归零。
  263. /// <para>那两个下拉是它唯一的用途;输入没了,接口调用也不该留着。</para>
  264. /// </summary>
  265. [Fact]
  266. public void ExceptionType_NoLongerCallsLegacyRolesApi()
  267. {
  268. var code = CodeOnly(TypePage);
  269. Assert.DoesNotContain("/api/aidop/s8/config/roles", code);
  270. Assert.DoesNotContain("roleOptions", code);
  271. // 场景下拉必须还在 —— 它是本页真正需要的
  272. Assert.Contains("s8ConfigApi.scenes()", code);
  273. Assert.Contains("sceneOptions", code);
  274. }
  275. // ══════════════════════ 异常操作权限 ══════════════════════
  276. /// <summary>T13/T14:页面保留全部授权能力,但不再暴露内部接口路径。</summary>
  277. [Fact]
  278. public void T13_T14_ActionPermission_KeepsCapabilityDropsApiHint()
  279. {
  280. var t = VisibleTemplate(PermPage);
  281. // 能力保留
  282. Assert.Contains("授权角色", t);
  283. Assert.Contains("动作码", t);
  284. Assert.Contains("保存", t);
  285. // 技术细节退出
  286. Assert.DoesNotContain("对应接口", t);
  287. Assert.DoesNotContain("apiHint", t);
  288. foreach (var tech in new[] { "Controller", "Attribute", "Authorizer", "/api/aidop" })
  289. Assert.DoesNotContain(tech, t);
  290. // 说明按业务语言收敛,且仍讲清 fail-closed 语义
  291. Assert.Contains("设置哪些角色可以执行对应的异常操作;未授权的操作默认禁止。", t);
  292. }
  293. /// <summary>权限强制链路一个都没动 —— 这是当前真实安全 Authority。</summary>
  294. [Fact]
  295. public void ActionPermission_EnforcementChainIsUntouched()
  296. {
  297. foreach (var kept in new[]
  298. {
  299. "Infrastructure/S8/S8ExceptionActionAttribute.cs",
  300. "Infrastructure/S8/S8ExceptionActionAuthorizer.cs",
  301. "Entity/S8/AdoS8ExceptionActionRole.cs",
  302. })
  303. Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
  304. $"权限强制链路被误删:{kept}");
  305. }
  306. // ══════════════════════ Employee / Operator 残留 ══════════════════════
  307. /// <summary>T16:正常 S8 UI 不再出现旧身份模型术语。</summary>
  308. [Fact]
  309. public void T16_NormalUi_HasNoOperatorBindingVocabulary()
  310. {
  311. foreach (var page in new[] { Hub, RulePage, TypePage, PermPage })
  312. {
  313. var t = VisibleTemplate(page);
  314. foreach (var banned in new[] { "操作员账号绑定", "员工绑定", "EmployeeMaster", "绑定维护" })
  315. Assert.DoesNotContain(banned, t);
  316. }
  317. }
  318. /// <summary>死 DTO 清理:操作员绑定页退役后遗留的行类型与接口封装。</summary>
  319. [Fact]
  320. public void FrontendDeadCode_OperatorBindingRemnantsAreGone()
  321. {
  322. var api = CodeOnly(ConfigApi);
  323. Assert.DoesNotContain("alreadyBoundEmployeeId", api);
  324. Assert.DoesNotContain("S8ConfigSysUserRow", api);
  325. Assert.DoesNotContain("importRolesFromSys", api);
  326. }
  327. /// <summary>
  328. /// 410 墓碑接口保持原状:本批只确认「正常前端零调用」,不做后端兼容清理。
  329. /// </summary>
  330. [Fact]
  331. public void TombstoneApis_RemainAndHaveNoFrontendConsumer()
  332. {
  333. foreach (var kept in new[]
  334. {
  335. "Controllers/S8/AdoS8ConfigBindingsController.cs",
  336. "Controllers/S8/AdoS8MasterDataController.cs",
  337. })
  338. Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
  339. $"墓碑接口被误删:{kept}");
  340. var webRoot = Path.GetFullPath(Path.Combine(PluginRoot, "../../../Web/src/views/aidop/s8"));
  341. // 只看**真实调用**:注释里写「取代已退役的 master-data/employees」是交代历史,
  342. // 不是消费者。把注释算进来会让"说明清楚"反而变成测试失败。
  343. var all = string.Join('\n', Directory.GetFiles(webRoot, "*.*", SearchOption.AllDirectories)
  344. .Where(f => f.EndsWith(".ts") || f.EndsWith(".vue"))
  345. .SelectMany(f => File.ReadAllLines(f))
  346. .Where(l =>
  347. {
  348. var t = l.TrimStart();
  349. return !t.StartsWith("//", StringComparison.Ordinal)
  350. && !t.StartsWith("*", StringComparison.Ordinal)
  351. && !t.StartsWith("/*", StringComparison.Ordinal);
  352. }));
  353. Assert.DoesNotContain("master-data/employees", all);
  354. Assert.DoesNotContain("config/bindings", all);
  355. }
  356. }