package cn.iocoder.yudao.module.qcreport.service.aiimport.llm; import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO; import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportComponentSpecVO; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; 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.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 草稿归一:把不可信的模型输出收敛成「只含合法组件、合法属性」的清单。 *

* 守的是一条设计原则:单个元素坏掉不能让整个请求失败。模型的输出是一条长 JSON, * 里面混进一个没见过的 type 是常态;此时正确的反应是丢掉那一项并告诉用户, * 而不是把整套已经识别对的组件一起扔掉,也不是让接口 500。 *

* 因此这里的断言几乎都成对出现:被丢掉的那项出了问题,其余项仍然完好。 */ class QcReportAiDraftNormalizerTest { @Test @DisplayName("清单外的组件类型被丢掉,同时产出可读提示") void unknownTypeDropped() { QcReportAiDraftNormalizer.Result result = normalize(List.of( component("CustomHtml", Map.of("html", "x")), component("Heading", Map.of("text", "检验项目")))); assertEquals(1, result.components().size(), "清单外的类型必须被丢掉"); assertEquals("Heading", result.components().get(0).getType()); assertTrue(hasWarning(result, "CustomHtml"), "必须告诉用户丢掉了什么类型,否则会以为文件里的内容都识别到了"); } @Test @DisplayName("清单未声明的属性 key 被丢掉,声明过的原样保留") void undeclaredPropsDropped() { QcReportAiDraftNormalizer.Result result = normalize(List.of( component("Heading", Map.of("text", "检验项目", "fontSize", 20, "color", "red")))); assertEquals(1, result.components().size()); Map props = result.components().get(0).getProps(); assertEquals(Map.of("text", "检验项目"), props); assertTrue(hasWarning(result, "fontSize") && hasWarning(result, "color"), "被丢弃的属性 key 要逐个写明,用户才知道前端为什么没渲染出来"); } @Test @DisplayName("清单没声明 fields 时不做过滤:无从判断不等于全都非法") void nullFieldsMeansNoFiltering() { QcReportComponentSpecVO spec = new QcReportComponentSpecVO(); spec.setType("Heading"); spec.setLabel("标题"); // 刻意不 setFields Map props = Map.of("text", "检验项目"); QcReportAiDraftNormalizer.Result result = QcReportAiDraftNormalizer.normalize(List.of(component("Heading", props)), List.of(spec), "f.pdf"); assertEquals(1, result.components().size()); assertSame(props, result.components().get(0).getProps(), "fields 为 null 表示清单压根没声明,此时过滤会把识别对的属性静默抹平"); } @Test @DisplayName("清单明确声明「不吃任何属性」时,属性确实被丢光,组件随之被丢掉") void emptyFieldsDropsEverything() { QcReportComponentSpecVO spec = new QcReportComponentSpecVO(); spec.setType("Divider"); spec.setLabel("分隔线"); spec.setFields(List.of()); QcReportAiDraftNormalizer.Result result = QcReportAiDraftNormalizer.normalize( List.of(component("Divider", Map.of("thickness", 1))), List.of(spec), "f.pdf"); assertTrue(result.components().isEmpty(), "属性全被过滤后组件没有意义,应当一并丢掉"); assertTrue(hasWarning(result, "分隔线")); } @Test @DisplayName("缺类型、缺属性、空元素各自被丢掉,其余组件不受影响") void malformedItemsAreIsolated() { List raw = new ArrayList<>(); raw.add(null); raw.add(component(" ", Map.of("text", "x"))); raw.add(component("Heading", null)); raw.add(component("Heading", Map.of())); raw.add(component("Heading", Map.of("text", "好的一项"))); QcReportAiDraftNormalizer.Result result = normalize(raw); assertEquals(1, result.components().size(), "四个坏元素不该带走唯一一个好元素"); assertEquals("好的一项", result.components().get(0).getProps().get("text")); assertEquals(4, result.warnings().size(), "每一个被丢掉的元素都要有对应的提示,不能静默"); } @Test @DisplayName("必填属性缺失时提示,但不丢掉组件——用户可以在设计器里补") void missingRequiredWarnsButKeeps() { QcReportAiDraftNormalizer.Result result = normalize( List.of(component("QualityTable", Map.of("title", "检验项目")))); assertEquals(1, result.components().size(), "缺必填不该丢组件:设计器里还能补,丢了用户得重新拖"); assertTrue(hasWarning(result, "检验项数据源"), "提示要用字段显示名而不是 key,用户看到的是属性面板上的中文标签"); } @Test @DisplayName("必填属性值为空白串等同于缺失") void blankRequiredCountsAsMissing() { assertTrue(hasWarning(normalize(List.of(component("QualityTable", Map.of("itemsPath", " ")))), "检验项数据源")); } @Test @DisplayName("多页结果按 (type, props) 去重:props 的 key 顺序不同也算同一个组件") void mergeDedupsAcrossPages() { Map pageOne = new LinkedHashMap<>(); pageOne.put("title", "来料检验报告"); pageOne.put("showLogo", true); Map pageTwo = new LinkedHashMap<>(); pageTwo.put("showLogo", true); pageTwo.put("title", "来料检验报告"); QcReportAiDraftNormalizer.Result merged = QcReportAiDraftNormalizer.mergeAndDedup(List.of( normalize(List.of(component("ReportHeader", pageOne))), normalize(List.of(component("ReportHeader", pageTwo))), normalize(List.of(component("Heading", Map.of("text", "检验项目"))))), 200); assertEquals(2, merged.components().size(), "页眉在三页里都出现,最终只该留一份"); assertEquals("ReportHeader", merged.components().get(0).getType()); assertTrue(merged.warnings().stream().anyMatch(w -> w.contains("重复") || w.contains("已合并")), "合并了重复项要讲一声,否则用户会奇怪为什么上传三页只出一个页眉"); } @Test @DisplayName("单次调用不做重复提示:只有一个来源时没有「多页重复」可言") void noDedupWarningForSingleCall() { QcReportAiDraftNormalizer.Result merged = QcReportAiDraftNormalizer.mergeAndDedup( List.of(normalize(List.of(component("Heading", Map.of("text", "检验项目"))))), 200); assertFalse(merged.warnings().stream().anyMatch(w -> w.contains("已合并"))); } @Test @DisplayName("超过上限时截断并提示补录,而不是静默丢弃") void capTruncatesWithWarning() { List many = new ArrayList<>(); for (int i = 0; i < 5; i++) { many.add(component("Heading", Map.of("text", "标题" + i))); } QcReportAiDraftNormalizer.Result merged = QcReportAiDraftNormalizer.mergeAndDedup( List.of(normalize(many)), 3); assertEquals(3, merged.components().size()); assertTrue(merged.warnings().stream().anyMatch(w -> w.contains("超过单次上限")), "截断必须提示:否则用户以为文件里就只有 3 个组件"); } @Test @DisplayName("提示都带来源标签,多文件时才分得清是哪一份出的问题") void warningsCarrySourceLabel() { QcReportAiDraftNormalizer.Result result = QcReportAiDraftNormalizer.normalize( List.of(component("CustomHtml", Map.of("html", "x"))), catalog(), "扫描件.pdf 第 2 页"); assertEquals(1, result.warnings().size()); assertTrue(result.warnings().get(0).startsWith("扫描件.pdf 第 2 页:"), "不带来源的提示在「一次传三个文件」时等于没说"); } // ==================== 夹具 ==================== private static QcReportAiDraftNormalizer.Result normalize(List raw) { return QcReportAiDraftNormalizer.normalize(raw, catalog(), "扫描件.pdf"); } private static boolean hasWarning(QcReportAiDraftNormalizer.Result result, String fragment) { return result.warnings().stream().anyMatch(w -> w.contains(fragment)); } private static QcReportAiDraftRespVO.DraftComponent component(String type, Map props) { QcReportAiDraftRespVO.DraftComponent component = new QcReportAiDraftRespVO.DraftComponent(); component.setType(type); component.setProps(props); return component; } private static List catalog() { QcReportComponentSpecVO heading = spec("Heading", "标题", field("text", "标题文字", true)); QcReportComponentSpecVO table = spec("QualityTable", "检验项目表格", field("title", "表格标题", false), requiredField("itemsPath", "检验项数据源"), field("showIndex", "显示序号", false)); QcReportComponentSpecVO header = spec("ReportHeader", "报告抬头", field("title", "报告标题", false), field("showLogo", "显示 logo", false)); QcReportComponentSpecVO divider = new QcReportComponentSpecVO(); divider.setType("Divider"); divider.setLabel("分隔线"); divider.setFields(List.of()); return List.of(heading, table, header, divider); } private static QcReportComponentSpecVO spec(String type, String label, QcReportComponentSpecVO.Field... fields) { QcReportComponentSpecVO spec = new QcReportComponentSpecVO(); spec.setType(type); spec.setLabel(label); spec.setFields(List.of(fields)); return spec; } private static QcReportComponentSpecVO.Field field(String key, String label, boolean required) { QcReportComponentSpecVO.Field field = new QcReportComponentSpecVO.Field(); field.setKey(key); field.setLabel(label); field.setRequired(required); return field; } private static QcReportComponentSpecVO.Field requiredField(String key, String label) { return field(key, label, true); } }