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);
|
}
|
|
}
|