8 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
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;
    }
 
}