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);
}
}