package jnpf.elnService.formula; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.BooleanNode; import com.fasterxml.jackson.databind.node.DecimalNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.TextNode; import jnpf.elnEntity.formula.ElnFormulaForms; import jnpf.elnEntity.formula.ElnFormulaVos; import jnpf.elnFormula.FormulaDiagnostic; import jnpf.elnFormula.FormulaLimits; import jnpf.elnService.formula.impl.FormulaPreviewServiceImpl; import jnpf.elnService.formula.impl.FormulaValidationServiceImpl; import org.junit.Before; import org.junit.Test; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.math.BigDecimal; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class FormulaPreviewServiceTest { private FormulaPreviewService service; @Before public void setUp() { service = new FormulaPreviewServiceImpl(new FormulaValidationServiceImpl()); } @Test public void exposesFixedFunctionCatalogAndFullValidation() { assertEquals(37, service.functions().size()); assertEquals("ABS", service.functions().get(0).getCode()); assertEquals("DECIMAL", service.functions().get(0).getReturnType()); assertEquals("DYNAMIC", service.functions().get(5).getReturnType()); assertEquals("DYNAMIC", service.functions().get(6).getReturnType()); assertEquals("BOOLEAN", service.functions().get(7).getReturnType()); assertEquals("LOOKUP_LINEAR", service.functions().get(17).getCode()); assertEquals("DECIMAL", service.functions().get(17).getReturnType()); ElnFormulaForms.Validate form = validateForm("amount + 1", "DECIMAL", slot("amount", "金额", "DECIMAL", true)); FormulaValidationService.ValidationResult result = service.validate(form); assertFalse(result.hasErrors()); assertEquals("\\text{金额} + 1", result.getLatex()); } @Test public void dependsOnlyOnValidationAndInMemoryFormulaEngine() { for (Field field : FormulaPreviewServiceImpl.class.getDeclaredFields()) { assertNotPersistenceOrAuditDependency(field.getType()); } for (Constructor constructor : FormulaPreviewServiceImpl.class.getDeclaredConstructors()) { for (Class parameterType : constructor.getParameterTypes()) { assertNotPersistenceOrAuditDependency(parameterType); } } } @Test public void validationAndPreviewDoNotMutateOrRetainRequestData() { ElnFormulaForms.Validate validation = validateForm( "amount + 1", "DECIMAL", slot("amount", "金额", "DECIMAL", true)); String validationExpression = validation.getBiaodashi(); List validationParameters = validation.getParameters(); JsonNode validationSlots = snapshotSlots(validationParameters); service.validate(validation); assertEquals(validationExpression, validation.getBiaodashi()); assertSame(validationParameters, validation.getParameters()); assertEquals(validationSlots, snapshotSlots(validation.getParameters())); ElnFormulaForms.Preview preview = previewForm( "amount + SUM(values)", "DECIMAL", slot("amount", "金额", "DECIMAL", true), slot("values", "结果集", "DECIMAL_LIST", true)); preview.getInputs().put("amount", new TextNode("1.25")); ArrayNode values = JsonNodeFactory.instance.arrayNode(); values.add(new BigDecimal("1")); values.add(new BigDecimal("2")); preview.getInputs().put("values", values); String previewExpression = preview.getBiaodashi(); List previewParameters = preview.getParameters(); JsonNode previewSlots = snapshotSlots(previewParameters); Map previewInputs = preview.getInputs(); Map inputSnapshot = copyInputs(previewInputs); assertEquals(new BigDecimal("4.25"), service.preview(preview).getRawResult()); assertEquals(previewExpression, preview.getBiaodashi()); assertSame(previewParameters, preview.getParameters()); assertEquals(previewSlots, snapshotSlots(preview.getParameters())); assertSame(previewInputs, preview.getInputs()); assertEquals(inputSnapshot, preview.getInputs()); ElnFormulaForms.Preview next = previewForm( "amount + SUM(values)", "DECIMAL", slot("amount", "金额", "DECIMAL", true), slot("values", "结果集", "DECIMAL_LIST", true)); next.getInputs().put("amount", new TextNode("7")); ArrayNode nextValues = JsonNodeFactory.instance.arrayNode(); nextValues.add(new BigDecimal("3")); next.getInputs().put("values", nextValues); assertEquals(new BigDecimal("10"), service.preview(next).getRawResult()); assertEquals(inputSnapshot, preview.getInputs()); } @Test public void previewsStrictInputsAndAppliesConfiguredRounding() { ElnFormulaForms.Preview form = previewForm( "amount + SUM(values)", "DECIMAL", slot("amount", "金额", "DECIMAL", true), slot("values", "结果集", "DECIMAL_LIST", true)); form.setXiuyueXiaoshuWei(2); form.setXiuyueMoshi("HALF_UP"); form.getInputs().put("amount", new TextNode("1.005")); ArrayNode values = JsonNodeFactory.instance.arrayNode(); values.add(new BigDecimal("1.11")); values.add("2.22"); form.getInputs().put("values", values); ElnFormulaVos.Preview result = service.preview(form); assertEquals("SUCCESS", result.getStatus()); assertEquals(new BigDecimal("4.335"), result.getRawResult()); assertEquals(new BigDecimal("4.34"), result.getRoundedResult()); assertTrue(result.getDiagnostics().isEmpty()); assertTrue(result.getLatex().contains("金额")); } @Test public void acceptsOnlyExactTextAndBooleanJsonTypes() { ElnFormulaForms.Preview text = previewForm("label", "TEXT", slot("label", "标签", "TEXT", true)); text.getInputs().put("label", new TextNode("合格")); assertEquals("合格", service.preview(text).getRawResult()); ElnFormulaForms.Preview bool = previewForm("flag", "BOOLEAN", slot("flag", "标志", "BOOLEAN", true)); bool.getInputs().put("flag", BooleanNode.TRUE); assertEquals(Boolean.TRUE, service.preview(bool).getRawResult()); text.getInputs().put("label", BooleanNode.TRUE); assertError(service.preview(text), "INVALID_INPUT_TYPE"); bool.getInputs().put("flag", new TextNode("true")); assertError(service.preview(bool), "INVALID_INPUT_TYPE"); } @Test public void rejectsUnknownScientificNonFiniteAndOversizedInputs() { ElnFormulaForms.Preview decimal = previewForm("amount", "DECIMAL", slot("amount", "金额", "DECIMAL", true)); decimal.getInputs().put("other", DecimalNode.valueOf(BigDecimal.ONE)); assertError(service.preview(decimal), "UNKNOWN_INPUT"); decimal.getInputs().clear(); decimal.getInputs().put("amount", new TextNode("1e3")); assertError(service.preview(decimal), "INVALID_DECIMAL_INPUT"); decimal.getInputs().put("amount", JsonNodeFactory.instance.numberNode(Double.NaN)); assertError(service.preview(decimal), "INVALID_DECIMAL_INPUT"); decimal.getInputs().put("amount", JsonNodeFactory.instance.numberNode(Double.POSITIVE_INFINITY)); assertError(service.preview(decimal), "INVALID_DECIMAL_INPUT"); ElnFormulaForms.Preview list = previewForm("SUM(values)", "DECIMAL", slot("values", "结果集", "DECIMAL_LIST", true)); ArrayNode values = JsonNodeFactory.instance.arrayNode(); for (int i = 0; i < 1001; i++) { values.add(i); } list.getInputs().put("values", values); assertError(service.preview(list), "LIST_ITEM_LIMIT_EXCEEDED"); } @Test public void rejectsScientificJsonNumberAfterRequestDeserialization() throws Exception { String json = "{\"biaodashi\":\"amount\",\"jieguoLeixing\":\"DECIMAL\"," + "\"parameters\":[{\"canshuBianma\":\"amount\"," + "\"canshuMingcheng\":\"金额\",\"shujuLeixing\":\"DECIMAL\"," + "\"shifouBitian\":1}],\"inputs\":{\"amount\":1e3}}"; ElnFormulaForms.Preview form = new ObjectMapper() .readValue(json, ElnFormulaForms.Preview.class); assertError(service.preview(form), "INVALID_DECIMAL_INPUT"); } @Test public void rejectsScientificListItemsAfterRequestDeserialization() throws Exception { String json = "{\"biaodashi\":\"SUM(values)\",\"jieguoLeixing\":\"DECIMAL\"," + "\"parameters\":[{\"canshuBianma\":\"values\"," + "\"canshuMingcheng\":\"结果集\",\"shujuLeixing\":\"DECIMAL_LIST\"," + "\"shifouBitian\":1}],\"inputs\":{\"values\":[1,1e3,\"2e2\"]}}"; ElnFormulaForms.Preview form = new ObjectMapper() .readValue(json, ElnFormulaForms.Preview.class); assertError(service.preview(form), "INVALID_DECIMAL_INPUT"); ElnFormulaForms.Preview stringItem = new ObjectMapper().readValue( json.replace("[1,1e3,\"2e2\"]", "[1,\"2e2\"]"), ElnFormulaForms.Preview.class); assertError(service.preview(stringItem), "INVALID_DECIMAL_INPUT"); } @Test public void rejectsNegativeExponentJsonNumbersAfterRequestDeserialization() throws Exception { String scalarJson = "{\"biaodashi\":\"amount\",\"jieguoLeixing\":\"DECIMAL\"," + "\"parameters\":[{\"canshuBianma\":\"amount\"," + "\"canshuMingcheng\":\"金额\",\"shujuLeixing\":\"DECIMAL\"," + "\"shifouBitian\":1}],\"inputs\":{\"amount\":1E-3}}"; String listJson = "{\"biaodashi\":\"SUM(values)\",\"jieguoLeixing\":\"DECIMAL\"," + "\"parameters\":[{\"canshuBianma\":\"values\"," + "\"canshuMingcheng\":\"结果集\",\"shujuLeixing\":\"DECIMAL_LIST\"," + "\"shifouBitian\":1}],\"inputs\":{\"values\":[1,1E-3]}}"; ObjectMapper mapper = new ObjectMapper(); assertError(service.preview(mapper.readValue(scalarJson, ElnFormulaForms.Preview.class)), "INVALID_DECIMAL_INPUT"); assertError(service.preview(mapper.readValue(listJson, ElnFormulaForms.Preview.class)), "INVALID_DECIMAL_INPUT"); } @Test public void acceptsPlainJsonNumbersAndStringsAfterRequestDeserialization() throws Exception { String json = "{\"biaodashi\":\"amount + SUM(values)\"," + "\"jieguoLeixing\":\"DECIMAL\",\"parameters\":[" + "{\"canshuBianma\":\"amount\",\"canshuMingcheng\":\"金额\"," + "\"shujuLeixing\":\"DECIMAL\",\"shifouBitian\":1}," + "{\"canshuBianma\":\"values\",\"canshuMingcheng\":\"结果集\"," + "\"shujuLeixing\":\"DECIMAL_LIST\",\"shifouBitian\":1}]," + "\"inputs\":{\"amount\":1.25,\"values\":[1,2.5,\"3.25\"]}}"; ElnFormulaForms.Preview form = new ObjectMapper() .readValue(json, ElnFormulaForms.Preview.class); assertEquals(new BigDecimal("8.00"), service.preview(form).getRawResult()); ElnFormulaForms.Preview plainSmall = new ObjectMapper().readValue( json.replace("1.25", "0.001"), ElnFormulaForms.Preview.class); assertEquals(new BigDecimal("6.751"), service.preview(plainSmall).getRawResult()); } @Test public void acceptsLargeFinitePlainJsonNumberLikeEquivalentString() throws Exception { String digits = repeat('9', 309); String prefix = "{\"biaodashi\":\"amount\",\"jieguoLeixing\":\"DECIMAL\"," + "\"parameters\":[{\"canshuBianma\":\"amount\"," + "\"canshuMingcheng\":\"金额\",\"shujuLeixing\":\"DECIMAL\"," + "\"shifouBitian\":1}],\"inputs\":{\"amount\":"; ObjectMapper mapper = new ObjectMapper(); ElnFormulaVos.Preview numberResult = service.preview(mapper.readValue( prefix + digits + "}}", ElnFormulaForms.Preview.class)); ElnFormulaVos.Preview stringResult = service.preview(mapper.readValue( prefix + "\"" + digits + "\"}}", ElnFormulaForms.Preview.class)); assertEquals("SUCCESS", numberResult.getStatus()); assertEquals("SUCCESS", stringResult.getStatus()); assertEquals(stringResult.getRawResult(), numberResult.getRawResult()); } @Test public void rejectsDecimalLexemesLongerThanExpressionLimit() { String digits = repeat('7', FormulaLimits.DEFAULTS.getMaxExpressionLength() + 1); ElnFormulaForms.Preview scalar = previewForm("amount", "DECIMAL", slot("amount", "金额", "DECIMAL", true)); scalar.getInputs().put("amount", new TextNode(digits)); assertError(service.preview(scalar), "DECIMAL_INPUT_TOO_LONG"); scalar.getInputs().put("amount", DecimalNode.valueOf(new BigDecimal(digits))); assertError(service.preview(scalar), "DECIMAL_INPUT_TOO_LONG"); ElnFormulaForms.Preview list = previewForm("SUM(values)", "DECIMAL", slot("values", "结果集", "DECIMAL_LIST", true)); ArrayNode values = JsonNodeFactory.instance.arrayNode(); values.add(digits); list.getInputs().put("values", values); assertError(service.preview(list), "DECIMAL_INPUT_TOO_LONG"); } @Test public void doesNotTurnJsonNumberIntoTextDuringRequestDeserialization() throws Exception { String json = "{\"biaodashi\":\"label\",\"jieguoLeixing\":\"TEXT\"," + "\"parameters\":[{\"canshuBianma\":\"label\"," + "\"canshuMingcheng\":\"标签\",\"shujuLeixing\":\"TEXT\"," + "\"shifouBitian\":1}],\"inputs\":{\"label\":1.25}}"; ElnFormulaForms.Preview form = new ObjectMapper() .readValue(json, ElnFormulaForms.Preview.class); assertError(service.preview(form), "INVALID_INPUT_TYPE"); } @Test public void reportsRequiredNullAndBlankInputsAsMissing() { ElnFormulaForms.Preview form = previewForm("amount", "DECIMAL", slot("amount", "金额", "DECIMAL", true)); assertMissing(service.preview(form), "amount"); form.getInputs().put("amount", NullNode.instance); assertMissing(service.preview(form), "amount"); form.getInputs().put("amount", new TextNode("")); assertMissing(service.preview(form), "amount"); } @Test public void optionalMissingInputCanBeHandledByCoalesceOrShortCircuit() { ElnFormulaForms.Preview coalesce = previewForm("COALESCE(amount, 2)", "DECIMAL", slot("amount", "金额", "DECIMAL", false)); assertEquals(new BigDecimal("2"), service.preview(coalesce).getRawResult()); ElnFormulaForms.Preview branch = previewForm("IF(false, amount, 3)", "DECIMAL", slot("amount", "金额", "DECIMAL", false)); assertEquals(new BigDecimal("3"), service.preview(branch).getRawResult()); } @Test public void reportsOptionalNullWhenEvaluatedOrReturned() { ElnFormulaForms.Preview evaluated = previewForm("amount + 1", "DECIMAL", slot("amount", "金额", "DECIMAL", false)); assertMissing(service.preview(evaluated), "amount"); ElnFormulaForms.Preview returned = previewForm("COALESCE(amount)", "DECIMAL", slot("amount", "金额", "DECIMAL", false)); assertMissing(service.preview(returned), "结果为空"); } @Test public void reportsOnlyActuallyAccessedOptionalMissingInputs() { ElnFormulaForms.Preview form = previewForm("amount + 1", "DECIMAL", slot("amount", "金额", "DECIMAL", false), slot("unused", "未使用", "DECIMAL", false)); ElnFormulaVos.Preview result = service.preview(form); assertEquals("MISSING_INPUT", result.getStatus()); int missingDiagnostics = 0; for (ElnFormulaVos.Diagnostic diagnostic : result.getDiagnostics()) { if ("MISSING_INPUT".equals(diagnostic.getCode())) { missingDiagnostics++; assertTrue(diagnostic.getMessage().contains("amount")); assertFalse(diagnostic.getMessage().contains("unused")); } } assertEquals(1, missingDiagnostics); } @Test public void reportsOnlyRightMissingInputWhenLeftCoalesceHasFallback() { ElnFormulaForms.Preview form = previewForm("COALESCE(a, 1) + b", "DECIMAL", slot("a", "参数 A", "DECIMAL", false), slot("b", "参数 B", "DECIMAL", false)); assertOnlyMissing(service.preview(form), "b", "a"); } @Test public void reportsOnlyLeftMissingInputWhenRightCoalesceHasFallback() { ElnFormulaForms.Preview form = previewForm("a + COALESCE(b, 1)", "DECIMAL", slot("a", "参数 A", "DECIMAL", false), slot("b", "参数 B", "DECIMAL", false)); assertOnlyMissing(service.preview(form), "a", "b"); } @Test public void rejectsInvalidRequiredFlagsDuringValidation() { for (Integer flag : Arrays.asList(null, 2, -1)) { ElnFormulaForms.Parameter parameter = slot("amount", "金额", "DECIMAL", true); parameter.setShifouBitian(flag); FormulaValidationService.ValidationResult result = service.validate( validateForm("amount + 1", "DECIMAL", parameter)); assertValidationDiagnostic(result, "INVALID_PARAMETER_REQUIRED_FLAG"); assertNull(result.getLatex()); } } @Test public void rejectsInvalidRequiredFlagsBeforePreviewEvaluation() { for (Integer flag : Arrays.asList(null, 2, -1)) { ElnFormulaForms.Parameter parameter = slot("amount", "金额", "DECIMAL", true); parameter.setShifouBitian(flag); ElnFormulaForms.Preview form = previewForm( "COALESCE(amount, 2)", "DECIMAL", parameter); ElnFormulaVos.Preview result = service.preview(form); assertError(result, "INVALID_PARAMETER_REQUIRED_FLAG"); assertNull(result.getLatex()); } } @Test public void stopsAtFullValidationBeforeInspectingInputs() { ElnFormulaForms.Preview form = previewForm("unknown +", "DECIMAL"); form.getInputs().put("unexpected", DecimalNode.valueOf(BigDecimal.ONE)); ElnFormulaVos.Preview result = service.preview(form); assertEquals("ERROR", result.getStatus()); assertDiagnostic(result.getDiagnostics(), "SYNTAX_ERROR"); assertFalse(hasDiagnostic(result.getDiagnostics(), "UNKNOWN_INPUT")); assertNull(result.getRawResult()); assertNull(result.getRoundedResult()); } private static ElnFormulaForms.Validate validateForm(String expression, String resultType, ElnFormulaForms.Parameter... slots) { ElnFormulaForms.Validate form = new ElnFormulaForms.Validate(); setRule(form, expression, resultType, slots); return form; } private static ElnFormulaForms.Preview previewForm(String expression, String resultType, ElnFormulaForms.Parameter... slots) { ElnFormulaForms.Preview form = new ElnFormulaForms.Preview(); setRule(form, expression, resultType, slots); form.setInputs(new LinkedHashMap()); return form; } private static void setRule(ElnFormulaForms.VersionRule form, String expression, String resultType, ElnFormulaForms.Parameter... slots) { form.setBiaodashi(expression); form.setJieguoLeixing(resultType); form.setParameters(Arrays.asList(slots)); } private static ElnFormulaForms.Parameter slot(String code, String name, String type, boolean required) { ElnFormulaForms.Parameter slot = new ElnFormulaForms.Parameter(); slot.setCanshuBianma(code); slot.setCanshuMingcheng(name); slot.setShujuLeixing(type); slot.setShifouBitian(required ? 1 : 0); return slot; } private static JsonNode snapshotSlots(List slots) { return new ObjectMapper().valueToTree(slots); } private static Map copyInputs(Map inputs) { Map copy = new LinkedHashMap<>(); for (Map.Entry entry : inputs.entrySet()) { copy.put(entry.getKey(), entry.getValue() == null ? null : entry.getValue().deepCopy()); } return copy; } private static String repeat(char value, int count) { char[] values = new char[count]; Arrays.fill(values, value); return new String(values); } private static void assertNotPersistenceOrAuditDependency(Class type) { String typeName = type.getName(); assertFalse("不允许持久化或审计依赖: " + typeName, typeName.contains("elnMapper") || typeName.contains("BaseMapper") || typeName.contains("AuditClient") || typeName.contains("CatalogService")); } private static void assertError(ElnFormulaVos.Preview result, String diagnosticCode) { assertEquals("ERROR", result.getStatus()); assertNull(result.getRawResult()); assertNull(result.getRoundedResult()); assertDiagnostic(result.getDiagnostics(), diagnosticCode); } private static void assertMissing(ElnFormulaVos.Preview result, String expectedText) { assertEquals("MISSING_INPUT", result.getStatus()); assertNull(result.getRawResult()); assertNull(result.getRoundedResult()); for (ElnFormulaVos.Diagnostic diagnostic : result.getDiagnostics()) { if (diagnostic.getMessage().contains(expectedText)) { return; } } throw new AssertionError("缺少包含文本的诊断: " + expectedText); } private static void assertOnlyMissing(ElnFormulaVos.Preview result, String expectedCode, String unexpectedCode) { assertEquals("MISSING_INPUT", result.getStatus()); int missingDiagnostics = 0; for (ElnFormulaVos.Diagnostic diagnostic : result.getDiagnostics()) { if ("MISSING_INPUT".equals(diagnostic.getCode())) { missingDiagnostics++; assertTrue(diagnostic.getMessage().contains(expectedCode)); assertFalse(diagnostic.getMessage().contains(unexpectedCode)); } } assertEquals(1, missingDiagnostics); } private static void assertDiagnostic(List diagnostics, String code) { assertTrue("缺少诊断 " + code, hasDiagnostic(diagnostics, code)); } private static void assertValidationDiagnostic( FormulaValidationService.ValidationResult result, String code) { for (FormulaDiagnostic diagnostic : result.getDiagnostics()) { if (code.equals(diagnostic.getCode())) { return; } } throw new AssertionError("缺少诊断 " + code); } private static boolean hasDiagnostic(List diagnostics, String code) { for (ElnFormulaVos.Diagnostic diagnostic : diagnostics) { if (code.equals(diagnostic.getCode())) { return true; } } return false; } }