AdoS0QmsSpecAggregateGuardTests.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. using Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
  2. using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Quality;
  5. /// <summary>
  6. /// 检验规范聚合必填契约(UAT-S0-10 拍板,2026-09-02)。
  7. /// 新增严格 / 编辑「历史兼容、禁止劣化」是两套规则,分别覆盖。
  8. /// </summary>
  9. public class AdoS0QmsSpecAggregateGuardTests
  10. {
  11. private static (string, string?)[] RawFields(string? file, string? mat, string? name, string? ver) =>
  12. [("文件编号", file), ("物料编码", mat), ("原材料名称", name), ("版本", ver)];
  13. // ==================== 新增 ====================
  14. [Fact]
  15. public void Create_WhenAllRequiredPresentAndHasItem_Passes()
  16. {
  17. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 1);
  18. Assert.Null(error);
  19. }
  20. /// <summary>UAT-S0-10 原始场景:只填文件编号、其余必填全空。</summary>
  21. [Fact]
  22. public void Create_WhenOnlyFileNumberFilled_IsRejected()
  23. {
  24. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("UATA-BAD-SPEC", null, null, null), 0);
  25. Assert.Equal("物料编码不能为空", error);
  26. }
  27. [Theory]
  28. [InlineData(null)]
  29. [InlineData("")]
  30. [InlineData(" ")]
  31. public void Create_TreatsWhitespaceAsMissing(string? blank)
  32. {
  33. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", blank, "V1"), 1);
  34. Assert.Equal("原材料名称不能为空", error);
  35. }
  36. /// <summary>头字段齐全但 0 条明细,仍必须拒绝——Create / Import 两条入口共用本规则。</summary>
  37. [Fact]
  38. public void Create_WhenNoDetail_IsRejected()
  39. {
  40. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 0);
  41. Assert.Equal("检验明细至少需要 1 条", error);
  42. }
  43. // ==================== 编辑:历史兼容 ====================
  44. [Fact]
  45. public void Update_WhenHistoricalFieldWasAlreadyBlank_AllowsStayingBlank()
  46. {
  47. // 实测 aidopdev 有 72 条历史记录缺必填字段;它们必须仍可编辑其他字段。
  48. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  49. [("物料编码", null, null), ("版本", "V1", "V2")], existingItemCount: 3, incomingMeaningfulItemCount: 3);
  50. Assert.Null(error);
  51. }
  52. [Fact]
  53. public void Update_WhenHistoricalRecordHadNoDetail_AllowsStayingZero()
  54. {
  55. // 实测 22 条历史记录 0 明细。
  56. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  57. [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: 0);
  58. Assert.Null(error);
  59. }
  60. [Fact]
  61. public void Update_WhenHistoricalBlankFieldGetsFilled_Passes()
  62. {
  63. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  64. [("物料编码", "", "M-001")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
  65. Assert.Null(error);
  66. }
  67. // ==================== 编辑:禁止劣化 ====================
  68. [Fact]
  69. public void Update_WhenClearingAPopulatedRequiredField_IsRejected()
  70. {
  71. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  72. [("物料编码", "M-001", " ")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
  73. Assert.Equal("物料编码原本已有值,不允许清空", error);
  74. }
  75. [Fact]
  76. public void Update_WhenDeletingAllExistingDetails_IsRejected()
  77. {
  78. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  79. [("物料编码", "M-001", "M-001")], existingItemCount: 5, incomingMeaningfulItemCount: 0);
  80. Assert.Equal("该检规原有 5 条检验明细,不允许全部删除", error);
  81. }
  82. // ==================== 文件编号:必填已从 [Required] 迁入守卫 ====================
  83. /// <summary>阶段 1.1 · 场景 1:历史 FileNumber 为空的记录,改其他字段必须能存。</summary>
  84. [Fact]
  85. public void Update_WhenHistoricalFileNumberIsNull_AllowsEditingOtherFields()
  86. {
  87. // 对应实测的 3 行 qms_gcjygf.wjbh IS NULL。
  88. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  89. [
  90. ("文件编号", null, null),
  91. ("物料编码", "M-001", "M-002"),
  92. ("版本", "V1", "V1")
  93. ],
  94. existingItemCount: 2, incomingMeaningfulItemCount: 2);
  95. Assert.Null(error);
  96. }
  97. /// <summary>阶段 1.1 · 场景 2:原本有文件编号,Update 清空必须拒绝。</summary>
  98. [Fact]
  99. public void Update_WhenClearingAnExistingFileNumber_IsRejected()
  100. {
  101. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  102. [("文件编号", "F-001", "")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
  103. Assert.Equal("文件编号原本已有值,不允许清空", error);
  104. }
  105. [Fact]
  106. public void Create_WhenFileNumberMissing_IsRejected()
  107. {
  108. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields(null, "M-001", "钢板", "V1"), 1);
  109. Assert.Equal("文件编号不能为空", error);
  110. }
  111. // ==================== 明细归一化:全空占位不落库 ====================
  112. /// <summary>阶段 1.1 · 场景 3:1 实 + 1 空 → 只保留 1 条真实明细。</summary>
  113. [Fact]
  114. public void Meaningful_DropsBlankPlaceholderRows_KeepingOnlyRealDetails()
  115. {
  116. var incoming = new List<AdoS0QmsRawInspectionSpecEntryDto>
  117. {
  118. new() { InspectionItem = "外观", UpperLimit = "10" },
  119. new() { InspectionItem = "", InspectionStandard = " ", UpperLimit = null }, // 前端 addItem 的空占位
  120. };
  121. var kept = AdoS0QmsSpecAggregateGuard.Meaningful(incoming);
  122. Assert.Single(kept);
  123. Assert.Equal("外观", kept[0].InspectionItem);
  124. // 过滤结果同时用于数量校验,保证「校验看到的」与「写进库的」一致
  125. Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count));
  126. }
  127. /// <summary>阶段 1.1 · 场景 4:明细全为空占位 → Create 按 0 明细拒绝。</summary>
  128. [Fact]
  129. public void Create_WhenAllDetailsAreBlankPlaceholders_IsRejected()
  130. {
  131. var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsRawInspectionSpecEntryDto> { new(), new() });
  132. Assert.Empty(kept);
  133. Assert.Equal("检验明细至少需要 1 条",
  134. AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count));
  135. }
  136. /// <summary>阶段 1.1 · 场景 5:历史 0 明细 + UI 空占位 → 放行,且不得写出任何明细。</summary>
  137. [Fact]
  138. public void Update_WhenHistoricalHadNoDetailAndUiAddsBlankPlaceholder_WritesNothing()
  139. {
  140. var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsProcessInspectionSpecEntryDto> { new() });
  141. Assert.Empty(kept); // 写库用的就是这份 → 不会新增全空明细
  142. Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  143. [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: kept.Count));
  144. }
  145. /// <summary>阶段 1.1 · 场景 6:原有真实明细被删到只剩空占位 → 按「删到 0」拒绝。</summary>
  146. [Fact]
  147. public void Update_WhenRealDetailsReducedToBlankPlaceholderOnly_IsRejected()
  148. {
  149. var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsFqcInspectionSpecEntryDto> { new() });
  150. Assert.Empty(kept);
  151. Assert.Equal("该检规原有 3 条检验明细,不允许全部删除",
  152. AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  153. [("物料编码", "M-001", "M-001")], existingItemCount: 3, incomingMeaningfulItemCount: kept.Count));
  154. }
  155. /// <summary>阶段 1.1 · 场景 7:导入侧口径与实体侧同源——空明细行不落库,全空 aggregate 拒绝。</summary>
  156. [Fact]
  157. public void Import_BlankDetailRowsAreNotCountedAndAllBlankAggregateIsRejected()
  158. {
  159. var headerOnly = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", RawMaterialName = "钢板", VersionNo = "V1" };
  160. var blankDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001" };
  161. var realDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", InspectionItem = "外观" };
  162. Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(headerOnly));
  163. Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(blankDetail));
  164. Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue(realDetail));
  165. // 只有表头行 + 空行的分组 → 0 条明细 → 整份拒绝
  166. var group = new[] { headerOnly, blankDetail };
  167. Assert.Equal("检验明细至少需要 1 条",
  168. AdoS0QmsSpecAggregateGuard.ValidateCreate(
  169. RawFields("F-001", "M-001", "钢板", "V1"),
  170. group.Count(AdoS0QmsSpecAggregateGuard.HasDetailValue)));
  171. }
  172. [Fact]
  173. public void Import_ProcessLikeBlankRowIsNotADetail()
  174. {
  175. Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(
  176. new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", VersionNo = "V1" }));
  177. Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue(
  178. new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", OperationCode = "OP10" }));
  179. }
  180. // ==================== 明细「有内容」判据 ====================
  181. [Fact]
  182. public void HasValue_WhenRawEntryIsEntirelyBlank_ReturnsFalse()
  183. {
  184. // 前端 openCreate 会自动补一行空明细;它不得让「≥1 条」形同虚设。
  185. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto
  186. {
  187. InspectionItem = "", InspectionStandard = " ", UpperLimit = null
  188. }));
  189. }
  190. [Fact]
  191. public void HasValue_WhenRawEntryHasAnyContent_ReturnsTrue()
  192. {
  193. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { InspectionItem = "外观" }));
  194. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { Seq = 1 }));
  195. }
  196. [Fact]
  197. public void HasValue_WhenProcessLikeEntryIsEntirelyBlank_ReturnsFalse()
  198. {
  199. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto()));
  200. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto()));
  201. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto()));
  202. }
  203. [Fact]
  204. public void HasValue_WhenProcessLikeEntryHasAnyContent_ReturnsTrue()
  205. {
  206. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto { OperationCode = "OP10" }));
  207. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto { InspectionItem = "尺寸" }));
  208. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto { PeelingForce = 5 }));
  209. }
  210. }