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;
|
|
/**
|
* 草稿归一:把不可信的模型输出收敛成「只含合法组件、合法属性」的清单。
|
* <p>
|
* 守的是一条设计原则:<b>单个元素坏掉不能让整个请求失败</b>。模型的输出是一条长 JSON,
|
* 里面混进一个没见过的 type 是常态;此时正确的反应是丢掉那一项并告诉用户,
|
* 而不是把整套已经识别对的组件一起扔掉,也不是让接口 500。
|
* <p>
|
* 因此这里的断言几乎都成对出现:被丢掉的那项出了问题,<b>其余项仍然完好</b>。
|
*/
|
class QcReportAiDraftNormalizerTest {
|
|
@Test
|
@DisplayName("清单外的组件类型被丢掉,同时产出可读提示")
|
void unknownTypeDropped() {
|
QcReportAiDraftNormalizer.Result result = normalize(List.of(
|
component("CustomHtml", Map.of("html", "<b>x</b>")),
|
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<String, Object> 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<String, Object> 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<QcReportAiDraftRespVO.DraftComponent> 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<String, Object> pageOne = new LinkedHashMap<>();
|
pageOne.put("title", "来料检验报告");
|
pageOne.put("showLogo", true);
|
Map<String, Object> 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<QcReportAiDraftRespVO.DraftComponent> 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<QcReportAiDraftRespVO.DraftComponent> 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<String, Object> props) {
|
QcReportAiDraftRespVO.DraftComponent component = new QcReportAiDraftRespVO.DraftComponent();
|
component.setType(type);
|
component.setProps(props);
|
return component;
|
}
|
|
private static List<QcReportComponentSpecVO> 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);
|
}
|
|
}
|