S6ProcessInspectionEditAuthorityContractTests.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
  3. /// <summary>
  4. /// 前端源码文本契约的公共视图:**去注释**后再断言。
  5. /// 缘由:这些页面的注释里会成段引用节点码(N1_SUBMIT)、字段名(flowStatus / canSubmitReview)
  6. /// 来解释"为什么不这么做",裸文本 DoesNotContain 会把说明文字当成违规实现命中。
  7. /// </summary>
  8. internal static class VueSource
  9. {
  10. /// <summary>剥掉 /* */ 块注释、&lt;!-- --&gt; HTML 注释、以及整行 // 与 * 续行注释。</summary>
  11. public static string CodeOnly(string src)
  12. {
  13. var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
  14. s = System.Text.RegularExpressions.Regex.Replace(s, @"<!--.*?-->", "", System.Text.RegularExpressions.RegexOptions.Singleline);
  15. var kept = s.Split('\n').Where(l =>
  16. {
  17. var t = l.TrimStart();
  18. return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("* ", StringComparison.Ordinal) && t != "*";
  19. });
  20. return string.Join("\n", kept);
  21. }
  22. }
  23. /// <summary>
  24. /// S6-IPQC-EDIT-1 源码契约测试:IPQC 检查单详情页「编辑权限」归后端 Authority。
  25. ///
  26. /// 缺口:`ReturnToPrev` 后实例仍是 Running(只回退 CurrentNodeId),而详情页把编辑权限写成
  27. /// 本地表达式 `flowStatus !== 'NotStarted' → 只读`,于是主管退回回到 N1 后录入区仍锁死 ——
  28. /// 后端已允许重新提交(canSubmitReview=true),前端却不允许改数据,形成"能提交但改不动"。
  29. ///
  30. /// 修法:后端 `S6ReviewStateOutput.CanEdit`(复用与 canSubmitReview 同一份 N1 待办归属判定,
  31. /// 但不叠加 completed),前端纯消费、状态缺失时 fail-closed。
  32. ///
  33. /// 关键不变量:
  34. /// 1. canEdit ≠ canSubmitReview(后者多一个 completed 条件)——不得互相顶替;
  35. /// 2. 授权不能只看节点,必须节点 + Pending + AssigneeId==当前用户;
  36. /// 3. 首次录入(NotStarted)不得被本次修复关掉;
  37. /// 4. 前端不得保留任何本地流程推断。
  38. ///
  39. /// 沿用本仓既有做法——源码契约断言而非行为级集成测试(service 依赖 ISqlSugarClient /
  40. /// FlowEngineService,裸 xUnit 进程无法实例化)。真实状态矩阵由本批 Runtime R1–R12 覆盖。
  41. /// </summary>
  42. public class S6ProcessInspectionEditAuthorityContractTests
  43. {
  44. private static string Svc() => File.ReadAllText(FindFile(
  45. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs"));
  46. private static string Dto() => File.ReadAllText(FindFile(
  47. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "Dto", "S6ProcessInspectionReviewDto.cs"));
  48. private static string DetailPage() => File.ReadAllText(FindFile(
  49. "Web", "src", "views", "aidop", "s6", "process-quality", "ipqcBillDetail.vue"));
  50. private static string ItemPanel() => File.ReadAllText(FindFile(
  51. "Web", "src", "views", "aidop", "s6", "process-quality", "components", "IpqcBillItemPanel.vue"));
  52. private static string ApiTypes() => File.ReadAllText(FindFile(
  53. "Web", "src", "views", "aidop", "s6", "api", "productionInstruction.ts"));
  54. // ── 后端必须成为唯一 authority ──
  55. /// <summary>capability 必须落在只读状态 DTO 上(与其余 4 个按钮门控同一处)。</summary>
  56. [Fact]
  57. public void Dto_ExposesCanEditCapability()
  58. {
  59. Assert.Contains("public bool CanEdit { get; set; }", Dto());
  60. }
  61. /// <summary>编辑权限必须在 BuildStateAsync 里算,不得散落到 Controller / 前端。</summary>
  62. [Fact]
  63. public void State_ComputesCanEditInBuildState()
  64. {
  65. var body = BuildStateBody();
  66. Assert.Contains("output.CanEdit", body);
  67. }
  68. // ── §30 状态矩阵 ──
  69. /// <summary>NotStarted:首次录入必须可编辑,且**不受 completed 约束**(否则录完即锁死)。</summary>
  70. [Fact]
  71. public void Matrix_NotStarted_IsEditable()
  72. {
  73. var branch = Slice(BuildStateBody(), "output.FlowStatus = \"NotStarted\";", "return output;");
  74. Assert.Contains("output.CanEdit = true;", branch);
  75. Assert.DoesNotContain("output.CanEdit = completed", branch);
  76. }
  77. /// <summary>退回到 N1 且本人持有该待办:可编辑,且同样不叠加 completed。</summary>
  78. [Fact]
  79. public void Matrix_ReturnedToN1_IsEditable()
  80. {
  81. var branch = ReturnedBranch();
  82. Assert.Contains("output.CanEdit = true;", branch);
  83. Assert.DoesNotContain("output.CanEdit = completed", branch);
  84. }
  85. /// <summary>终态(Approved / Rejected / Cancelled / Terminated):显式不可编辑。</summary>
  86. [Fact]
  87. public void Matrix_Terminal_IsReadOnly()
  88. {
  89. var branch = Slice(BuildStateBody(),
  90. "if (inst.Status != FlowInstanceStatusEnum.Running)", "output.CurrentNodeCode = inst.CurrentNodeId;");
  91. Assert.Contains("output.CanEdit = false;", branch);
  92. }
  93. /// <summary>
  94. /// N2 主管审核 / N3 质量处置 / 本人无待办三条路径必须靠 bool 默认 false 兜底 ——
  95. /// 即这些分支里不得出现任何把 CanEdit 打开的赋值。
  96. /// </summary>
  97. [Theory]
  98. [InlineData("if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSupervisor)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)")]
  99. [InlineData("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)")]
  100. [InlineData("output.Message = \"流程进行中(当前用户无待办)\";", "return output;")]
  101. public void Matrix_NonEditableBranches_NeverOpenCanEdit(string from, string to)
  102. {
  103. var branch = Slice(BuildStateBody(), from, to);
  104. Assert.DoesNotContain("output.CanEdit = true", branch);
  105. }
  106. /// <summary>CanEdit 在整个 service 里只有三处赋值(NotStarted / 终态 / 退回 N1),防止后续被到处打开。</summary>
  107. [Fact]
  108. public void CanEdit_HasExactlyThreeAssignments()
  109. {
  110. Assert.Equal(3, CountOf(Svc(), "output.CanEdit"));
  111. }
  112. // ── §8 授权不能只看节点 ──
  113. /// <summary>退回态的可编辑判定必须建立在「本人持有的 Pending 待办」之上,不是只看 CurrentNodeId。</summary>
  114. [Fact]
  115. public void EditAuthority_RequiresOwnedPendingTaskNotJustNode()
  116. {
  117. var body = BuildStateBody();
  118. // 分支入口本身就由 myTask 决定
  119. Assert.Contains("var myTask = await GetMyPendingTaskAsync(inst.Id);", body);
  120. Assert.Contains("if (myTask != null)", body);
  121. Assert.Contains("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)", body);
  122. // 不得出现「只按实例当前节点」开放编辑的写法
  123. Assert.DoesNotContain("inst.CurrentNodeId == S6ProcessInspectionFlowConst.NodeSubmit)\n output.CanEdit", body);
  124. }
  125. /// <summary>待办归属必须按当前用户 + Pending 过滤(与重提复用同一 helper)。</summary>
  126. [Fact]
  127. public void PendingTaskHelper_ScopedToCurrentUserAndPending()
  128. {
  129. var helper = Slice(Svc(), "private async Task<ApprovalFlowTask?> GetMyPendingTaskAsync", "private async Task<(ApprovalFlowInstance");
  130. Assert.Contains("_userManager.UserId", helper);
  131. Assert.Contains("x.AssigneeId == userId", helper);
  132. Assert.Contains("FlowTaskStatusEnum.Pending", helper);
  133. }
  134. /// <summary>租户收口:state 入口先走 LoadBillAsync,且 BuildStateAsync 的单据查询带 tenant_id。</summary>
  135. [Fact]
  136. public void State_IsTenantScoped()
  137. {
  138. var s = Svc();
  139. Assert.Contains("await LoadBillAsync(billId);", s);
  140. Assert.Contains("var tid = ResolveTenantOrThrow();", BuildStateBody());
  141. Assert.Contains("WHERE b.id=@id AND b.tenant_id=@tid", BuildStateBody());
  142. }
  143. // ── §10 两个 capability 语义不等价 ──
  144. /// <summary>
  145. /// canEdit 与 canSubmitReview 不得等价:退回分支里 canSubmitReview 仍带 completed 门槛,
  146. /// canEdit 不带 —— 这正是"退回后开始改结果(ResultStatus 回到 IN_PROGRESS)仍要能改"的依据。
  147. /// </summary>
  148. [Fact]
  149. public void CanEdit_IsNotAliasOfCanSubmitReview()
  150. {
  151. var branch = ReturnedBranch();
  152. Assert.Contains("output.CanEdit = true;", branch);
  153. Assert.Contains("output.CanSubmitReview = completed;", branch);
  154. }
  155. /// <summary>后端不得把 canEdit 直接写成 canSubmitReview 的别名。</summary>
  156. [Theory]
  157. [InlineData("output.CanEdit = output.CanSubmitReview")]
  158. [InlineData("output.CanEdit = completed")]
  159. public void CanEdit_NotDerivedFromSubmitCapability(string forbidden)
  160. {
  161. Assert.DoesNotContain(forbidden, Svc());
  162. }
  163. // ── §31 Reject→N1 契约冻结(上游语义不得被改坏)──
  164. /// <summary>退回必须保持实例 Running + 回到 N1,否则 canEdit 的退回分支永远进不到。</summary>
  165. [Fact]
  166. public void SupervisorReturn_KeepsInstanceRunningAtN1()
  167. {
  168. var engine = File.ReadAllText(FindFile(
  169. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
  170. var body = Slice(engine, "public async Task ReturnToPrev(long taskId, string? comment)", "/// <summary>\n /// 加签");
  171. Assert.Contains("instance.CurrentNodeId = prevNodeId;", body);
  172. Assert.Contains("CreateTasksForNode(instance, flowData, prevNodeId)", body);
  173. Assert.DoesNotContain("instance.Status = FlowInstanceStatusEnum.Rejected", body);
  174. }
  175. /// <summary>退回后展示状态仍须是业务中文(后端拥有语义,前端不拼)。</summary>
  176. [Fact]
  177. public void ReturnedBranch_KeepsBackendOwnedDisplayStatus()
  178. {
  179. Assert.Contains("output.DisplayStatus = \"已退回待重新提交\";", ReturnedBranch());
  180. }
  181. // ── 前端:纯消费 + fail-closed ──
  182. /// <summary>详情页必须直接消费后端字段。</summary>
  183. [Fact]
  184. public void Frontend_ConsumesBackendCanEdit()
  185. {
  186. Assert.Contains("review.value?.canEdit === true", DetailPage());
  187. }
  188. /// <summary>状态缺失(加载中 / 接口失败)时必须 fail-closed —— 旧实现在 review 为空时是放开的。</summary>
  189. [Fact]
  190. public void Frontend_FailsClosedWhenStateMissing()
  191. {
  192. var s = DetailPage();
  193. Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", s);
  194. Assert.DoesNotContain("review.value?.canEdit !== false", s);
  195. }
  196. /// <summary>
  197. /// 前端不得保留任何本地流程推断作为编辑权限来源。
  198. /// 断言只作用于 canEdit 那一行本身 —— 页面别处按 capability 显示按钮(hasAnyAction 聚合
  199. /// canSubmitReview 等)是正当用法,不能一并禁掉。
  200. /// </summary>
  201. [Theory]
  202. [InlineData("flowStatus")]
  203. [InlineData("currentNodeCode")]
  204. [InlineData("canSubmitReview")]
  205. [InlineData("N1_SUBMIT")]
  206. public void Frontend_CanEditExpressionHasNoLocalFlowInference(string forbidden)
  207. {
  208. var line = VueSource.CodeOnly(DetailPage())
  209. .Split('\n').Single(l => l.Contains("const canEdit", StringComparison.Ordinal));
  210. Assert.DoesNotContain(forbidden, line);
  211. }
  212. /// <summary>整页不得残留旧的本地推断表达式。</summary>
  213. [Fact]
  214. public void Frontend_OldLocalExpressionIsGone()
  215. {
  216. Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", VueSource.CodeOnly(DetailPage()));
  217. }
  218. /// <summary>TS 类型须同步补齐,否则 vue-tsc 下 canEdit 会是隐式 any/报错。</summary>
  219. [Fact]
  220. public void FrontendTypes_DeclareCanEdit()
  221. {
  222. Assert.Contains("canEdit?: boolean;", ApiTypes());
  223. }
  224. /// <summary>
  225. /// 录入控件与保存/完成工具条必须由**同一个** canEdit 控制 ——
  226. /// 不允许出现"按钮没了但 input 还能改"。
  227. /// 2026-09-14(FQC Parity)结构变更:实测样本从主表大单元格移入展开行,
  228. /// 编辑控件改为**仅在 canEdit 分支下渲染**(不再靠 `:disabled` 置灰),
  229. /// 三个分支 v-if / v-else-if / v-else 互斥,只读态根本不产出输入控件。
  230. /// </summary>
  231. [Fact]
  232. public void ItemPanel_SingleEditSwitchGovernsInputsAndToolbar()
  233. {
  234. var s = VueSource.CodeOnly(ItemPanel());
  235. Assert.Contains("v-if=\"canEdit\" class=\"insp-toolbar\"", s);
  236. Assert.Contains("v-if=\"canEdit && row.resultType === 'NUMERIC'\"", s);
  237. Assert.Contains("v-else-if=\"canEdit\"", s);
  238. // 只读分支必须是 v-else(保证三分支互斥,不会与编辑分支同时出现)
  239. Assert.Contains("v-else class=\"samples-wrap\"", s);
  240. // 组件内不得自己看流程(去注释后断言:注释里成段解释"不按 flowStatus 推断",不算实现)
  241. Assert.DoesNotContain("flowStatus", s);
  242. Assert.DoesNotContain("currentNodeCode", s);
  243. }
  244. // ── FQC 布局对齐冻结(S6-IPQC-FQC-PARITY-1)──
  245. /// <summary>区块标题与顺序:基础信息 → 生产信息 → 检规信息 → 检验项目明细 → 流程信息 → 审核历史。</summary>
  246. [Fact]
  247. public void Layout_SectionOrderFollowsFqcTemplate()
  248. {
  249. var s = VueSource.CodeOnly(DetailPage());
  250. var order = new[] { "title=\"基础信息\"", "title=\"生产信息\"", "title=\"检规信息\"", "IpqcBillItemPanel", "title=\"流程信息\"", "审核历史" };
  251. var last = -1;
  252. foreach (var token in order)
  253. {
  254. var at = s.IndexOf(token, StringComparison.Ordinal);
  255. Assert.True(at > last, $"区块顺序不符:{token} 应出现在前一区块之后(at={at}, last={last})");
  256. last = at;
  257. }
  258. }
  259. /// <summary>Descriptions 样式与 FQC 一致:两列 + border + size=small。</summary>
  260. [Fact]
  261. public void Layout_DescriptionsMatchFqcProps()
  262. {
  263. var s = VueSource.CodeOnly(DetailPage());
  264. Assert.Equal(0, CountOf(s, ":column=\"3\""));
  265. Assert.True(CountOf(s, ":column=\"2\" border size=\"small\"") >= 4, "至少 4 个区块应使用 FQC 的 descriptions props");
  266. }
  267. /// <summary>操作区必须紧随流程信息(FQC 位置),且不在审核历史之后再放一套。</summary>
  268. [Fact]
  269. public void Layout_OperationBlocksFollowFlowSection()
  270. {
  271. var s = VueSource.CodeOnly(DetailPage());
  272. var flow = s.IndexOf("title=\"流程信息\"", StringComparison.Ordinal);
  273. var firstOp = s.IndexOf("class=\"flow-op mt12\"", StringComparison.Ordinal);
  274. var history = s.IndexOf("审核历史", StringComparison.Ordinal);
  275. Assert.True(flow < firstOp && firstOp < history, $"操作区应在流程信息之后、审核历史之前(flow={flow}, op={firstOp}, hist={history})");
  276. }
  277. /// <summary>主表列顺序与 FQC 只读明细表一致。</summary>
  278. [Fact]
  279. public void Layout_MainTableColumnsFollowFqcOrder()
  280. {
  281. var s = VueSource.CodeOnly(ItemPanel());
  282. var cols = new[] { "type=\"expand\"", "label=\"序号\"", "label=\"检验项目\"", "label=\"接收标准\"", "label=\"技术要求\"",
  283. "label=\"抽样方案\"", "label=\"样本量\"", "label=\"上限\"", "label=\"下限\"", "label=\"规格标准\"",
  284. "label=\"判定\"", "label=\"非数值OK\"", "label=\"非数值NG\"", "label=\"备注\"" };
  285. var last = -1;
  286. foreach (var c in cols)
  287. {
  288. var at = s.IndexOf(c, StringComparison.Ordinal);
  289. Assert.True(at > last, $"主表列顺序不符:{c}(at={at}, last={last})");
  290. last = at;
  291. }
  292. }
  293. /// <summary>主表不得再把工序 / 录入类型 / 频次这类系统字段摆在业务列前面(§16)。</summary>
  294. [Theory]
  295. [InlineData("label=\"工序\"")]
  296. [InlineData("label=\"录入类型\"")]
  297. [InlineData("label=\"频次\"")]
  298. [InlineData("label=\"实测录入\"")]
  299. [InlineData("label=\"后端判定\"")]
  300. [InlineData("label=\"单项结果\"")]
  301. public void Layout_MainTableDropsSystemOrientedColumns(string forbidden)
  302. {
  303. Assert.DoesNotContain(forbidden, VueSource.CodeOnly(ItemPanel()));
  304. }
  305. /// <summary>S6 特有信息(工序/方法/录入类型/录入状态)降级到展开行,未丢失。</summary>
  306. [Fact]
  307. public void Layout_S6SpecificFieldsMovedToExpandRow()
  308. {
  309. var s = VueSource.CodeOnly(ItemPanel());
  310. var expand = Slice(s, "class=\"expand-meta\"", "</div>");
  311. foreach (var token in new[] { "工序", "检验方法", "录入类型", "录入状态" })
  312. Assert.Contains(token, expand);
  313. }
  314. /// <summary>不得为"像 FQC"调用 S7 接口或引入 FQC 语义。</summary>
  315. [Theory]
  316. [InlineData("fqc")]
  317. [InlineData("Fqc")]
  318. [InlineData("s7")]
  319. public void Layout_DoesNotBorrowS7Implementation(string forbidden)
  320. {
  321. Assert.DoesNotContain(forbidden, VueSource.CodeOnly(DetailPage()));
  322. Assert.DoesNotContain(forbidden, VueSource.CodeOnly(ItemPanel()));
  323. }
  324. /// <summary>子组件 canEdit 默认必须是 false(fail-closed)。</summary>
  325. [Fact]
  326. public void ItemPanel_DefaultsToReadOnly()
  327. {
  328. Assert.Contains("canEdit: false", ItemPanel());
  329. }
  330. // ── 范围守卫:本轮不得动审批流框架 ──
  331. /// <summary>编辑权限不得改成前端可传入 —— state 是只读输出,不接受回传判定。</summary>
  332. [Theory]
  333. [InlineData("CanEdit { get; set; }", "S6ReviewStateOutput")]
  334. public void CanEdit_LivesOnlyOnReadOnlyStateOutput(string decl, string owner)
  335. {
  336. var dto = Dto();
  337. var cls = Slice(dto, $"public class {owner}", "public class S6DispositionView");
  338. Assert.Contains(decl, cls);
  339. // 入参 DTO 一律不得带 CanEdit
  340. foreach (var input in new[] { "S6SubmitReviewInput", "S6ReviewApproveInput", "S6ReviewReturnInput", "S6QeDispositionInput" })
  341. {
  342. var start = dto.IndexOf($"public class {input}", StringComparison.Ordinal);
  343. Assert.True(start >= 0, $"未找到入参类 {input}");
  344. var end = dto.IndexOf("public class ", start + input.Length + 14, StringComparison.Ordinal);
  345. var slice = end > start ? dto[start..end] : dto[start..];
  346. Assert.DoesNotContain("CanEdit", slice);
  347. }
  348. }
  349. private static string BuildStateBody() => Slice(Svc(),
  350. "private async Task<S6ReviewStateOutput> BuildStateAsync(long billId)",
  351. "// ── Phase 1E 展示辅助");
  352. private static string ReturnedBranch() => Slice(BuildStateBody(),
  353. "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)",
  354. " else\n {");
  355. private static int CountOf(string src, string needle)
  356. {
  357. int n = 0, i = 0;
  358. while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
  359. return n;
  360. }
  361. private static string Slice(string src, string from, string to)
  362. {
  363. var a = src.IndexOf(from, StringComparison.Ordinal);
  364. Assert.True(a >= 0, $"未找到起点:{from}");
  365. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  366. return b > a ? src[a..b] : src[a..];
  367. }
  368. private static string FindFile(params string[] parts)
  369. {
  370. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  371. while (dir != null)
  372. {
  373. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  374. if (File.Exists(candidate)) return candidate;
  375. dir = dir.Parent;
  376. }
  377. throw new FileNotFoundException(string.Join("/", parts));
  378. }
  379. }