using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing; /// /// 前端源码文本契约的公共视图:**去注释**后再断言。 /// 缘由:这些页面的注释里会成段引用节点码(N1_SUBMIT)、字段名(flowStatus / canSubmitReview) /// 来解释"为什么不这么做",裸文本 DoesNotContain 会把说明文字当成违规实现命中。 /// internal static class VueSource { /// 剥掉 /* */ 块注释、<!-- --> HTML 注释、以及整行 // 与 * 续行注释。 public static string CodeOnly(string src) { var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline); s = System.Text.RegularExpressions.Regex.Replace(s, @"", "", System.Text.RegularExpressions.RegexOptions.Singleline); var kept = s.Split('\n').Where(l => { var t = l.TrimStart(); return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("* ", StringComparison.Ordinal) && t != "*"; }); return string.Join("\n", kept); } } /// /// S6-IPQC-EDIT-1 源码契约测试:IPQC 检查单详情页「编辑权限」归后端 Authority。 /// /// 缺口:`ReturnToPrev` 后实例仍是 Running(只回退 CurrentNodeId),而详情页把编辑权限写成 /// 本地表达式 `flowStatus !== 'NotStarted' → 只读`,于是主管退回回到 N1 后录入区仍锁死 —— /// 后端已允许重新提交(canSubmitReview=true),前端却不允许改数据,形成"能提交但改不动"。 /// /// 修法:后端 `S6ReviewStateOutput.CanEdit`(复用与 canSubmitReview 同一份 N1 待办归属判定, /// 但不叠加 completed),前端纯消费、状态缺失时 fail-closed。 /// /// 关键不变量: /// 1. canEdit ≠ canSubmitReview(后者多一个 completed 条件)——不得互相顶替; /// 2. 授权不能只看节点,必须节点 + Pending + AssigneeId==当前用户; /// 3. 首次录入(NotStarted)不得被本次修复关掉; /// 4. 前端不得保留任何本地流程推断。 /// /// 沿用本仓既有做法——源码契约断言而非行为级集成测试(service 依赖 ISqlSugarClient / /// FlowEngineService,裸 xUnit 进程无法实例化)。真实状态矩阵由本批 Runtime R1–R12 覆盖。 /// public class S6ProcessInspectionEditAuthorityContractTests { private static string Svc() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs")); private static string Dto() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "Dto", "S6ProcessInspectionReviewDto.cs")); private static string DetailPage() => File.ReadAllText(FindFile( "Web", "src", "views", "aidop", "s6", "process-quality", "ipqcBillDetail.vue")); private static string ItemPanel() => File.ReadAllText(FindFile( "Web", "src", "views", "aidop", "s6", "process-quality", "components", "IpqcBillItemPanel.vue")); private static string ApiTypes() => File.ReadAllText(FindFile( "Web", "src", "views", "aidop", "s6", "api", "productionInstruction.ts")); // ── 后端必须成为唯一 authority ── /// capability 必须落在只读状态 DTO 上(与其余 4 个按钮门控同一处)。 [Fact] public void Dto_ExposesCanEditCapability() { Assert.Contains("public bool CanEdit { get; set; }", Dto()); } /// 编辑权限必须在 BuildStateAsync 里算,不得散落到 Controller / 前端。 [Fact] public void State_ComputesCanEditInBuildState() { var body = BuildStateBody(); Assert.Contains("output.CanEdit", body); } // ── §30 状态矩阵 ── /// NotStarted:首次录入必须可编辑,且**不受 completed 约束**(否则录完即锁死)。 [Fact] public void Matrix_NotStarted_IsEditable() { var branch = Slice(BuildStateBody(), "output.FlowStatus = \"NotStarted\";", "return output;"); Assert.Contains("output.CanEdit = true;", branch); Assert.DoesNotContain("output.CanEdit = completed", branch); } /// 退回到 N1 且本人持有该待办:可编辑,且同样不叠加 completed。 [Fact] public void Matrix_ReturnedToN1_IsEditable() { var branch = ReturnedBranch(); Assert.Contains("output.CanEdit = true;", branch); Assert.DoesNotContain("output.CanEdit = completed", branch); } /// 终态(Approved / Rejected / Cancelled / Terminated):显式不可编辑。 [Fact] public void Matrix_Terminal_IsReadOnly() { var branch = Slice(BuildStateBody(), "if (inst.Status != FlowInstanceStatusEnum.Running)", "output.CurrentNodeCode = inst.CurrentNodeId;"); Assert.Contains("output.CanEdit = false;", branch); } /// /// N2 主管审核 / N3 质量处置 / 本人无待办三条路径必须靠 bool 默认 false 兜底 —— /// 即这些分支里不得出现任何把 CanEdit 打开的赋值。 /// [Theory] [InlineData("if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSupervisor)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)")] [InlineData("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)")] [InlineData("output.Message = \"流程进行中(当前用户无待办)\";", "return output;")] public void Matrix_NonEditableBranches_NeverOpenCanEdit(string from, string to) { var branch = Slice(BuildStateBody(), from, to); Assert.DoesNotContain("output.CanEdit = true", branch); } /// CanEdit 在整个 service 里只有三处赋值(NotStarted / 终态 / 退回 N1),防止后续被到处打开。 [Fact] public void CanEdit_HasExactlyThreeAssignments() { Assert.Equal(3, CountOf(Svc(), "output.CanEdit")); } // ── §8 授权不能只看节点 ── /// 退回态的可编辑判定必须建立在「本人持有的 Pending 待办」之上,不是只看 CurrentNodeId。 [Fact] public void EditAuthority_RequiresOwnedPendingTaskNotJustNode() { var body = BuildStateBody(); // 分支入口本身就由 myTask 决定 Assert.Contains("var myTask = await GetMyPendingTaskAsync(inst.Id);", body); Assert.Contains("if (myTask != null)", body); Assert.Contains("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)", body); // 不得出现「只按实例当前节点」开放编辑的写法 Assert.DoesNotContain("inst.CurrentNodeId == S6ProcessInspectionFlowConst.NodeSubmit)\n output.CanEdit", body); } /// 待办归属必须按当前用户 + Pending 过滤(与重提复用同一 helper)。 [Fact] public void PendingTaskHelper_ScopedToCurrentUserAndPending() { var helper = Slice(Svc(), "private async Task GetMyPendingTaskAsync", "private async Task<(ApprovalFlowInstance"); Assert.Contains("_userManager.UserId", helper); Assert.Contains("x.AssigneeId == userId", helper); Assert.Contains("FlowTaskStatusEnum.Pending", helper); } /// 租户收口:state 入口先走 LoadBillAsync,且 BuildStateAsync 的单据查询带 tenant_id。 [Fact] public void State_IsTenantScoped() { var s = Svc(); Assert.Contains("await LoadBillAsync(billId);", s); Assert.Contains("var tid = ResolveTenantOrThrow();", BuildStateBody()); Assert.Contains("WHERE b.id=@id AND b.tenant_id=@tid", BuildStateBody()); } // ── §10 两个 capability 语义不等价 ── /// /// canEdit 与 canSubmitReview 不得等价:退回分支里 canSubmitReview 仍带 completed 门槛, /// canEdit 不带 —— 这正是"退回后开始改结果(ResultStatus 回到 IN_PROGRESS)仍要能改"的依据。 /// [Fact] public void CanEdit_IsNotAliasOfCanSubmitReview() { var branch = ReturnedBranch(); Assert.Contains("output.CanEdit = true;", branch); Assert.Contains("output.CanSubmitReview = completed;", branch); } /// 后端不得把 canEdit 直接写成 canSubmitReview 的别名。 [Theory] [InlineData("output.CanEdit = output.CanSubmitReview")] [InlineData("output.CanEdit = completed")] public void CanEdit_NotDerivedFromSubmitCapability(string forbidden) { Assert.DoesNotContain(forbidden, Svc()); } // ── §31 Reject→N1 契约冻结(上游语义不得被改坏)── /// 退回必须保持实例 Running + 回到 N1,否则 canEdit 的退回分支永远进不到。 [Fact] public void SupervisorReturn_KeepsInstanceRunningAtN1() { var engine = File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs")); var body = Slice(engine, "public async Task ReturnToPrev(long taskId, string? comment)", "/// \n /// 加签"); Assert.Contains("instance.CurrentNodeId = prevNodeId;", body); Assert.Contains("CreateTasksForNode(instance, flowData, prevNodeId)", body); Assert.DoesNotContain("instance.Status = FlowInstanceStatusEnum.Rejected", body); } /// 退回后展示状态仍须是业务中文(后端拥有语义,前端不拼)。 [Fact] public void ReturnedBranch_KeepsBackendOwnedDisplayStatus() { Assert.Contains("output.DisplayStatus = \"已退回待重新提交\";", ReturnedBranch()); } // ── 前端:纯消费 + fail-closed ── /// 详情页必须直接消费后端字段。 [Fact] public void Frontend_ConsumesBackendCanEdit() { Assert.Contains("review.value?.canEdit === true", DetailPage()); } /// 状态缺失(加载中 / 接口失败)时必须 fail-closed —— 旧实现在 review 为空时是放开的。 [Fact] public void Frontend_FailsClosedWhenStateMissing() { var s = DetailPage(); Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", s); Assert.DoesNotContain("review.value?.canEdit !== false", s); } /// /// 前端不得保留任何本地流程推断作为编辑权限来源。 /// 断言只作用于 canEdit 那一行本身 —— 页面别处按 capability 显示按钮(hasAnyAction 聚合 /// canSubmitReview 等)是正当用法,不能一并禁掉。 /// [Theory] [InlineData("flowStatus")] [InlineData("currentNodeCode")] [InlineData("canSubmitReview")] [InlineData("N1_SUBMIT")] public void Frontend_CanEditExpressionHasNoLocalFlowInference(string forbidden) { var line = VueSource.CodeOnly(DetailPage()) .Split('\n').Single(l => l.Contains("const canEdit", StringComparison.Ordinal)); Assert.DoesNotContain(forbidden, line); } /// 整页不得残留旧的本地推断表达式。 [Fact] public void Frontend_OldLocalExpressionIsGone() { Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", VueSource.CodeOnly(DetailPage())); } /// TS 类型须同步补齐,否则 vue-tsc 下 canEdit 会是隐式 any/报错。 [Fact] public void FrontendTypes_DeclareCanEdit() { Assert.Contains("canEdit?: boolean;", ApiTypes()); } /// /// 录入控件与保存/完成工具条必须由**同一个** canEdit 控制 —— /// 不允许出现"按钮没了但 input 还能改"。 /// 2026-09-14(FQC Parity)结构变更:实测样本从主表大单元格移入展开行, /// 编辑控件改为**仅在 canEdit 分支下渲染**(不再靠 `:disabled` 置灰), /// 三个分支 v-if / v-else-if / v-else 互斥,只读态根本不产出输入控件。 /// [Fact] public void ItemPanel_SingleEditSwitchGovernsInputsAndToolbar() { var s = VueSource.CodeOnly(ItemPanel()); Assert.Contains("v-if=\"canEdit\" class=\"insp-toolbar\"", s); Assert.Contains("v-if=\"canEdit && row.resultType === 'NUMERIC'\"", s); Assert.Contains("v-else-if=\"canEdit\"", s); // 只读分支必须是 v-else(保证三分支互斥,不会与编辑分支同时出现) Assert.Contains("v-else class=\"samples-wrap\"", s); // 组件内不得自己看流程(去注释后断言:注释里成段解释"不按 flowStatus 推断",不算实现) Assert.DoesNotContain("flowStatus", s); Assert.DoesNotContain("currentNodeCode", s); } // ── FQC 布局对齐冻结(S6-IPQC-FQC-PARITY-1)── /// 区块标题与顺序:基础信息 → 生产信息 → 检规信息 → 检验项目明细 → 流程信息 → 审核历史。 [Fact] public void Layout_SectionOrderFollowsFqcTemplate() { var s = VueSource.CodeOnly(DetailPage()); var order = new[] { "title=\"基础信息\"", "title=\"生产信息\"", "title=\"检规信息\"", "IpqcBillItemPanel", "title=\"流程信息\"", "审核历史" }; var last = -1; foreach (var token in order) { var at = s.IndexOf(token, StringComparison.Ordinal); Assert.True(at > last, $"区块顺序不符:{token} 应出现在前一区块之后(at={at}, last={last})"); last = at; } } /// Descriptions 样式与 FQC 一致:两列 + border + size=small。 [Fact] public void Layout_DescriptionsMatchFqcProps() { var s = VueSource.CodeOnly(DetailPage()); Assert.Equal(0, CountOf(s, ":column=\"3\"")); Assert.True(CountOf(s, ":column=\"2\" border size=\"small\"") >= 4, "至少 4 个区块应使用 FQC 的 descriptions props"); } /// 操作区必须紧随流程信息(FQC 位置),且不在审核历史之后再放一套。 [Fact] public void Layout_OperationBlocksFollowFlowSection() { var s = VueSource.CodeOnly(DetailPage()); var flow = s.IndexOf("title=\"流程信息\"", StringComparison.Ordinal); var firstOp = s.IndexOf("class=\"flow-op mt12\"", StringComparison.Ordinal); var history = s.IndexOf("审核历史", StringComparison.Ordinal); Assert.True(flow < firstOp && firstOp < history, $"操作区应在流程信息之后、审核历史之前(flow={flow}, op={firstOp}, hist={history})"); } /// 主表列顺序与 FQC 只读明细表一致。 [Fact] public void Layout_MainTableColumnsFollowFqcOrder() { var s = VueSource.CodeOnly(ItemPanel()); var cols = new[] { "type=\"expand\"", "label=\"序号\"", "label=\"检验项目\"", "label=\"接收标准\"", "label=\"技术要求\"", "label=\"抽样方案\"", "label=\"样本量\"", "label=\"上限\"", "label=\"下限\"", "label=\"规格标准\"", "label=\"判定\"", "label=\"非数值OK\"", "label=\"非数值NG\"", "label=\"备注\"" }; var last = -1; foreach (var c in cols) { var at = s.IndexOf(c, StringComparison.Ordinal); Assert.True(at > last, $"主表列顺序不符:{c}(at={at}, last={last})"); last = at; } } /// 主表不得再把工序 / 录入类型 / 频次这类系统字段摆在业务列前面(§16)。 [Theory] [InlineData("label=\"工序\"")] [InlineData("label=\"录入类型\"")] [InlineData("label=\"频次\"")] [InlineData("label=\"实测录入\"")] [InlineData("label=\"后端判定\"")] [InlineData("label=\"单项结果\"")] public void Layout_MainTableDropsSystemOrientedColumns(string forbidden) { Assert.DoesNotContain(forbidden, VueSource.CodeOnly(ItemPanel())); } /// S6 特有信息(工序/方法/录入类型/录入状态)降级到展开行,未丢失。 [Fact] public void Layout_S6SpecificFieldsMovedToExpandRow() { var s = VueSource.CodeOnly(ItemPanel()); var expand = Slice(s, "class=\"expand-meta\"", ""); foreach (var token in new[] { "工序", "检验方法", "录入类型", "录入状态" }) Assert.Contains(token, expand); } /// 不得为"像 FQC"调用 S7 接口或引入 FQC 语义。 [Theory] [InlineData("fqc")] [InlineData("Fqc")] [InlineData("s7")] public void Layout_DoesNotBorrowS7Implementation(string forbidden) { Assert.DoesNotContain(forbidden, VueSource.CodeOnly(DetailPage())); Assert.DoesNotContain(forbidden, VueSource.CodeOnly(ItemPanel())); } /// 子组件 canEdit 默认必须是 false(fail-closed)。 [Fact] public void ItemPanel_DefaultsToReadOnly() { Assert.Contains("canEdit: false", ItemPanel()); } // ── 范围守卫:本轮不得动审批流框架 ── /// 编辑权限不得改成前端可传入 —— state 是只读输出,不接受回传判定。 [Theory] [InlineData("CanEdit { get; set; }", "S6ReviewStateOutput")] public void CanEdit_LivesOnlyOnReadOnlyStateOutput(string decl, string owner) { var dto = Dto(); var cls = Slice(dto, $"public class {owner}", "public class S6DispositionView"); Assert.Contains(decl, cls); // 入参 DTO 一律不得带 CanEdit foreach (var input in new[] { "S6SubmitReviewInput", "S6ReviewApproveInput", "S6ReviewReturnInput", "S6QeDispositionInput" }) { var start = dto.IndexOf($"public class {input}", StringComparison.Ordinal); Assert.True(start >= 0, $"未找到入参类 {input}"); var end = dto.IndexOf("public class ", start + input.Length + 14, StringComparison.Ordinal); var slice = end > start ? dto[start..end] : dto[start..]; Assert.DoesNotContain("CanEdit", slice); } } private static string BuildStateBody() => Slice(Svc(), "private async Task BuildStateAsync(long billId)", "// ── Phase 1E 展示辅助"); private static string ReturnedBranch() => Slice(BuildStateBody(), "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)", " else\n {"); private static int CountOf(string src, string needle) { int n = 0, i = 0; while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; } return n; } private static string Slice(string src, string from, string to) { var a = src.IndexOf(from, StringComparison.Ordinal); Assert.True(a >= 0, $"未找到起点:{from}"); var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal); return b > a ? src[a..b] : src[a..]; } private static string FindFile(params string[] parts) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray()); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException(string.Join("/", parts)); } }