package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
|
|
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
import cn.iocoder.yudao.module.qcreport.config.QcReportAiImportProperties;
|
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.Test;
|
import org.springframework.test.util.ReflectionTestUtils;
|
|
import java.util.ArrayList;
|
import java.util.List;
|
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
/**
|
* 输入适配器:路由与抽取。
|
* <p>
|
* 守的是三件事:
|
* <ol>
|
* <li><b>路由不能有歧义</b>——同一份文件必须稳定地落到同一个适配器,否则「同一张照片今天走文本、
|
* 明天走图片」这种漂移会在排查时把人绕死。</li>
|
* <li><b>通道判定依据是「有没有文本层」</b>,不是扩展名。这两者被搞混时,扫描版 PDF 会被当成
|
* 电子版去抽文本,抽出一片空白,而用户看到的只是「AI 什么都没识别出来」。</li>
|
* <li><b>旧格式 .doc/.xls 要给出精确出路</b>——走通用「格式不支持」的文案等于没说,
|
* 因为用户的文件扩展名看起来完全合规。</li>
|
* </ol>
|
* <p>
|
* 这些用例都不需要 Spring 容器:适配器只依赖一个配置对象,用 {@code ReflectionTestUtils} 注入即可。
|
* 起容器测这些「纯输入 → 纯输出」的类,慢且不说,还会把失败原因埋在容器启动日志里。
|
*/
|
class QcReportImportAdapterTest {
|
|
@Test
|
@DisplayName("图片适配器认领常见图片扩展名,且原字节直通不解码")
|
void imageAdapterSupports() {
|
ImageImportAdapter adapter = new ImageImportAdapter();
|
for (String extension : List.of("png", "jpg", "jpeg", "bmp", "gif", "webp")) {
|
assertTrue(adapter.supports(extension, null), extension + " 应当被图片适配器认领");
|
}
|
assertFalse(adapter.supports("pdf", null));
|
assertFalse(adapter.supports("", null), "没有扩展名时不该被任何适配器认领");
|
}
|
|
@Test
|
@DisplayName("图片走 IMAGES 通道,单页,MIME 由扩展名推出")
|
void imageAdapterExtractsSinglePage() {
|
byte[] bytes = "假装这是照片字节".getBytes();
|
QcReportDocumentExtract extract = new ImageImportAdapter().extract(bytes, "现场照片.JPG");
|
|
assertEquals(QcReportDocumentExtract.Channel.IMAGES, extract.channel());
|
assertEquals(1, extract.pageCount());
|
assertEquals("raw-image", extract.extractor());
|
assertEquals(1, extract.images().size());
|
assertEquals("image/jpeg", extract.images().get(0).mimeType());
|
assertArrayEquals(bytes, extract.images().get(0).bytes(),
|
"图片必须原字节直通:中间解码再编码一次会丢掉元数据,也白白多一次内存拷贝");
|
}
|
|
@Test
|
@DisplayName("有文本层的 PDF 走 TEXT 通道:一次调用,不渲染页面")
|
void pdfWithTextLayerGoesTextChannel() {
|
QcReportDocumentExtract extract = pdfAdapter().extract(AiImportFixtures.textPdf(2), "报告.pdf");
|
|
assertEquals(QcReportDocumentExtract.Channel.TEXT, extract.channel());
|
assertEquals(2, extract.pageCount());
|
assertEquals("pdfbox-text", extract.extractor());
|
assertTrue(extract.text().contains("Incoming Inspection Report page 1"));
|
assertTrue(extract.text().contains("--- 第 2 页 ---"),
|
"多页文本要带页分隔标记,否则模型分不清哪里是新的一页");
|
}
|
|
@Test
|
@DisplayName("没有文本层的 PDF 走 IMAGES 通道逐页转图,并提示用户这是按扫描件处理的")
|
void pdfWithoutTextLayerGoesImageChannel() {
|
QcReportDocumentExtract extract = pdfAdapter().extract(AiImportFixtures.imageOnlyPdf(2), "扫描件.pdf");
|
|
assertEquals(QcReportDocumentExtract.Channel.IMAGES, extract.channel(),
|
"抽不出文本层却仍走 TEXT,结果是模型收到一片空白,用户只会看到「什么都没识别出来」");
|
assertEquals(2, extract.pageCount());
|
assertEquals("pdfbox-render", extract.extractor());
|
assertEquals(2, extract.images().size(), "逐页一张图,页数就是图片数与调用数");
|
assertEquals("image/png", extract.images().get(0).mimeType());
|
assertEquals("第 1 页", extract.images().get(0).label());
|
assertFalse(extract.notes().isEmpty(), "必须告诉用户这次是按扫描件处理的,否则解释不了为什么这么慢");
|
}
|
|
@Test
|
@DisplayName("页数超过上限直接报错,不静默截断")
|
void pdfTooManyPagesThrows() {
|
QcReportAiImportProperties properties = new QcReportAiImportProperties();
|
properties.setMaxPagesPerFile(2);
|
|
ServiceException e = assertThrows(ServiceException.class,
|
() -> pdfAdapter(properties).extract(AiImportFixtures.textPdf(3), "长报告.pdf"));
|
assertTrue(e.getMessage().contains("3 页") && e.getMessage().contains("上限 2 页"),
|
"报错要说清实际几页、上限几页,用户才知道该拆成几份;实际文案:" + e.getMessage());
|
}
|
|
@Test
|
@DisplayName("docx 保留段落与表格的版面顺序,表格用 Tab 分隔单元格并加边界标记")
|
void docxKeepsLayoutOrder() {
|
byte[] bytes = AiImportFixtures.docx("来料检验报告", new String[][]{
|
{"检验项目", "规格", "实测", "判定"},
|
{"外观", "无划痕", "合格", "PASS"}}, "检验员:张三");
|
|
String text = officeAdapter().extract(bytes, "报告.docx").text();
|
|
int heading = text.indexOf("来料检验报告");
|
int tableStart = text.indexOf("[表格开始]");
|
int tableEnd = text.indexOf("[表格结束]");
|
int footer = text.indexOf("检验员:张三");
|
assertTrue(heading >= 0 && tableStart > heading && tableEnd > tableStart && footer > tableEnd,
|
"顺序应是 段落 → 表格 → 段落;乱序说明抽取是「先所有段落、再所有表格」,"
|
+ "模型会看到一堆标题堆在开头。实际:" + text);
|
assertTrue(text.contains("检验项目\t规格\t实测\t判定"),
|
"单元格必须用 Tab 分隔,否则模型分不清哪几段文字属于同一行。实际:" + text);
|
assertTrue(text.contains("外观\t无划痕\t合格\tPASS"), "数据行同样要按行拼起来。实际:" + text);
|
}
|
|
@Test
|
@DisplayName("纵向合并的续格标成 ↑同上:模型才看得出「一个检验项目下挂若干子项」")
|
void docxKeepsVerticalMergeAsGroupMark() {
|
String text = officeAdapter()
|
.extract(AiImportFixtures.docxWithVerticalMerge(), "来料检验报告.docx").text();
|
|
List<String> rows = tableRows(text);
|
assertEquals(4, rows.size(), "表头 + 三行数据都该输出。实际:" + text);
|
for (String row : rows) {
|
assertEquals(6, row.split("\t", -1).length,
|
"每行的列数必须与表头对齐,否则模型会把值挂到错的列上。实际行:" + row);
|
}
|
assertTrue(rows.get(1).startsWith("粒度-筛上物比例(%)\t20目上"),
|
"合并的起始格带着真实分组名。实际:" + rows.get(1));
|
assertTrue(rows.get(2).startsWith(QcReportDocumentExtract.VERTICAL_MERGE_MARK + "\t40目上"),
|
"续格必须标出「与上一行同值」。留空的话,模型分不清它是合并续格还是原件没填,"
|
+ "整张表就退化成一堆平铺的检验项,分组信息全丢。实际:" + rows.get(2));
|
assertTrue(rows.get(3).startsWith("水分\t"),
|
"换了一个新的分组名,照常输出。实际:" + rows.get(3));
|
}
|
|
@Test
|
@DisplayName("横向合并的表头按占用的列数补占位,与数据行的列位对齐")
|
void docxPadsGridSpanHeaderToUniformWidth() {
|
String text = officeAdapter()
|
.extract(AiImportFixtures.docxWithGridSpanHeader(), "报告.docx").text();
|
|
List<String> rows = tableRows(text);
|
assertEquals("检验项目\t\t标准值\t实测值", rows.get(0),
|
"跨 2 列的表头要占满 2 个列位,否则它后面的列整体左移一格,表头与数据对不上。实际:" + text);
|
assertEquals("外观\t无划痕\t合格\t", rows.get(1),
|
"数据行按整表网格宽度补到等长,短的几行才不会被当成「列数就这么多」。实际:" + text);
|
}
|
|
@Test
|
@DisplayName("docx 的页眉页脚一并读入并加标记,顺序为 页眉 → 正文 → 页脚")
|
void docxReadsHeaderAndFooter() {
|
byte[] bytes = AiImportFixtures.docxWithHeaderFooter("检 验 报 告",
|
"新疆大罗素马铃薯制品有限公司", "制造商:某某公司 厂址:新疆北屯市工业园区金辉路555号");
|
|
QcReportDocumentExtract extract = officeAdapter().extract(bytes, "报告.docx");
|
String text = extract.text();
|
|
int header = text.indexOf("[页眉]");
|
int body = text.indexOf("检 验 报 告");
|
int footerMark = text.indexOf("[页脚]");
|
assertTrue(header >= 0 && body > header && footerMark > body,
|
"顺序应是 页眉 → 正文 → 页脚,与文档视觉顺序一致。实际:" + text);
|
assertTrue(text.contains("新疆大罗素马铃薯制品有限公司"),
|
"页眉文字必须真的进内容:模型没见过这段文字,就不可能把它认成 ReportHeader。实际:" + text);
|
assertTrue(text.contains("金辉路555号"), "页脚同理,对应 ReportFooter。实际:" + text);
|
assertFalse(extract.notes().isEmpty(),
|
"要进 warnings 提示原件带页眉页脚,否则用户不明白草稿里为什么多出一个抬头组件");
|
}
|
|
@Test
|
@DisplayName("没有页眉页脚的 docx 不会凭空多出标记与告警")
|
void docxWithoutHeaderFooterStaysClean() {
|
byte[] bytes = AiImportFixtures.docx("来料检验报告",
|
new String[][]{{"检验项目"}, {"外观"}}, "检验员:张三");
|
|
QcReportDocumentExtract extract = officeAdapter().extract(bytes, "报告.docx");
|
|
assertFalse(extract.text().contains("[页眉]") || extract.text().contains("[页脚]"),
|
"POI 在没有页眉时仍可能返回一个空 header,滤不掉就会让模型以为原件有抬头。"
|
+ "实际:" + extract.text());
|
assertTrue(extract.notes().isEmpty(), "没有页眉页脚就不该报这条警告");
|
}
|
|
@Test
|
@DisplayName("xlsx 带工作表名与列头,空白行不输出")
|
void xlsxKeepsHeaders() {
|
byte[] bytes = AiImportFixtures.xlsx("检验项目", new String[][]{
|
{"检验项目", "规格", "实测", "判定"},
|
{"外观", "无划痕", "合格", "PASS"},
|
{"", "", "", ""}});
|
|
String text = officeAdapter().extract(bytes, "项目.xlsx").text();
|
|
assertTrue(text.contains("[工作表: 检验项目]"), "不带工作表名时多 sheet 文件的输出会糊在一起");
|
assertTrue(text.contains("检验项目\t规格\t实测\t判定"), "列头顺序不能变,语义全靠它");
|
assertEquals(3, text.lines().count(),
|
"工作表名 + 列头 + 一行数据正好三行;多出来说明整行空白没被丢掉,"
|
+ "超大表里大量空行会把提示词灌满噪声。实际:" + text);
|
}
|
|
@Test
|
@DisplayName("Excel 显示值而非原始值:DataFormatter 之下 1 不会变成 1.0")
|
void xlsxUsesDisplayValue() {
|
byte[] bytes = AiImportFixtures.xlsx("数量", new String[][]{{"数量"}, {"100"}});
|
String text = officeAdapter().extract(bytes, "数量.xlsx").text();
|
|
assertTrue(text.contains("100"), "实际:" + text);
|
assertFalse(text.contains("100.0"), "取了原始值的话整数会变 100.0,模型容易当成小数");
|
}
|
|
@Test
|
@DisplayName(".doc / .xls 给出「另存为」的精确出路,而不是笼统的「格式不支持」")
|
void legacyOfficeThrowsPreciseError() {
|
OfficeImportAdapter adapter = officeAdapter();
|
|
ServiceException doc = assertThrows(ServiceException.class,
|
() -> adapter.extract(new byte[]{1, 2, 3}, "老报告.doc"));
|
assertTrue(doc.getMessage().contains("另存为") && doc.getMessage().contains(".docx"),
|
"扩展名看起来完全合规,只说「不支持」用户无从下手。实际:" + doc.getMessage());
|
|
ServiceException xls = assertThrows(ServiceException.class,
|
() -> adapter.extract(new byte[]{1, 2, 3}, "老台账.xls"));
|
assertTrue(xls.getMessage().contains(".xlsx"), "实际:" + xls.getMessage());
|
}
|
|
@Test
|
@DisplayName("带 BOM 的文本剥掉 BOM,首行首字段不带隐形字符")
|
void textWithBomIsStripped() {
|
String text = officeAdapter()
|
.extract(AiImportFixtures.textWithBom("检验项目,规格\n外观,无划痕"), "清单.csv").text();
|
|
assertTrue(text.startsWith("检验项目"),
|
"BOM 没剥掉时首字段会带一个看不见的字符,模型拼出来的 key 就对不上了。实际:" + text);
|
}
|
|
@Test
|
@DisplayName("门面按扩展名路由,大写扩展名走同一条路")
|
void facadeRoutesCaseInsensitively() {
|
QcReportDocumentExtractService facade = facade();
|
assertEquals("raw-image", facade.extract("图片".getBytes(), "现场照片.PNG", null).extractor());
|
assertEquals("pdfbox-text", facade.extract(AiImportFixtures.textPdf(1), "报告.PDF", null).extractor());
|
}
|
|
@Test
|
@DisplayName("没人认领的扩展名报「格式不支持」,并带上可用格式清单")
|
void facadeRejectsUnknownExtension() {
|
ServiceException e = assertThrows(ServiceException.class,
|
() -> facade().extract(new byte[]{1}, "草稿.pages", null));
|
|
assertTrue(e.getMessage().contains("扩展名:.pages"), "实际:" + e.getMessage());
|
assertTrue(e.getMessage().contains("txt/csv"), "要列出可用格式,用户才知道该转成什么");
|
}
|
|
@Test
|
@DisplayName("没有扩展名时报「扩展名:无」,不留下半个点")
|
void facadeHandlesMissingExtension() {
|
ServiceException e = assertThrows(ServiceException.class,
|
() -> facade().extract(new byte[]{1}, "无后缀名的文件", null));
|
|
assertTrue(e.getMessage().contains("扩展名:无"), "实际:" + e.getMessage());
|
}
|
|
// ==================== 夹具 ====================
|
|
/** 取出 [表格开始] 与 [表格结束] 之间的行,按出现顺序 */
|
private static List<String> tableRows(String text) {
|
List<String> rows = new ArrayList<>();
|
boolean inside = false;
|
for (String line : text.split("\n")) {
|
if ("[表格开始]".equals(line)) {
|
inside = true;
|
} else if ("[表格结束]".equals(line)) {
|
inside = false;
|
} else if (inside) {
|
rows.add(line);
|
}
|
}
|
return rows;
|
}
|
|
private static PdfImportAdapter pdfAdapter() {
|
return pdfAdapter(new QcReportAiImportProperties());
|
}
|
|
private static PdfImportAdapter pdfAdapter(QcReportAiImportProperties properties) {
|
PdfImportAdapter adapter = new PdfImportAdapter();
|
ReflectionTestUtils.setField(adapter, "properties", properties);
|
return adapter;
|
}
|
|
private static OfficeImportAdapter officeAdapter() {
|
OfficeImportAdapter adapter = new OfficeImportAdapter();
|
ReflectionTestUtils.setField(adapter, "properties", new QcReportAiImportProperties());
|
return adapter;
|
}
|
|
private static QcReportDocumentExtractService facade() {
|
QcReportDocumentExtractService facade = new QcReportDocumentExtractService();
|
ReflectionTestUtils.setField(facade, "adapters",
|
List.of(new ImageImportAdapter(), pdfAdapter(), officeAdapter()));
|
return facade;
|
}
|
|
}
|