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; /** * 输入适配器:路由与抽取。 *

* 守的是三件事: *

    *
  1. 路由不能有歧义——同一份文件必须稳定地落到同一个适配器,否则「同一张照片今天走文本、 * 明天走图片」这种漂移会在排查时把人绕死。
  2. *
  3. 通道判定依据是「有没有文本层」,不是扩展名。这两者被搞混时,扫描版 PDF 会被当成 * 电子版去抽文本,抽出一片空白,而用户看到的只是「AI 什么都没识别出来」。
  4. *
  5. 旧格式 .doc/.xls 要给出精确出路——走通用「格式不支持」的文案等于没说, * 因为用户的文件扩展名看起来完全合规。
  6. *
*

* 这些用例都不需要 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 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 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 tableRows(String text) { List 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; } }