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; /** * 判定语义测试。 *

* {@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 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 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 rules = List.of(itemRule("实测值达标", "item.actualValue >= 50")); List 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 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 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%」这种自相矛盾的报告。没验过的项不能让报告背合格。 *

* 「无判定规则」拆分出去之后,「待判定」只剩一个来源:项规则跑挂了。 * (没规则又没上下限的那种是「无判定规则」,已不计入分母。)所以这条用例改用 * 一条在「外观」上算不出来的规则来复现。 */ @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 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 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 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); } }