S6IpqcDisplayContractTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
  3. /// <summary>
  4. /// S6 DISPLAY FIX · Batch 1 源码契约测试。
  5. ///
  6. /// D-04:IPQC head transform 的字符串列必须经 <c>MdpJsonSql.Str</c> 把 JSON null 归一为 SQL NULL,
  7. /// 否则 <c>JSON_UNQUOTE(JSON_EXTRACT(...))</c> 会产出字面量字符串 'null' 并一路透到页面。
  8. /// detail transform 早已正确,本测试同时钉住其不回归。
  9. /// D-01:完成态 message 必须区分 APPROVED(整批合格)与 DISPOSED(整批不合格已处置),
  10. /// 不得只依据 ApprovalFlowInstance.Status 一律说"主管审核通过"。
  11. /// </summary>
  12. public class S6IpqcDisplayContractTests
  13. {
  14. private static string SyncService() => File.ReadAllText(FindFile(
  15. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionMdpSyncService.cs"));
  16. private static string FlowService() => File.ReadAllText(FindFile(
  17. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowService.cs"));
  18. private static string JsonSql() => File.ReadAllText(FindFile(
  19. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpJsonSql.cs"));
  20. private static string Vue() => File.ReadAllText(FindFile(
  21. "Web", "src", "views", "aidop", "s6", "process-quality", "processInspectionList.vue"));
  22. private static string ListService() => File.ReadAllText(FindFile(
  23. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionService.cs"));
  24. /// <summary>head transform 的每个字符串列都必须走 MdpJsonSql.Str。</summary>
  25. [Theory]
  26. [InlineData("djbh")]
  27. [InlineData("cplx")]
  28. [InlineData("scph")]
  29. [InlineData("lydjbh")]
  30. [InlineData("jgpd")]
  31. [InlineData("fj")]
  32. [InlineData("bz")]
  33. [InlineData("jyr")]
  34. [InlineData("gxbm")]
  35. [InlineData("gxmc")]
  36. [InlineData("sczyry")]
  37. [InlineData("bdbh")]
  38. [InlineData("bbh")]
  39. [InlineData("wlbm")]
  40. [InlineData("wlmc")]
  41. [InlineData("jgbb")]
  42. [InlineData("jgbh")]
  43. [InlineData("status")]
  44. public void HeadTransform_StringField_UsesNullSafeHelper(string field)
  45. {
  46. Assert.Contains($"MdpJsonSql.Str(\"m\", \"{field}\")", SyncService());
  47. }
  48. /// <summary>数值/日期列用对应类型 helper,不得被误改成字符串 helper。</summary>
  49. [Fact]
  50. public void HeadTransform_NumericAndDateFields_UseTypedHelpers()
  51. {
  52. var s = SyncService();
  53. Assert.Contains("MdpJsonSql.Dec(\"m\", \"ybl\", 18, 6)", s);
  54. Assert.Contains("MdpJsonSql.DateTimeSec(\"m\", \"sxrq\")", s);
  55. Assert.DoesNotContain("MdpJsonSql.Str(\"m\", \"ybl\")", s);
  56. Assert.DoesNotContain("MdpJsonSql.Str(\"m\", \"sxrq\")", s);
  57. }
  58. /// <summary>
  59. /// 除身份键 source_row_id / source_biz_key 的两处兜底外,转换 SQL 不得再出现裸 JSON_UNQUOTE。
  60. /// 这两处刻意保留:它们是 NOT NULL 的 upsert 身份列,不是展示列,改动会影响唯一键语义。
  61. /// </summary>
  62. [Fact]
  63. public void TransformSql_NoBareJsonUnquote_ExceptIdentityKeyFallbacks()
  64. {
  65. var s = SyncService();
  66. var bare = s.Split("JSON_UNQUOTE(JSON_EXTRACT(").Length - 1;
  67. Assert.Equal(2, bare);
  68. Assert.Contains("IFNULL(m.source_row_id, JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.id')))", s);
  69. Assert.Contains("IFNULL(NULLIF(m.source_biz_key,''), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.djbh')))", s);
  70. }
  71. /// <summary>detail transform 既有的 null 安全行为不得回归。</summary>
  72. [Fact]
  73. public void DetailTransform_StillUsesNullSafeHelpers()
  74. {
  75. var s = SyncService();
  76. Assert.Contains("MdpJsonSql.Str(\"d\", \"jyxm\")", s);
  77. Assert.Contains("MdpJsonSql.Str(\"d\", \"pd\")", s);
  78. Assert.Contains("MdpJsonSql.Dec(\"d\", \"jysl\", 18, 6)", s);
  79. Assert.Contains("MdpJsonSql.DateTimeSec(\"d\", \"InspectionTime\")", s);
  80. }
  81. /// <summary>helper 语义钉死:JSON null / 字符串 'null' → SQL NULL。</summary>
  82. [Fact]
  83. public void JsonSqlStrHelper_NormalizesLiteralNullToSqlNull()
  84. {
  85. Assert.Contains("NULLIF({Ext(alias, field)},'null')", JsonSql());
  86. }
  87. /// <summary>完成态文案必须由 businessStatus 决定,而非只看实例状态。</summary>
  88. [Fact]
  89. public void CompletedMessage_BranchesOnBusinessStatus()
  90. {
  91. var s = FlowService();
  92. Assert.Contains("BuildCompletedMessage(output.BusinessStatus)", s);
  93. Assert.Contains("IpqcInspectionFlowConst.StatusApproved => \"流程已完成(整批合格·主管审核通过)\"", s);
  94. Assert.Contains("IpqcInspectionFlowConst.StatusDisposed => \"流程已完成(整批不合格·已处置)\"", s);
  95. }
  96. /// <summary>两个完成终态的文案不得相同——这正是 D-01 的缺陷本身。</summary>
  97. [Fact]
  98. public void CompletedMessage_ApprovedAndDisposed_AreNotIdentical()
  99. {
  100. var s = FlowService();
  101. Assert.DoesNotContain(
  102. "inst.Status == FlowInstanceStatusEnum.Approved ? \"流程已完成(主管审核通过)\"", s);
  103. Assert.Contains("_ => \"流程已完成\",", s); // 业务状态缺失时中性表述,不臆断"审核通过"
  104. }
  105. /// <summary>非 Approved 的结束态文案保持原样,未被本批波及。</summary>
  106. [Fact]
  107. public void NonApprovedTerminal_MessageUnchanged()
  108. {
  109. Assert.Contains("$\"流程已结束({inst.Status})\"", FlowService());
  110. }
  111. /// <summary>
  112. /// 状态机未被改动:节点码、网关判据、审批人解析、can* 判据、任务归属均保持原样。
  113. /// D-01 只允许改"给 UI 的解释性文案"。
  114. /// </summary>
  115. [Fact]
  116. public void StateMachine_Untouched()
  117. {
  118. var s = FlowService();
  119. Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeInspect", s);
  120. Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeSupervisor", s);
  121. Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeDisposition", s);
  122. Assert.Contains("output.CanSupervisorApprove = true;", s);
  123. Assert.Contains("output.CanSupervisorReturn = true;", s);
  124. Assert.Contains("output.CanSubmitDisposition = true;", s);
  125. // 整批判定仍驱动网关,未被文案改动波及
  126. Assert.Contains("var dispositionRequired = input.Qualified ? 0 : 1;", s);
  127. }
  128. // ───────────────────────── Batch 2 · UI Readability ─────────────────────────
  129. /// <summary>六个业务状态 + 兜底 flowStatus 都必须有中文映射。</summary>
  130. [Theory]
  131. [InlineData("INSPECTING", "检验中")]
  132. [InlineData("PENDING_REVIEW", "待主管审核")]
  133. [InlineData("APPROVED", "已通过")]
  134. [InlineData("RETURNED", "已退回")]
  135. [InlineData("PENDING_DISPOSITION", "待质量处置")]
  136. [InlineData("DISPOSED", "已处置")]
  137. [InlineData("NotStarted", "待检验")]
  138. [InlineData("Running", "流程进行中")]
  139. public void BusinessStatus_HasChineseMapping(string raw, string zh)
  140. {
  141. Assert.Contains($"{raw}: '{zh}'", Vue());
  142. }
  143. /// <summary>未知状态原样返回,不静默显示"未知";空值 → '-'。</summary>
  144. [Fact]
  145. public void BusinessStatusText_UnknownFallsBackToRawValue()
  146. {
  147. var v = Vue();
  148. Assert.Contains("return BUSINESS_STATUS_TEXT[v] ?? v;", v);
  149. Assert.Contains("if (v === null || v === undefined || v === '') return '-';", v);
  150. }
  151. /// <summary>判定 0/1 中文化,且 0 不得被 truthy 判断吞掉(string 与 number 两型都认)。</summary>
  152. [Fact]
  153. public void JudgementText_MapsZeroAndOne_WithoutTruthyCheck()
  154. {
  155. var v = Vue();
  156. Assert.Contains("if (v === 0 || v === '0') return '合格';", v);
  157. Assert.Contains("if (v === 1 || v === '1') return '不合格';", v);
  158. Assert.DoesNotContain("resultJudgement || '-'", v);
  159. }
  160. /// <summary>空值占位统一走 displayValue;0 / false 是合法值必须原样显示。</summary>
  161. [Fact]
  162. public void DisplayValue_PreservesZeroAndFalse()
  163. {
  164. var v = Vue();
  165. Assert.Contains("function displayValue(v: unknown)", v);
  166. Assert.Contains("if (v === null || v === undefined) return '-';", v);
  167. // 不得再用 || '-' 处理可能为 0 的字段;仅允许 dispTypeText 的字符串枚举兜底
  168. Assert.Equal(1, v.Split("|| '-'").Length - 1);
  169. Assert.Contains("REWORK: '返工'", v);
  170. }
  171. /// <summary>列表必须有「流程状态」列,且绑定 businessStatus 而非源字段 inspectionStatus。</summary>
  172. [Fact]
  173. public void List_HasWorkflowStatusColumn_BoundToBusinessStatus()
  174. {
  175. var v = Vue();
  176. Assert.Contains("label=\"流程状态\"", v);
  177. Assert.Contains("businessStatusText(row.businessStatus)", v);
  178. Assert.DoesNotContain("businessStatusText(row.inspectionStatus)", v);
  179. }
  180. /// <summary>模板不得直接插值原始业务状态枚举。</summary>
  181. [Fact]
  182. public void Template_DoesNotRenderRawBusinessStatus()
  183. {
  184. var v = Vue();
  185. Assert.DoesNotContain("{{ row.businessStatus }}", v);
  186. Assert.DoesNotContain("{{ flowState.businessStatus }}", v);
  187. Assert.DoesNotContain("{{ detail.resultJudgement }}", v);
  188. }
  189. /// <summary>currentNodeName 被使用,且终态不展示(避免残留旧节点误导)。</summary>
  190. [Fact]
  191. public void CurrentNodeName_IsRendered_AndHiddenWhenCompleted()
  192. {
  193. var v = Vue();
  194. Assert.Contains("flowState.currentNodeName", v);
  195. Assert.Contains("!flowState.isCompleted && flowState.currentNodeName", v);
  196. }
  197. /// <summary>日期复用项目公共 formatter,不再自写 slice 逻辑;空值 → '-'。</summary>
  198. [Fact]
  199. public void Dates_ReuseSharedFormatter_AndPlaceholderOnEmpty()
  200. {
  201. var v = Vue();
  202. Assert.Contains("import { formatDate } from '/@/utils/formatTime';", v);
  203. Assert.Contains("formatDate(d, 'YYYY-mm-dd')", v);
  204. Assert.Contains("formatDate(d, 'YYYY-mm-dd HH:MM:SS')", v);
  205. Assert.DoesNotContain("v.slice(0, 10)", v); // 旧的自写实现已移除
  206. Assert.Contains("return isNaN(d.getTime()) ? null : d;", v); // Invalid Date → '-'
  207. }
  208. /// <summary>提交契约不得被中文化污染:payload / v-model / option value 仍为原始值。</summary>
  209. [Fact]
  210. public void SubmitContract_KeepsRawValues()
  211. {
  212. var v = Vue();
  213. Assert.Contains("label=\"合格(0)\" :value=\"0\"", v);
  214. Assert.Contains("label=\"不合格(1)\" :value=\"1\"", v);
  215. Assert.Contains("value=\"REWORK\"", v);
  216. Assert.DoesNotContain(":value=\"'合格'\"", v);
  217. }
  218. /// <summary>列表流程状态来自 flow_state 左联,且租户与软删都收口。</summary>
  219. [Fact]
  220. public void ListSql_JoinsFlowState_TenantScoped()
  221. {
  222. var s = ListService();
  223. Assert.Contains("f.business_status AS BusinessStatus", s);
  224. Assert.Contains("LEFT JOIN ado_s6_ipqc_inspection_flow_state f", s);
  225. Assert.Contains("ON f.inspection_id = CAST(m.source_row_id AS SIGNED)", s);
  226. Assert.Contains("AND f.tenant_id = m.tenant_id", s);
  227. Assert.Contains("AND f.IsDelete = 0", s);
  228. // 列表仍强制按登录租户过滤
  229. Assert.Contains("m.tenant_id = @TenantId", s);
  230. }
  231. private static string FindFile(params string[] parts)
  232. {
  233. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  234. while (dir != null)
  235. {
  236. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  237. if (File.Exists(candidate)) return candidate;
  238. dir = dir.Parent;
  239. }
  240. throw new FileNotFoundException(string.Join("/", parts));
  241. }
  242. }