using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing; /// /// S6 DISPLAY FIX · Batch 1 源码契约测试。 /// /// D-04:IPQC head transform 的字符串列必须经 MdpJsonSql.Str 把 JSON null 归一为 SQL NULL, /// 否则 JSON_UNQUOTE(JSON_EXTRACT(...)) 会产出字面量字符串 'null' 并一路透到页面。 /// detail transform 早已正确,本测试同时钉住其不回归。 /// D-01:完成态 message 必须区分 APPROVED(整批合格)与 DISPOSED(整批不合格已处置), /// 不得只依据 ApprovalFlowInstance.Status 一律说"主管审核通过"。 /// public class S6IpqcDisplayContractTests { private static string SyncService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionMdpSyncService.cs")); private static string FlowService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowService.cs")); private static string JsonSql() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpJsonSql.cs")); private static string Vue() => File.ReadAllText(FindFile( "Web", "src", "views", "aidop", "s6", "process-quality", "processInspectionList.vue")); private static string ListService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionService.cs")); /// head transform 的每个字符串列都必须走 MdpJsonSql.Str。 [Theory] [InlineData("djbh")] [InlineData("cplx")] [InlineData("scph")] [InlineData("lydjbh")] [InlineData("jgpd")] [InlineData("fj")] [InlineData("bz")] [InlineData("jyr")] [InlineData("gxbm")] [InlineData("gxmc")] [InlineData("sczyry")] [InlineData("bdbh")] [InlineData("bbh")] [InlineData("wlbm")] [InlineData("wlmc")] [InlineData("jgbb")] [InlineData("jgbh")] [InlineData("status")] public void HeadTransform_StringField_UsesNullSafeHelper(string field) { Assert.Contains($"MdpJsonSql.Str(\"m\", \"{field}\")", SyncService()); } /// 数值/日期列用对应类型 helper,不得被误改成字符串 helper。 [Fact] public void HeadTransform_NumericAndDateFields_UseTypedHelpers() { var s = SyncService(); Assert.Contains("MdpJsonSql.Dec(\"m\", \"ybl\", 18, 6)", s); Assert.Contains("MdpJsonSql.DateTimeSec(\"m\", \"sxrq\")", s); Assert.DoesNotContain("MdpJsonSql.Str(\"m\", \"ybl\")", s); Assert.DoesNotContain("MdpJsonSql.Str(\"m\", \"sxrq\")", s); } /// /// 除身份键 source_row_id / source_biz_key 的两处兜底外,转换 SQL 不得再出现裸 JSON_UNQUOTE。 /// 这两处刻意保留:它们是 NOT NULL 的 upsert 身份列,不是展示列,改动会影响唯一键语义。 /// [Fact] public void TransformSql_NoBareJsonUnquote_ExceptIdentityKeyFallbacks() { var s = SyncService(); var bare = s.Split("JSON_UNQUOTE(JSON_EXTRACT(").Length - 1; Assert.Equal(2, bare); Assert.Contains("IFNULL(m.source_row_id, JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.id')))", s); Assert.Contains("IFNULL(NULLIF(m.source_biz_key,''), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.djbh')))", s); } /// detail transform 既有的 null 安全行为不得回归。 [Fact] public void DetailTransform_StillUsesNullSafeHelpers() { var s = SyncService(); Assert.Contains("MdpJsonSql.Str(\"d\", \"jyxm\")", s); Assert.Contains("MdpJsonSql.Str(\"d\", \"pd\")", s); Assert.Contains("MdpJsonSql.Dec(\"d\", \"jysl\", 18, 6)", s); Assert.Contains("MdpJsonSql.DateTimeSec(\"d\", \"InspectionTime\")", s); } /// helper 语义钉死:JSON null / 字符串 'null' → SQL NULL。 [Fact] public void JsonSqlStrHelper_NormalizesLiteralNullToSqlNull() { Assert.Contains("NULLIF({Ext(alias, field)},'null')", JsonSql()); } /// 完成态文案必须由 businessStatus 决定,而非只看实例状态。 [Fact] public void CompletedMessage_BranchesOnBusinessStatus() { var s = FlowService(); Assert.Contains("BuildCompletedMessage(output.BusinessStatus)", s); Assert.Contains("IpqcInspectionFlowConst.StatusApproved => \"流程已完成(整批合格·主管审核通过)\"", s); Assert.Contains("IpqcInspectionFlowConst.StatusDisposed => \"流程已完成(整批不合格·已处置)\"", s); } /// 两个完成终态的文案不得相同——这正是 D-01 的缺陷本身。 [Fact] public void CompletedMessage_ApprovedAndDisposed_AreNotIdentical() { var s = FlowService(); Assert.DoesNotContain( "inst.Status == FlowInstanceStatusEnum.Approved ? \"流程已完成(主管审核通过)\"", s); Assert.Contains("_ => \"流程已完成\",", s); // 业务状态缺失时中性表述,不臆断"审核通过" } /// 非 Approved 的结束态文案保持原样,未被本批波及。 [Fact] public void NonApprovedTerminal_MessageUnchanged() { Assert.Contains("$\"流程已结束({inst.Status})\"", FlowService()); } /// /// 状态机未被改动:节点码、网关判据、审批人解析、can* 判据、任务归属均保持原样。 /// D-01 只允许改"给 UI 的解释性文案"。 /// [Fact] public void StateMachine_Untouched() { var s = FlowService(); Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeInspect", s); Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeSupervisor", s); Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeDisposition", s); Assert.Contains("output.CanSupervisorApprove = true;", s); Assert.Contains("output.CanSupervisorReturn = true;", s); Assert.Contains("output.CanSubmitDisposition = true;", s); // 整批判定仍驱动网关,未被文案改动波及 Assert.Contains("var dispositionRequired = input.Qualified ? 0 : 1;", s); } // ───────────────────────── Batch 2 · UI Readability ───────────────────────── /// 六个业务状态 + 兜底 flowStatus 都必须有中文映射。 [Theory] [InlineData("INSPECTING", "检验中")] [InlineData("PENDING_REVIEW", "待主管审核")] [InlineData("APPROVED", "已通过")] [InlineData("RETURNED", "已退回")] [InlineData("PENDING_DISPOSITION", "待质量处置")] [InlineData("DISPOSED", "已处置")] [InlineData("NotStarted", "待检验")] [InlineData("Running", "流程进行中")] public void BusinessStatus_HasChineseMapping(string raw, string zh) { Assert.Contains($"{raw}: '{zh}'", Vue()); } /// 未知状态原样返回,不静默显示"未知";空值 → '-'。 [Fact] public void BusinessStatusText_UnknownFallsBackToRawValue() { var v = Vue(); Assert.Contains("return BUSINESS_STATUS_TEXT[v] ?? v;", v); Assert.Contains("if (v === null || v === undefined || v === '') return '-';", v); } /// 判定 0/1 中文化,且 0 不得被 truthy 判断吞掉(string 与 number 两型都认)。 [Fact] public void JudgementText_MapsZeroAndOne_WithoutTruthyCheck() { var v = Vue(); Assert.Contains("if (v === 0 || v === '0') return '合格';", v); Assert.Contains("if (v === 1 || v === '1') return '不合格';", v); Assert.DoesNotContain("resultJudgement || '-'", v); } /// 空值占位统一走 displayValue;0 / false 是合法值必须原样显示。 [Fact] public void DisplayValue_PreservesZeroAndFalse() { var v = Vue(); Assert.Contains("function displayValue(v: unknown)", v); Assert.Contains("if (v === null || v === undefined) return '-';", v); // 不得再用 || '-' 处理可能为 0 的字段;仅允许 dispTypeText 的字符串枚举兜底 Assert.Equal(1, v.Split("|| '-'").Length - 1); Assert.Contains("REWORK: '返工'", v); } /// 列表必须有「流程状态」列,且绑定 businessStatus 而非源字段 inspectionStatus。 [Fact] public void List_HasWorkflowStatusColumn_BoundToBusinessStatus() { var v = Vue(); Assert.Contains("label=\"流程状态\"", v); Assert.Contains("businessStatusText(row.businessStatus)", v); Assert.DoesNotContain("businessStatusText(row.inspectionStatus)", v); } /// 模板不得直接插值原始业务状态枚举。 [Fact] public void Template_DoesNotRenderRawBusinessStatus() { var v = Vue(); Assert.DoesNotContain("{{ row.businessStatus }}", v); Assert.DoesNotContain("{{ flowState.businessStatus }}", v); Assert.DoesNotContain("{{ detail.resultJudgement }}", v); } /// currentNodeName 被使用,且终态不展示(避免残留旧节点误导)。 [Fact] public void CurrentNodeName_IsRendered_AndHiddenWhenCompleted() { var v = Vue(); Assert.Contains("flowState.currentNodeName", v); Assert.Contains("!flowState.isCompleted && flowState.currentNodeName", v); } /// 日期复用项目公共 formatter,不再自写 slice 逻辑;空值 → '-'。 [Fact] public void Dates_ReuseSharedFormatter_AndPlaceholderOnEmpty() { var v = Vue(); Assert.Contains("import { formatDate } from '/@/utils/formatTime';", v); Assert.Contains("formatDate(d, 'YYYY-mm-dd')", v); Assert.Contains("formatDate(d, 'YYYY-mm-dd HH:MM:SS')", v); Assert.DoesNotContain("v.slice(0, 10)", v); // 旧的自写实现已移除 Assert.Contains("return isNaN(d.getTime()) ? null : d;", v); // Invalid Date → '-' } /// 提交契约不得被中文化污染:payload / v-model / option value 仍为原始值。 [Fact] public void SubmitContract_KeepsRawValues() { var v = Vue(); Assert.Contains("label=\"合格(0)\" :value=\"0\"", v); Assert.Contains("label=\"不合格(1)\" :value=\"1\"", v); Assert.Contains("value=\"REWORK\"", v); Assert.DoesNotContain(":value=\"'合格'\"", v); } /// 列表流程状态来自 flow_state 左联,且租户与软删都收口。 [Fact] public void ListSql_JoinsFlowState_TenantScoped() { var s = ListService(); Assert.Contains("f.business_status AS BusinessStatus", s); Assert.Contains("LEFT JOIN ado_s6_ipqc_inspection_flow_state f", s); Assert.Contains("ON f.inspection_id = CAST(m.source_row_id AS SIGNED)", s); Assert.Contains("AND f.tenant_id = m.tenant_id", s); Assert.Contains("AND f.IsDelete = 0", s); // 列表仍强制按登录租户过滤 Assert.Contains("m.tenant_id = @TenantId", s); } // ───────────────── Batch 3 · D-06 源单状态 / 流程状态 语义消歧 ───────────────── /// /// 误导性 label「检验状态」必须消失——它绑定的是源字段 qms_gcjyd.status, /// 用户会误读成当前流程进度。 /// [Fact] public void Detail_NoLongerUsesMisleadingInspectionStatusLabel() { Assert.DoesNotContain("label=\"检验状态\"", Vue()); } /// 源字段改标为「源单状态」,但绑定的 DTO 字段不变(API contract 不动)。 [Fact] public void Detail_SourceStatus_RelabelledButBindingUnchanged() { var v = Vue(); Assert.Contains("label=\"源单状态\"", v); Assert.Contains("displayValue(detail.inspectionStatus)", v); } /// 工作流状态在详情顶部有显式「流程状态:」标注,不再是一个无标签的裸标签。 [Fact] public void Detail_WorkflowStatus_IsExplicitlyLabelled() { var v = Vue(); Assert.Contains("流程状态:", v); Assert.Contains("{{ flowStatusText }}", v); } /// /// 信息层级:流程状态(flow-panel)必须出现在源单状态(基础信息区)之前。 /// 抽屉先渲染 flow-panel 再渲染 el-descriptions,故以源码位置为序即可断言。 /// [Fact] public void Detail_WorkflowStatus_PrecedesSourceStatus() { var v = Vue(); Assert.True(v.IndexOf("流程状态:", StringComparison.Ordinal) < v.IndexOf("label=\"源单状态\"", StringComparison.Ordinal)); } /// 列表只展示流程状态,不得再引入第二个状态列造成二次困惑。 [Fact] public void List_ShowsOnlyWorkflowStatus_NoSourceStatusColumn() { var v = Vue(); Assert.Contains("prop=\"businessStatus\" label=\"流程状态\"", v); Assert.DoesNotContain("el-table-column prop=\"inspectionStatus\"", v); Assert.DoesNotContain("row.inspectionStatus", v); } /// Batch 2 的终态不显示当前节点逻辑保持不变(防回归)。 [Fact] public void CurrentNode_TerminalSuppression_StillIntact() { Assert.Contains("!flowState.isCompleted && flowState.currentNodeName", Vue()); } 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)); } }