using Admin.NET.Plugin.AiDOP.Controllers.S0.Quality; using Admin.NET.Plugin.AiDOP.Dto.S0.Quality; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S0.Quality; /// /// 检验规范聚合必填契约(UAT-S0-10 拍板,2026-09-02)。 /// 新增严格 / 编辑「历史兼容、禁止劣化」是两套规则,分别覆盖。 /// public class AdoS0QmsSpecAggregateGuardTests { private static (string, string?)[] RawFields(string? file, string? mat, string? name, string? ver) => [("文件编号", file), ("物料编码", mat), ("原材料名称", name), ("版本", ver)]; // ==================== 新增 ==================== [Fact] public void Create_WhenAllRequiredPresentAndHasItem_Passes() { var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 1); Assert.Null(error); } /// UAT-S0-10 原始场景:只填文件编号、其余必填全空。 [Fact] public void Create_WhenOnlyFileNumberFilled_IsRejected() { var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("UATA-BAD-SPEC", null, null, null), 0); Assert.Equal("物料编码不能为空", error); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void Create_TreatsWhitespaceAsMissing(string? blank) { var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", blank, "V1"), 1); Assert.Equal("原材料名称不能为空", error); } /// 头字段齐全但 0 条明细,仍必须拒绝——Create / Import 两条入口共用本规则。 [Fact] public void Create_WhenNoDetail_IsRejected() { var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 0); Assert.Equal("检验明细至少需要 1 条", error); } // ==================== 编辑:历史兼容 ==================== [Fact] public void Update_WhenHistoricalFieldWasAlreadyBlank_AllowsStayingBlank() { // 实测 aidopdev 有 72 条历史记录缺必填字段;它们必须仍可编辑其他字段。 var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("物料编码", null, null), ("版本", "V1", "V2")], existingItemCount: 3, incomingMeaningfulItemCount: 3); Assert.Null(error); } [Fact] public void Update_WhenHistoricalRecordHadNoDetail_AllowsStayingZero() { // 实测 22 条历史记录 0 明细。 var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: 0); Assert.Null(error); } [Fact] public void Update_WhenHistoricalBlankFieldGetsFilled_Passes() { var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("物料编码", "", "M-001")], existingItemCount: 1, incomingMeaningfulItemCount: 1); Assert.Null(error); } // ==================== 编辑:禁止劣化 ==================== [Fact] public void Update_WhenClearingAPopulatedRequiredField_IsRejected() { var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("物料编码", "M-001", " ")], existingItemCount: 1, incomingMeaningfulItemCount: 1); Assert.Equal("物料编码原本已有值,不允许清空", error); } [Fact] public void Update_WhenDeletingAllExistingDetails_IsRejected() { var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("物料编码", "M-001", "M-001")], existingItemCount: 5, incomingMeaningfulItemCount: 0); Assert.Equal("该检规原有 5 条检验明细,不允许全部删除", error); } // ==================== 文件编号:必填已从 [Required] 迁入守卫 ==================== /// 阶段 1.1 · 场景 1:历史 FileNumber 为空的记录,改其他字段必须能存。 [Fact] public void Update_WhenHistoricalFileNumberIsNull_AllowsEditingOtherFields() { // 对应实测的 3 行 qms_gcjygf.wjbh IS NULL。 var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [ ("文件编号", null, null), ("物料编码", "M-001", "M-002"), ("版本", "V1", "V1") ], existingItemCount: 2, incomingMeaningfulItemCount: 2); Assert.Null(error); } /// 阶段 1.1 · 场景 2:原本有文件编号,Update 清空必须拒绝。 [Fact] public void Update_WhenClearingAnExistingFileNumber_IsRejected() { var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("文件编号", "F-001", "")], existingItemCount: 1, incomingMeaningfulItemCount: 1); Assert.Equal("文件编号原本已有值,不允许清空", error); } [Fact] public void Create_WhenFileNumberMissing_IsRejected() { var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields(null, "M-001", "钢板", "V1"), 1); Assert.Equal("文件编号不能为空", error); } // ==================== 明细归一化:全空占位不落库 ==================== /// 阶段 1.1 · 场景 3:1 实 + 1 空 → 只保留 1 条真实明细。 [Fact] public void Meaningful_DropsBlankPlaceholderRows_KeepingOnlyRealDetails() { var incoming = new List { new() { InspectionItem = "外观", UpperLimit = "10" }, new() { InspectionItem = "", InspectionStandard = " ", UpperLimit = null }, // 前端 addItem 的空占位 }; var kept = AdoS0QmsSpecAggregateGuard.Meaningful(incoming); Assert.Single(kept); Assert.Equal("外观", kept[0].InspectionItem); // 过滤结果同时用于数量校验,保证「校验看到的」与「写进库的」一致 Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count)); } /// 阶段 1.1 · 场景 4:明细全为空占位 → Create 按 0 明细拒绝。 [Fact] public void Create_WhenAllDetailsAreBlankPlaceholders_IsRejected() { var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List { new(), new() }); Assert.Empty(kept); Assert.Equal("检验明细至少需要 1 条", AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count)); } /// 阶段 1.1 · 场景 5:历史 0 明细 + UI 空占位 → 放行,且不得写出任何明细。 [Fact] public void Update_WhenHistoricalHadNoDetailAndUiAddsBlankPlaceholder_WritesNothing() { var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List { new() }); Assert.Empty(kept); // 写库用的就是这份 → 不会新增全空明细 Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: kept.Count)); } /// 阶段 1.1 · 场景 6:原有真实明细被删到只剩空占位 → 按「删到 0」拒绝。 [Fact] public void Update_WhenRealDetailsReducedToBlankPlaceholderOnly_IsRejected() { var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List { new() }); Assert.Empty(kept); Assert.Equal("该检规原有 3 条检验明细,不允许全部删除", AdoS0QmsSpecAggregateGuard.ValidateUpdate( [("物料编码", "M-001", "M-001")], existingItemCount: 3, incomingMeaningfulItemCount: kept.Count)); } /// 阶段 1.1 · 场景 7:导入侧口径与实体侧同源——空明细行不落库,全空 aggregate 拒绝。 [Fact] public void Import_BlankDetailRowsAreNotCountedAndAllBlankAggregateIsRejected() { var headerOnly = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", RawMaterialName = "钢板", VersionNo = "V1" }; var blankDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001" }; var realDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", InspectionItem = "外观" }; Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(headerOnly)); Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(blankDetail)); Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue(realDetail)); // 只有表头行 + 空行的分组 → 0 条明细 → 整份拒绝 var group = new[] { headerOnly, blankDetail }; Assert.Equal("检验明细至少需要 1 条", AdoS0QmsSpecAggregateGuard.ValidateCreate( RawFields("F-001", "M-001", "钢板", "V1"), group.Count(AdoS0QmsSpecAggregateGuard.HasDetailValue))); } [Fact] public void Import_ProcessLikeBlankRowIsNotADetail() { Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue( new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", VersionNo = "V1" })); Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue( new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", OperationCode = "OP10" })); } // ==================== 明细「有内容」判据 ==================== [Fact] public void HasValue_WhenRawEntryIsEntirelyBlank_ReturnsFalse() { // 前端 openCreate 会自动补一行空明细;它不得让「≥1 条」形同虚设。 Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { InspectionItem = "", InspectionStandard = " ", UpperLimit = null })); } [Fact] public void HasValue_WhenRawEntryHasAnyContent_ReturnsTrue() { Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { InspectionItem = "外观" })); Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { Seq = 1 })); } [Fact] public void HasValue_WhenProcessLikeEntryIsEntirelyBlank_ReturnsFalse() { Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto())); Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto())); Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto())); } [Fact] public void HasValue_WhenProcessLikeEntryHasAnyContent_ReturnsTrue() { Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto { OperationCode = "OP10" })); Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto { InspectionItem = "尺寸" })); Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto { PeelingForce = 5 })); } }