6 小时以前 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
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);
    }
 
}