package cn.iocoder.yudao.module.qcreport.engine.context; import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema; import cn.iocoder.yudao.module.qcreport.engine.QualityReportEngine; import cn.iocoder.yudao.module.qcreport.engine.render.RenderOutcome; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 数据快照的编解码。 *

* 这里是「历史报告可复现」的唯一实现路径,所以第一条用例不是比对快照本身, * 而是走完整链路:出件 → 冻结快照 → 从快照读回 → 重渲,产物必须与首次逐字相同。 * 只要这条断了,报告实例就会出现「生成时一个样、重新生成后另一个样」, * 而库里的数据看不出任何异常——这种问题事后极难追。 *

* 用的是与对拍同一份 fixture({@code schema.json} / {@code context.json} / * {@code expected-html.html}),所以「重渲一致」与「前后端一致」在这里是一次性验证的。 */ class ReportContextCodecTest { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final String FIXTURE = "qcreport/"; @Test @DisplayName("冻结快照读回后重渲,产物与首次出件逐字相同") void roundTripReproducesHtml() throws IOException { ReportTemplateSchema schema = readSchema(); // 1. 首次出件:判定 + 渲染 RenderOutcome first = QualityReportEngine.render(schema, readContext()); assertEquals(readFixture("expected-html.html"), first.html(), "首次出件就与前端引擎不一致"); // 2. 冻结快照(存的就是判定后的上下文) Map snapshot = ReportContextCodec.toMap(first.context()); // 3. 从快照读回,重渲 RenderOutcome second = QualityReportEngine.render(schema, ReportContextCodec.fromMap(snapshot)); assertEquals(first.html(), second.html(), "从数据快照重渲的 HTML 与首次出件不一致,历史报告不再可复现"); assertEquals(first.errors(), second.errors(), "重渲的数据缺口清单与首次出件不一致"); } @Test @DisplayName("快照里带着算好的判定结果与合格率") void snapshotCarriesEvaluation() throws IOException { RenderOutcome outcome = QualityReportEngine.render(readSchema(), readContext()); Map snapshot = ReportContextCodec.toMap(outcome.context()); @SuppressWarnings("unchecked") Map report = (Map) snapshot.get("report"); assertEquals("FAIL", report.get("result")); assertEquals(3, report.get("total")); assertEquals("33.33%", report.get("passRate")); @SuppressWarnings("unchecked") List items = (List) snapshot.get("inspectionItems"); assertEquals(3, items.size()); assertEquals("FAIL", ((Map) items.get(0)).get("result")); assertEquals("PASS", ((Map) items.get(1)).get("result")); } @Test @DisplayName("编解码往返不丢字段:文本、整数、上下限、空值都原样回来") void roundTripIsLossless() { InspectionItem item = new InspectionItem() .setIndex(7) .setItemCode("LEN") .setItemName("长度") .setStandardValue("10±0.5") .setActualValue("10.2") .setUnit("mm") // 检测要求与检测方法各占一处:模板里常各写一列,往返时必须都不丢 .setRequirement("10±0.5") .setCheckMethod("GB/T 1804-2000") .setUpperLimit(10.5D) .setLowerLimit(9.5D) .setResult("PASS") .setResultText("合格") .setRemark("抽检"); ReportContext original = new ReportContext() .setReport(new ReportFields() .setReportNo("QR20260918-0001") .setReportName("出货检验报告") .setProductName("法兰") .setInspector("张三") .setResult("PASS") .setResultText("合格") .setConclusion("准予出货") .setPassRate("100.00%") .setTotal(1) .setPassCount(1) .setFailCount(0)) .setInspectionItems(new ArrayList<>(List.of(item))); ReportContext restored = ReportContextCodec.fromMap(ReportContextCodec.toMap(original)); assertEquals(original, restored, "往返后有字段变了"); } @Test @DisplayName("没有规格区间的项,上下限保持为 null,不会被填成 0") void absentLimitsStayNull() { String itemName = "外观"; InspectionItem original = new InspectionItem().setIndex(1).setItemName(itemName); ReportContext restored = ReportContextCodec.fromMap( ReportContextCodec.toMap(new ReportContext().setInspectionItems(new ArrayList<>(List.of(original))))); InspectionItem item = restored.getInspectionItems().get(0); assertEquals(itemName, item.getItemName()); assertNull(item.getUpperLimit(), "没写上限的项被判成「上限 0」,规格判定会全错"); assertNull(item.getLowerLimit()); } @Test @DisplayName("字段不全的快照照常读回:缺的字段是「没值」(空串),不是报错") void partialSnapshotReadsAsEmpty() { Map snapshot = new LinkedHashMap<>(); Map report = new LinkedHashMap<>(); report.put("reportNo", "QR20260918-0001"); report.put("reportName", "来料检验报告"); snapshot.put("report", report); snapshot.put("inspectionItems", List.of(Map.of("index", 1, "itemName", "外观"))); ReportContext context = ReportContextCodec.fromMap(snapshot); assertEquals("QR20260918-0001", context.getReport().getReportNo()); assertEquals("来料检验报告", context.getReport().getReportName()); assertEquals("", context.getReport().getProductName(), "缺的字段应当是空串"); assertEquals(0, context.getReport().getTotal()); assertEquals(1, context.getInspectionItems().size()); assertEquals("", context.getInspectionItems().get(0).getActualValue()); } @Test @DisplayName("空快照读回空白上下文,不抛异常") void nullAndEmptySnapshotYieldEmptyContext() { assertNotNull(ReportContextCodec.fromMap(null).getReport()); assertTrue(ReportContextCodec.fromMap(null).getInspectionItems().isEmpty()); assertTrue(ReportContextCodec.fromMap(new LinkedHashMap<>()).getInspectionItems().isEmpty()); assertNotNull(ReportContextCodec.toMap(null).get("report")); } @Test @DisplayName("检验项数组里混进非对象元素时跳过该元素,不让整份报告打不开") void malformedItemIsSkipped() { Map snapshot = new LinkedHashMap<>(); snapshot.put("inspectionItems", List.of( Map.of("index", 1, "itemName", "外观"), "不是对象", Map.of("index", 3, "itemName", "硬度"))); ReportContext context = ReportContextCodec.fromMap(snapshot); assertEquals(2, context.getInspectionItems().size()); assertEquals("外观", context.getInspectionItems().get(0).getItemName()); assertEquals("硬度", context.getInspectionItems().get(1).getItemName()); } /* ------------------------------ 辅助 ------------------------------ */ private static ReportTemplateSchema readSchema() throws IOException { return MAPPER.readValue(readFixture("schema.json"), ReportTemplateSchema.class); } private static ReportContext readContext() throws IOException { return MAPPER.readValue(readFixture("context.json"), ReportContext.class); } private static String readFixture(String name) throws IOException { try (InputStream in = ReportContextCodecTest.class.getClassLoader() .getResourceAsStream(FIXTURE + name)) { if (in == null) { throw new IOException("测试 fixture 缺失:" + FIXTURE + name); } return new String(in.readAllBytes(), StandardCharsets.UTF_8); } } }