13 小时以前 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
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
package cn.iocoder.yudao.module.qcreport.engine;
 
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
import cn.iocoder.yudao.module.qcreport.engine.context.InspectionItem;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import cn.iocoder.yudao.module.qcreport.engine.report.EvaluationOutcome;
import cn.iocoder.yudao.module.qcreport.engine.report.ReportEvaluator;
import cn.iocoder.yudao.module.qcreport.engine.rule.QualityRuleDefinition;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleEngine;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleEvaluator;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleRuntimeException;
import cn.iocoder.yudao.module.qcreport.engine.rule.RuleScope;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
 
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
/**
 * 判定语义测试。
 * <p>
 * {@link FrontendConformanceTest} 保证后端引擎与前端引擎算得一样,
 * 这里管的是「算得对不对」:判定优先级、为什么不给结论、规则写错时报什么。
 * 其中「待判定不给合格结论」是 Phase 2 修过的一个真实缺陷,下面有用例守着。
 */
class ReportEvaluatorTest {
 
    /* ------------------------------ 单项判定 ------------------------------ */
 
    @Test
    @DisplayName("实测值在规格上下限内判合格,超出判不合格")
    void judgesItemByLimits() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(
                item("长度", "10.2", 10.5, 9.5),
                item("硬度", "62", 60.0, 50.0),
                item("宽度", "4.9", 5.0, 5.0)));
 
        List<InspectionItem> items = ReportEvaluator.evaluate(context, List.of()).context().getInspectionItems();
 
        assertEquals("PASS", items.get(0).getResult());
        assertEquals("合格", items.get(0).getResultText());
        assertEquals("FAIL", items.get(1).getResult());
        assertEquals("不合格", items.get(1).getResultText());
        assertEquals("FAIL", items.get(2).getResult(), "低于下限同样判不合格");
    }
 
    @Test
    @DisplayName("只有单侧规格时只按有的一侧判")
    void judgesItemWithOneSidedLimit() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(
                item("厚度", "0.9", null, 1.0),
                item("重量", "3", 5.0, null)));
 
        List<InspectionItem> items = ReportEvaluator.evaluate(context, List.of()).context().getInspectionItems();
 
        assertEquals("FAIL", items.get(0).getResult(), "只给了下限,0.9 低于 1.0");
        assertEquals("PASS", items.get(1).getResult(), "只给了上限,3 没超 5.0");
    }
 
    @Test
    @DisplayName("逐项规则优先于规格上下限")
    void itemRuleWinsOverLimits() {
        // 实测值明明在上下限内,但规则要求实测值≥50,所以应判不合格
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(item("硬度", "10.2", 10.5, 9.5)));
        List<QualityRuleDefinition> rules = List.of(itemRule("实测值达标", "item.actualValue >= 50"));
 
        List<InspectionItem> items = ReportEvaluator.evaluate(context, rules).context().getInspectionItems();
 
        assertEquals("FAIL", items.get(0).getResult(), "有规则时以规则为准,不再看上下限");
    }
 
    @Test
    @DisplayName("没有规则也没有上下限时取数据自带的结果")
    void fallsBackToDataSuppliedResult() {
        InspectionItem appearance = item("外观", "合格", null, null).setResult("FAIL");
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(appearance));
 
        List<InspectionItem> items = ReportEvaluator.evaluate(context, List.of()).context().getInspectionItems();
 
        assertEquals("FAIL", items.get(0).getResult(), "数据里已经填了判定值就直接用");
        assertEquals("不合格", items.get(0).getResultText());
    }
 
    @Test
    @DisplayName("既无规则也无上下限、数据里也没结果时标为无判定规则并记进问题清单")
    void marksUndecidableItemWithoutVerdict() {
        ReportContext context = ReportContext.empty()
                .setInspectionItems(List.of(item("外观", "无划痕", null, null)));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, List.of());
        InspectionItem item = outcome.context().getInspectionItems().get(0);
 
        assertEquals("", item.getResult());
        assertEquals(QualityResult.UNDECIDABLE_TEXT, item.getResultText());
        assertEquals(1, outcome.errors().size());
        assertEquals("第 1 项「外观」没有规格上下限也没有判定规则,无法判定", outcome.errors().get(0));
        assertEquals(1, outcome.context().getReport().getTotal(), "总数照实算,不因为判不了就不算");
        assertEquals("", outcome.context().getReport().getPassRate(), "一项都判不了时合格率留空而不是 0.00%");
    }
 
    @Test
    @DisplayName("无判定规则的项不计入合格率分母,也不压住合格结论")
    void undecidableItemIsExcludedFromPassRate() {
        // 「试样质量 m」这类过程参数只供公式取数,没有规格也就无从判定
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(
                item("长度", "10.2", 10.5, 9.5),
                item("试样质量 m", "50.2", null, null)));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, List.of());
 
        assertEquals("PASS", outcome.context().getReport().getResult(), "过程参数不该把全合格的报告压成待判定");
        assertEquals(2, outcome.context().getReport().getTotal(), "总数仍是真实的检验项数");
        assertEquals(1, outcome.context().getReport().getPassCount());
        assertEquals("100.00%", outcome.context().getReport().getPassRate(), "分母只算可判定项");
        assertEquals(QualityResult.UNDECIDABLE_TEXT,
                outcome.context().getInspectionItems().get(1).getResultText());
    }
 
    @Test
    @DisplayName("规则执行报错时该项不给结论,错误里带上项名与规则名")
    void reportsRuleFailureWithoutVerdict() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(item("硬度", "62", 60.0, 50.0)));
        List<QualityRuleDefinition> rules = List.of(itemRule("除零规则", "item.actualValue / 0 > 0"));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, rules);
        InspectionItem item = outcome.context().getInspectionItems().get(0);
 
        assertEquals("", item.getResult(), "规则跑不通就不能说合格也不能说不合格");
        assertEquals(QualityResult.PENDING_TEXT, item.getResultText());
        assertEquals(List.of("第 1 项「硬度」的规则「除零规则」无法执行:规则里出现了除以 0"), outcome.errors());
    }
 
    /* ------------------------------ 报告汇总 ------------------------------ */
 
    @Test
    @DisplayName("全部检验项合格才给合格结论,合格率 100%")
    void allPassGivesPassVerdict() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(
                item("长度", "10.2", 10.5, 9.5),
                item("宽度", "4.9", 5.0, 4.0)));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, List.of());
 
        assertEquals("PASS", outcome.context().getReport().getResult());
        assertEquals("合格", outcome.context().getReport().getConclusion());
        assertEquals(2, outcome.context().getReport().getTotal());
        assertEquals(2, outcome.context().getReport().getPassCount());
        assertEquals("100.00%", outcome.context().getReport().getPassRate());
    }
 
    @Test
    @DisplayName("有不合格项就给不合格结论,合格率按比例算")
    void anyFailGivesFailVerdict() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(
                item("长度", "10.2", 10.5, 9.5),
                item("硬度", "62", 60.0, 50.0),
                item("宽度", "4.9", 5.0, 4.0)));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, List.of());
 
        assertEquals("FAIL", outcome.context().getReport().getResult());
        assertEquals(2, outcome.context().getReport().getPassCount());
        assertEquals(1, outcome.context().getReport().getFailCount());
        assertEquals("66.67%", outcome.context().getReport().getPassRate());
    }
 
    /**
     * Phase 2 的真实缺陷:三项里两项合格、一项没验出结论,旧逻辑只数「有没有不合格」,
     * 于是给出「结论合格、合格率 66.67%」这种自相矛盾的报告。没验过的项不能让报告背合格。
     * <p>
     * 「无判定规则」拆分出去之后,「待判定」只剩一个来源:项规则跑挂了。
     * (没规则又没上下限的那种是「无判定规则」,已不计入分母。)所以这条用例改用
     * 一条在「外观」上算不出来的规则来复现。
     */
    @Test
    @DisplayName("存在待判定项时不给合格结论(回归:不得出现「合格 + 合格率不足 100%」)")
    void pendingItemBlocksPassVerdict() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(
                item("长度", "10.2", 10.5, 9.5),
                item("宽度", "4.9", 5.0, 4.0),
                item("外观", "无划痕", null, null)));
        // 外观实测值是「无划痕」,数值函数在它身上算不出来 → 该项待判定
        List<QualityRuleDefinition> rules = List.of(itemRule("实测值可算", "ABS(item.actualValue) >= 0"));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, rules);
 
        assertEquals("", outcome.context().getReport().getResult());
        assertEquals(QualityResult.PENDING_TEXT, outcome.context().getReport().getConclusion());
        assertEquals(2, outcome.context().getReport().getPassCount());
        assertEquals(0, outcome.context().getReport().getFailCount(), "待判定既不算合格也不算不合格");
        assertEquals(3, outcome.context().getReport().getTotal());
        assertEquals("66.67%", outcome.context().getReport().getPassRate(), "待判定项仍留在合格率分母里");
    }
 
    @Test
    @DisplayName("一项都没有时不给结论,合格率留空而不是 0.00%")
    void emptyReportHasNoVerdict() {
        EvaluationOutcome outcome = ReportEvaluator.evaluate(ReportContext.empty(), List.of());
 
        assertEquals("", outcome.context().getReport().getResult());
        assertEquals(0, outcome.context().getReport().getTotal());
        assertEquals("", outcome.context().getReport().getPassRate());
        assertTrue(outcome.errors().isEmpty());
    }
 
    @Test
    @DisplayName("有报告级规则时以规则为准,不再看逐项汇总")
    void reportRuleWinsOverAggregation() {
        // 逐项全合格,但报告级规则要求合格率≥150%,必然不成立
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(item("长度", "10.2", 10.5, 9.5)));
        List<QualityRuleDefinition> rules = List.of(
                new QualityRuleDefinition("r", "整体合格", RuleScope.REPORT, "PASS_RATE(inspectionItems.result) >= 150", true));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, rules);
 
        assertEquals("FAIL", outcome.context().getReport().getResult());
        assertEquals(1, outcome.context().getReport().getPassCount(), "汇总数据照常算出来,只是不拿来定结论");
    }
 
    @Test
    @DisplayName("停用的规则不参与判定")
    void disabledRuleIsIgnored() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(item("长度", "10.2", 10.5, 9.5)));
        List<QualityRuleDefinition> rules = List.of(
                new QualityRuleDefinition("r", "实测值达标", RuleScope.ITEM, "item.actualValue >= 50", false));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(context, rules);
 
        assertEquals("PASS", outcome.context().getInspectionItems().get(0).getResult(), "规则被停用,退回按上下限判");
        assertTrue(outcome.errors().isEmpty());
    }
 
    @Test
    @DisplayName("判定不修改传进来的上下文")
    void doesNotMutateInputContext() {
        ReportContext context = ReportContext.empty().setInspectionItems(List.of(item("硬度", "62", 60.0, 50.0)));
 
        ReportEvaluator.evaluate(context, List.of());
 
        assertEquals("", context.getInspectionItems().get(0).getResult(), "原上下文的检验项不该被写回判定值");
        assertEquals("", context.getReport().getResult());
        assertEquals(0, context.getReport().getTotal());
    }
 
    /* ------------------------------ 规则求值的健壮性 ------------------------------ */
 
    @Test
    @DisplayName("函数缺参报可读错误,而不是抛下标越界穿透到接口层")
    void missingArgumentGivesReadableError() {
        RuleRuntimeException error = assertThrows(RuleRuntimeException.class,
                () -> RuleEngine.evaluate("SUM()", ReportContext.empty().toScope()));
 
        assertEquals("函数 SUM 需要数值型参数,实际取到「」", error.getMessage());
    }
 
    @Test
    @DisplayName("未知函数报错里列出全部可用函数,顺序稳定")
    void unknownFunctionListsAvailableFunctions() {
        RuleRuntimeException error = assertThrows(RuleRuntimeException.class,
                () -> RuleEngine.evaluate("FOO(1)", ReportContext.empty().toScope()));
 
        assertEquals("不支持函数 FOO,可用函数:ABS、AVG、COUNT、CP、CPK、FAIL_RATE、MAX、MIN、PASS_RATE、ROUND、STDDEV、SUM",
                error.getMessage());
    }
 
    @Test
    @DisplayName("保存前校验:语法错误与未知函数都能被拦下")
    void validateCatchesSyntaxAndUnknownFunction() {
        assertEquals(List.of("规则「写得不对」语法错误:表达式不完整(第 20 个字符)"),
                RuleEngine.validate(itemRule("写得不对", "item.actualValue >= ")));
        assertTrue(RuleEngine.validate(itemRule("写得对", "item.actualValue >= item.lowerLimit")).isEmpty());
        assertEquals("FOO", RuleEngine.findUnknownFunction("FOO(item.actualValue)"));
        assertNull(RuleEngine.findUnknownFunction("ABS(item.actualValue)"));
    }
 
    @Test
    @DisplayName("空表达式单独给一条提示,不再当成语法错误")
    void validateReportsEmptyExpression() {
        assertEquals(List.of("规则「空规则」的表达式为空"), RuleEngine.validate(itemRule("空规则", "   ")));
    }
 
    /**
     * 保存前的整份模板校验。语法与白名单两块都要拦:只拦语法的话,
     * {@code FOO(1)} 这种调用会一路存进库里,之后每张报告都带一条「无法执行」。
     */
    @Test
    @DisplayName("模板级规则校验:语法错与白名单外的函数都要拦下,并指明是哪条规则")
    void validatesAllRulesBeforeSave() {
        ReportTemplateSchema schema = new ReportTemplateSchema();
        schema.setSchemaVersion(ReportTemplateSchema.SCHEMA_VERSION);
        schema.setRules(List.of(
                Map.of("id", "r1", "name", "语法写错", "scope", "item", "expression", "item.actualValue >= "),
                Map.of("id", "r2", "name", "函数不存在", "scope", "report", "expression", "FOO(inspectionItems)"),
                Map.of("id", "r3", "name", "正常规则", "scope", "item", "expression", "item.actualValue > 0")));
 
        assertEquals(List.of(
                        "规则「语法写错」语法错误:表达式不完整(第 20 个字符)",
                        "规则「函数不存在」调用了不支持的函数 FOO,可用函数:ABS、AVG、COUNT、CP、CPK、FAIL_RATE、MAX、MIN、PASS_RATE、ROUND、STDDEV、SUM"),
                QualityReportEngine.validateRules(schema));
    }
 
    @Test
    @DisplayName("规则都合规时校验返回空清单")
    void validatePassesWhenRulesAreFine() {
        ReportTemplateSchema schema = new ReportTemplateSchema();
        schema.setRules(List.of(
                Map.of("id", "r1", "name", "规格内", "scope", "item", "expression", "item.actualValue BETWEEN item.lowerLimit AND item.upperLimit"),
                Map.of("id", "r2", "name", "合格率", "scope", "report", "expression", "PASS_RATE(inspectionItems.result) >= 60")));
 
        assertTrue(QualityReportEngine.validateRules(schema).isEmpty());
    }
 
    /**
     * 历史脏数据里可能有缺 expression 的规则项。读的时候宽容地留着(执行期会报出来),
     * 但保存前校验要拦下来:规则编辑界面已经做出来了,用户在设计器里删掉这条就能继续保存。
     * 在此之前这条是放行的——当时没有规则界面,报错只会让人卡在保存不了,无处可改。
     */
    @Test
    @DisplayName("缺 expression 的规则会被读取到,但阻断保存(规则界面里删得掉,不再是死路)")
    void blankExpressionRuleBlocksSave() {
        ReportTemplateSchema schema = new ReportTemplateSchema();
        schema.setRules(List.of(
                Map.of("scope", "item"),
                Map.of("id", "r2", "name", "合格率", "scope", "report", "expression", "PASS_RATE(inspectionItems.result) >= 60")));
 
        assertEquals(2, QualityReportEngine.rulesOf(schema).size(), "宽松读取:两个都留着");
        assertEquals(List.of("规则「未命名规则」的表达式为空"), QualityReportEngine.validateRules(schema),
                "空表达式也要拦住,且不能因此把另一条正常规则也报成错的");
    }
 
    @Test
    @DisplayName("空表达式的规则在执行期会被报出来,不至于静默失效")
    void blankExpressionRuleSurfacesAtExecution() {
        ReportTemplateSchema schema = new ReportTemplateSchema();
        schema.setRules(List.of(Map.of("id", "r1", "name", "空规则", "scope", "item")));
 
        EvaluationOutcome outcome = ReportEvaluator.evaluate(
                ReportContext.empty().setInspectionItems(List.of(item("外观", "无划痕", null, null))),
                QualityReportEngine.rulesOf(schema));
 
        assertEquals(List.of("第 1 项「外观」的规则「空规则」无法执行:规则表达式为空"), outcome.errors());
        assertEquals(QualityResult.PENDING_TEXT, outcome.context().getInspectionItems().get(0).getResultText());
    }
 
    @Test
    @DisplayName("判定结果取值兼容中文、布尔与 1/0")
    void normalizesResultTokens() {
        assertEquals("PASS", RuleEvaluator.normalizeResultToken("合格"));
        assertEquals("PASS", RuleEvaluator.normalizeResultToken(true));
        assertEquals("PASS", RuleEvaluator.normalizeResultToken("1"));
        assertEquals("FAIL", RuleEvaluator.normalizeResultToken("NG"));
        assertEquals("FAIL", RuleEvaluator.normalizeResultToken(false));
        assertEquals("FAIL", RuleEvaluator.normalizeResultToken("否"));
        assertEquals("", RuleEvaluator.normalizeResultToken("也许"), "认不出来的取值不能瞎猜");
    }
 
    /**
     * 这里的真值口径是「引擎口径」不是「JS Boolean() 口径」:
     * 能当数字看的取值统一按 {@code !== 0} 判真,所以字符串 "0" 是假值,
     * 而原生 JS 的 {@code Boolean("0")} 是真值。前端引擎就是这么写的,后端照搬才有一致性,
     * 改动前必须先改前端,否则预览与出件会分叉。
     */
    @Test
    @DisplayName("真值判定与前端引擎同口径")
    void truthinessMatchesEngine() {
        assertFalse(RuleEvaluator.truthy(null));
        assertFalse(RuleEvaluator.truthy(""));
        assertFalse(RuleEvaluator.truthy(0));
        assertFalse(RuleEvaluator.truthy(false));
        assertFalse(RuleEvaluator.truthy("0"), "能当数字看,所以是假值——与原生 JS Boolean(\"0\") 不同");
        assertTrue(RuleEvaluator.truthy("abc"), "当不成数字的字符串是真值");
        assertTrue(RuleEvaluator.truthy(1));
        assertFalse(RuleEvaluator.truthy(List.of()), "空数组判假:没有检验项不能算「有数据」");
        assertTrue(RuleEvaluator.truthy(List.of("PASS")));
        assertTrue(RuleEvaluator.isBlank(null));
        assertTrue(RuleEvaluator.isBlank(""));
        // 空值与真值是两码事:空数组「不空」但「假」
        assertFalse(RuleEvaluator.isBlank(List.of()), "空数组不算空");
    }
 
    /* ------------------------------ 辅助 ------------------------------ */
 
    private static InspectionItem item(String name, String actual, Double upper, Double lower) {
        return new InspectionItem()
                .setItemName(name).setActualValue(actual)
                .setUpperLimit(upper).setLowerLimit(lower);
    }
 
    private static QualityRuleDefinition itemRule(String name, String expression) {
        return new QualityRuleDefinition("rule-" + name, name, RuleScope.ITEM, expression, true);
    }
 
}