This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push: new 1c68d340c Test modernization 1c68d340c is described below commit 1c68d340c64750b84a37e2fcda28cbb964c0ce72 Author: James Bognar <james.bog...@salesforce.com> AuthorDate: Wed Sep 10 08:38:01 2025 -0400 Test modernization --- .../java/org/apache/juneau/NestedTokenizer.java | 253 ++++++++ .../src/test/java/org/apache/juneau/TestUtils.java | 174 +----- .../juneau/html/HtmlDocConfigAnnotation_Test.java | 2 +- .../org/apache/juneau/junit/AssertionArgs.java | 264 ++++++++ .../apache/juneau/junit/AssertionArgs_Test.java | 508 +++++++++++++++ .../java/org/apache/juneau/junit/Assertions2.java | 696 +++++++++++++++++++++ .../test/java/org/apache/juneau/junit/Utils.java | 114 +++- .../apache/juneau/objecttools/ObjectRest_Test.java | 41 +- 8 files changed, 1886 insertions(+), 166 deletions(-) diff --git a/juneau-utest/src/test/java/org/apache/juneau/NestedTokenizer.java b/juneau-utest/src/test/java/org/apache/juneau/NestedTokenizer.java new file mode 100644 index 000000000..8cec94e8e --- /dev/null +++ b/juneau-utest/src/test/java/org/apache/juneau/NestedTokenizer.java @@ -0,0 +1,253 @@ +package org.apache.juneau; + +import static org.apache.juneau.NestedTokenizer.ParseState.*; +import static java.util.Collections.*; +import static java.util.stream.Collectors.*; + +import java.util.*; +import java.util.function.*; + +/** + * Splits a nested comma-delimited string into a list of Token objects using a state machine parser. + * + * <p>This class parses complex nested structures with support for escaping and arbitrary nesting depth. + * The parser uses a finite state machine to handle different contexts during parsing.</p> + * + * <h5 class='section'>Supported Syntax:</h5> + * <ul> + * <li><js>"foo"</js> - Single value token</li> + * <li><js>"foo,bar"</js> - Multiple value tokens</li> + * <li><js>"foo{a,b},bar"</js> - Token with nested values</li> + * <li><js>"foo{a{a1,a2}},bar"</js> - Recursively nested values</li> + * <li><js>"foo\\,bar"</js> - Escaped comma in value</li> + * <li><js>"foo\\{bar\\}"</js> - Escaped braces in value</li> + * </ul> + * + * <h5 class='section'>State Machine:</h5> + * <p>The parser operates in several states:</p> + * <ul> + * <li><b>PARSING_VALUE:</b> Reading a token value</li> + * <li><b>PARSING_NESTED:</b> Reading nested content within braces</li> + * <li><b>IN_ESCAPE:</b> Processing escaped character</li> + * </ul> + * + * <h5 class='section'>Usage Examples:</h5> + * <p class='bjava'> + * <jc>// Simple tokens</jc> + * var tokens = NestedTokenizer.splitNested(<js>"foo,bar,baz"</js>); + * <jc>// tokens = [Token{value="foo"}, Token{value="bar"}, Token{value="baz"}]</jc> + * + * <jc>// Nested tokens</jc> + * var nested = NestedTokenizer.splitNested(<js>"user{name,email},config{timeout,retries}"</js>); + * <jc>// nested[0] = Token{value="user", nested=[Token{value="name"}, Token{value="email"}]}</jc> + * <jc>// nested[1] = Token{value="config", nested=[Token{value="timeout"}, Token{value="retries"}]}</jc> + * </p> + */ +public class NestedTokenizer { + + /** + * Parser states for the finite state machine. + */ + enum ParseState { + /** Parsing a token value outside of nested braces */ + PARSING_VALUE, + /** Parsing nested content within braces */ + PARSING_NESTED, + /** Processing an escaped character */ + IN_ESCAPE + } + + public static List<Token> tokenize(String in) { + if (in == null) throw new IllegalArgumentException("Input was null."); + if (in.isBlank()) throw new IllegalArgumentException("Input was empty."); + + var length = in.length(); + var pos = 0; + var state = PARSING_VALUE; + var currentValue = new StringBuilder(); + var nestedDepth = 0; + var nestedStart = -1; + var tokens = new ArrayList<Token>(); + var lastWasComma = false; + var justCompletedNested = false; + + while (pos < length) { + var c = in.charAt(pos); + + if (state == PARSING_VALUE) { + if (c == '\\') { + state = IN_ESCAPE; + } else if (c == ',') { + var value = currentValue.toString().trim(); + // Add token unless it's empty and we just completed a nested token + if (!value.isEmpty() || tokens.isEmpty() || !justCompletedNested) { + tokens.add(new Token(value)); + } + currentValue.setLength(0); + nestedStart = -1; + lastWasComma = true; + justCompletedNested = false; + pos = skipWhitespace(in, pos); + } else if (c == '{') { + nestedStart = pos + 1; + nestedDepth = 1; + state = PARSING_NESTED; + } else { + currentValue.append(c); + lastWasComma = false; + justCompletedNested = false; + } + } else if (state == PARSING_NESTED) { + if (c == '\\') { + state = IN_ESCAPE; + } else if (c == '{') { + nestedDepth++; + } else if (c == '}') { + nestedDepth--; + if (nestedDepth == 0) { + var value = currentValue.toString().trim(); + var nestedContent = in.substring(nestedStart, pos); + var token = new Token(value); + if (!nestedContent.trim().isEmpty()) { + token.setNested(tokenize(nestedContent)); + } + tokens.add(token); + currentValue.setLength(0); + nestedStart = -1; + lastWasComma = false; // Reset since we've completed a token + justCompletedNested = true; // Flag that we just completed a nested token + pos = skipWhitespace(in, pos); + state = PARSING_VALUE; + } + } + } else if (state == IN_ESCAPE) { + // Add the escaped character to current value only if we're parsing the main token value + if (nestedDepth == 0) { + currentValue.append(c); + } + state = (nestedDepth > 0) ? PARSING_NESTED : PARSING_VALUE; + } + + pos++; + } + + // Add final token if we have content, or if input ended with comma, or if no tokens yet + var finalValue = currentValue.toString().trim(); + if (!finalValue.isEmpty() || lastWasComma || tokens.isEmpty()) { + tokens.add(new Token(finalValue)); + } + + return tokens; + } + + private static int skipWhitespace(String input, int position) { + var length = input.length(); + while (position + 1 < length && Character.isWhitespace(input.charAt(position + 1))) { + position++; + } + return position; + } + + /** + * Represents a parsed token with optional nested sub-tokens. + * + * <p>A Token contains a string value and may have nested tokens representing + * the content inside braces. Tokens support deep nesting for complex hierarchical structures.</p> + * + * <h5 class='section'>Structure:</h5> + * <ul> + * <li><b>value:</b> The main token value (part before any braces)</li> + * <li><b>nested:</b> Optional list of nested tokens (content within braces)</li> + * </ul> + * + * <h5 class='section'>Examples:</h5> + * <ul> + * <li><js>"foo"</js> → <js>Token{value="foo", nested=null}</js></li> + * <li><js>"foo{a,b}"</js> → <js>Token{value="foo", nested=[Token{value="a"}, Token{value="b"}]}</js></li> + * </ul> + */ + public static class Token { + + /** The main value of this token */ + private final String value; + + /** Nested tokens if this token has braced content, null otherwise */ + private List<Token> nested; + + /** + * Creates a new token with the specified value. + * + * @param value The token value + */ + public Token(String value) { + this.value = value != null ? value : ""; + } + + /** + * Returns the main value of this token. + * + * @return The token value + */ + public String getValue() { + return value; + } + + /** + * Returns true if this token has nested content. + * + * @return true if nested tokens exist + */ + public boolean hasNested() { + return nested != null && !nested.isEmpty(); + } + + /** + * Returns an unmodifiable view of the nested tokens. + * + * @return unmodifiable list of nested tokens, or empty list if none + */ + public List<Token> getNested() { + return nested != null ? unmodifiableList(nested) : emptyList(); + } + + /** + * Sets the nested tokens for this token (package-private for tokenizer use). + * + * @param nested The list of nested tokens + */ + void setNested(List<Token> nested) { + this.nested = nested; + } + + @Override + public String toString() { + return hasNested() ? nested.stream().map(Object::toString).collect(joining(",",value + "{","}")) : value; + } + + @Override + public boolean equals(Object o) { + return (o instanceof Token o2) && eq(this, o2, (x,y)->x.value.equals(y.value) && eq(x.nested, y.nested)); + } + + @Override + public int hashCode() { + return Objects.hash(value, nested); + } + } + + //--------------------------------------------------------------------------------------------- + // Helper methods. + //--------------------------------------------------------------------------------------------- + + private static <T,U> boolean eq(T o1, U o2, BiPredicate<T,U> test) { + if (o1 == null) { return o2 == null; } + if (o2 == null) { return false; } + if (o1 == o2) { return true; } + return test.test(o1, o2); + } + + @SuppressWarnings("unlikely-arg-type") + private static <T,U> boolean eq(T o1, U o2) { + return Objects.equals(o1, o2); + } +} diff --git a/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java b/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java index 1f6f06e84..7344e6e1a 100644 --- a/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java +++ b/juneau-utest/src/test/java/org/apache/juneau/TestUtils.java @@ -34,7 +34,6 @@ import org.apache.juneau.rest.mock.*; import org.apache.juneau.serializer.*; import org.apache.juneau.xml.*; import org.junit.jupiter.api.*; -import org.opentest4j.*; /** * Comprehensive utility class for Bean-Centric Tests (BCT) and general testing operations. @@ -314,14 +313,7 @@ public class TestUtils extends Utils2 { * @see BasicBeanConverter */ public static void assertBean(Object actual, String fields, String expected) { - assertBean(BasicBeanConverter.DEFAULT, actual, fields, expected); - } - - public static void assertBean(BeanConverter converter, Object actual, String fields, String expected) { - assertNotNull(actual, "Value was null."); - assertArgNotNull("fields", fields); - assertArgNotNull("expected", expected); - assertEquals(expected, tokenize(fields).stream().map(x -> converter.getNested(actual, x)).collect(joining(","))); + Assertions2.assertBean(actual, fields, expected); } private static List<NestedTokenizer.Token> tokenize(String fields) { @@ -386,70 +378,9 @@ public class TestUtils extends Utils2 { * @see #assertBean(Object, String, String) */ public static void assertBeans(Object actual, String fields, String...expected) { - assertBeans(BasicBeanConverter.DEFAULT, actual, fields, expected); + Assertions2.assertBeans(actual, fields, expected); } - public static void assertBeans(BasicBeanConverter converter, Object actual, String fields, String...expected) { - assertNotNull(actual, "Value was null."); - assertArgNotNull("fields", fields); - assertArgNotNull("expected", expected); - - var tokens = tokenize(fields); - var errors = new ArrayList<AssertionFailedError>(); - var actualList = converter.listify(actual); - - if (ne(expected.length, actualList.size())) { - errors.add(assertionFailed(expected.length, actualList.size(), "Wrong number of beans.")); - } else { - for (var i = 0; i < actualList.size(); i++) { - var i2 = i; - var a = tokens.stream().map(x -> converter.getNested(actualList.get(i2), x)).collect(joining(",")); - if (ne(r(expected[i]), a)) { - errors.add(assertionFailed(r(expected[i]), a, "Bean at row {0} did not match.", i)); - } - } - } - - if (errors.isEmpty()) return; - - var actualStrings = new ArrayList<String>(); - for (var o : actualList) { - actualStrings.add(tokens.stream().map(x -> converter.getNested(o, x)).collect(joining(","))); - } - - if (errors.size() == 1) throw errors.get(0); - throw assertionFailed( - Stream.of(expected).map(TestUtils::escapeForJava).collect(joining("\", \"", "\"", "\"")), - actualStrings.stream().map(TestUtils::escapeForJava).collect(joining("\", \"", "\"", "\"")), - "{0} bean assertions failed: {1}", errors.size(), errors.stream().map(x -> x.getMessage()).collect(joining("\n")) - ); - } - - private static AssertionFailedError assertionFailed(Object expected, Object actual, String message, Object...args) { - return new AssertionFailedError(f(message, args) + f(" ==> expected: <{0}> but was: <{1}>", expected, actual), expected, actual); - } - - private static String escapeForJava(String s) { - StringBuilder sb = new StringBuilder(); - for (char c : s.toCharArray()) { - switch (c) { - case '\"': sb.append("\\\""); break; - case '\\': sb.append("\\\\"); break; - case '\n': sb.append("\\n"); break; - case '\r': sb.append("\\r"); break; - case '\t': sb.append("\\t"); break; - case '\f': sb.append("\\f"); break; - case '\b': sb.append("\\b"); break; - default: - if (c < 0x20 || c > 0x7E) { - sb.append(String.format("\\u%04x", (int)c)); - } else { - sb.append(c); - } - } - } - return sb.toString(); - } /** * Asserts that a List contains the expected values using flexible comparison logic. @@ -508,41 +439,14 @@ public class TestUtils extends Utils2 { * @see #l(Object) for converting other collection types to Lists */ public static <T> void assertList(Object actual, Object...expected) { - assertList(BasicBeanConverter.DEFAULT, actual, expected); - } - - @SuppressWarnings("unchecked") - public static <T> void assertList(BeanConverter converter, Object actual, Object...expected) { - assertNotNull(actual, "Value was null."); - assertArgNotNull("expected", expected); - - var list = converter.listify(actual); - assertEquals(expected.length, list.size(), fms("Wrong list length. expected={0}, actual={1}", expected.length, list.size())); - - for (var i = 0; i < expected.length; i++) { - var x = list.get(i); - if (expected[i] instanceof String e) { - assertEquals(e, converter.stringify(x), fms("Element at index {0} did not match. expected={1}, actual={2}", i, e, converter.stringify(x))); - } else if (expected[i] instanceof Predicate e) { - assertTrue(e.test(x), fms("Element at index {0} did pass predicate. actual={1}", i, converter.stringify(x))); - } else { - assertEquals(expected[i], x, fms("Element at index {0} did not match. expected={1}({2}), actual={3}(4)", i, expected[i], t(expected[i]), x, t(x))); - } - } - } - - private static String t(Object o) { - return o == null ? null : o.getClass().getSimpleName(); + Assertions2.assertList(actual, expected); } /** * Asserts an object matches the expected string after it's been made {@link Utils#r readable}. */ public static void assertContains(String expected, Object actual) { - assertArgNotNull("expected", expected); - assertNotNull(actual, "Value was null."); - var a2 = r(actual); - assertTrue(a2.contains(expected), fms("String did not contain expected substring. expected={0}, actual={1}", expected, a2)); + Assertions2.assertContains(expected, actual); } /** @@ -551,33 +455,15 @@ public class TestUtils extends Utils2 { * @param expected * @param actual */ - public static void assertContainsAll(String expected, Object actual) { - assertArgNotNull("expected", expected); - assertNotNull(actual, "Value was null."); - var a2 = r(actual); - for (var e : splita(expected)) - assertTrue(a2.contains(e), fms("String did not contain expected substring. expected={0}, actual={1}", e, a2)); + public static void assertContainsAll(Object actual, String...expected) { + Assertions2.assertContainsAll(actual, expected); } /** * Asserts that a collection is not null and empty. */ public static void assertEmpty(Object value) { - if (value instanceof Optional v2) { - assertTrue(v2.isEmpty(), "Optional was not empty"); - return; - } - if (value instanceof Map v2) { - assertTrue(v2.isEmpty(), "Map was not empty"); - return; - } - assertEmpty(BasicBeanConverter.DEFAULT, value); - } - - public static void assertEmpty(BasicBeanConverter converter, Object value) { - assertNotNull(value, "Value was null."); - assertTrue(converter.canListify(value), fms("Value cannot be converted to a list. Class={0}", value.getClass().getSimpleName())); - assertTrue(converter.listify(value).isEmpty(), "Value was not empty."); + Assertions2.assertEmpty(value); } public static void assertEqualsAll(Object...values) { @@ -656,14 +542,7 @@ public class TestUtils extends Utils2 { * @see BasicBeanConverter */ public static void assertMap(Map<?,?> actual, String fields, String expected) { - assertMap(BasicBeanConverter.DEFAULT, actual, fields, expected); - } - - public static void assertMap(BeanConverter converter, Map<?,?> actual, String fields, String expected) { - assertNotNull(actual, "Value was null."); - assertArgNotNull("fields", fields); - assertArgNotNull("expected", expected); - assertEquals(expected, tokenize(fields).stream().map(x -> converter.getNested(actual, x)).collect(joining(","))); + Assertions2.assertBean(actual, fields, expected); } /** @@ -693,24 +572,14 @@ public class TestUtils extends Utils2 { * @see BasicBeanConverter */ public static <T> void assertMapped(T actual, BiFunction<T,String,Object> f, String properties, String expected) { - assertNotNull(actual, "Value was null."); - var m = new LinkedHashMap<String,Object>(); - for (var p : split(properties)) { - try { - m.put(p, f.apply(actual, p)); - } catch (Exception e) { - m.put(p, simpleClassNameOf(e)); - } - } - assertMap(m, properties, expected); + Assertions2.assertMapped(actual, f, properties, expected); } /** * Asserts that a collection is not null and not empty. */ public static void assertNotEmpty(Object value) { - assertNotNull(value, "Value was null."); - assertFalse(toList(value).isEmpty(), "Value was empty."); + Assertions2.assertNotEmpty(value); } public static void assertNotEqualsAny(Object actual, Object...values) { @@ -753,41 +622,28 @@ public class TestUtils extends Utils2 { * @throws AssertionError if the object is null or not the expected size. */ public static void assertSize(int expected, Object actual) { - assertNotNull(actual, "Value was null."); - if (actual instanceof String a2) { - assertEquals(expected, a2.length(), fms("Value not expected size. Expected: {0}, Actual: {1}, Value: {2}", expected, a2.length(), a2)); - return; - } - assertEquals(expected, toList(actual).size(), fms("Value not expected size. Expected: {0}, Actual: {1}", expected, toList(actual).size())); + Assertions2.assertSize(expected, actual); } /** * Asserts an object matches the expected string after it's been made {@link Utils#r readable}. */ public static void assertString(String expected, Object actual) { - assertNotNull(actual, "Value was null."); - assertEquals(expected, r(actual)); + Assertions2.assertString(expected, actual); } /** * Asserts value when stringified matches the specified pattern. */ - public static Object assertMatches(String pattern, Object value) { - var m = getMatchPattern3(pattern).matcher(s(value)); - if (! m.matches()) { - var msg = "Pattern didn't match: \n\tExpected:\n"+pattern+"\n\tActual:\n"+value; - System.err.println(msg); // For easier debugging. - fail(msg); - } - return value; + public static void assertMatches(String pattern, Object value) { + Assertions2.assertMatches(pattern, value); } /** * Asserts an object matches the expected string after it's been made {@link Utils#r readable}. */ public static void assertString(String expected, Object actual, Supplier<String> messageSupplier) { - assertNotNull(actual, "Value was null."); - assertEquals(expected, r(actual), messageSupplier); + Assertions2.assertString(expected, actual); } public static <T extends Throwable> T assertThrowable(Class<? extends Throwable> expectedType, String expectedSubstring, T t) { diff --git a/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java b/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java index 1ae77f1d9..6bc18ef9e 100644 --- a/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java +++ b/juneau-utest/src/test/java/org/apache/juneau/html/HtmlDocConfigAnnotation_Test.java @@ -314,7 +314,7 @@ class HtmlDocConfigAnnotation_Test extends SimpleTestBase { var al = AnnotationWorkList.of(sr, e.getAnnotationList()); var x = HtmlDocSerializer.create().apply(al).build().getSession(); var r = x.serialize(null).replaceAll("[\r\n]+", "|"); - assertContainsAll("<aside>xxx</aside>,<footer>xxx</footer>,<head>xxx,<style>@import \"xxx\"; xxx zzz</style>,<nav><ol><li>xxx</li></ol>xxx</nav>,<script>xxx| yyy|</script>", r); + assertContainsAll(r, "<aside>xxx</aside>","<footer>xxx</footer>","<head>xxx","<style>@import \"xxx\"; xxx zzz</style>","<nav><ol><li>xxx</li></ol>xxx</nav>","<script>xxx| yyy|</script>"); } //----------------------------------------------------------------------------------------------------------------- diff --git a/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs.java b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs.java new file mode 100644 index 000000000..cdf2a1705 --- /dev/null +++ b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs.java @@ -0,0 +1,264 @@ +// *************************************************************************************************************************** +// * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file * +// * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file * +// * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance * +// * with the License. You may obtain a copy of the License at * +// * * +// * http://www.apache.org/licenses/LICENSE-2.0 * +// * * +// * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an * +// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * +// * specific language governing permissions and limitations under the License. * +// *************************************************************************************************************************** +package org.apache.juneau.junit; + +import static java.util.Optional.*; +import static org.apache.juneau.junit.Utils.*; + +import java.text.*; +import java.util.*; +import java.util.function.*; + +/** + * Configuration and context object for advanced assertion operations. + * + * <p>This class encapsulates additional arguments and configuration options for assertion methods + * in the Bean-Centric Testing (BCT) framework. It provides a fluent API for customizing assertion + * behavior including custom converters and enhanced error messaging.</p> + * + * <p>The primary purposes of this class are:</p> + * <ul> + * <li><b>Custom Bean Conversion:</b> Override the default {@link BeanConverter} for specialized object introspection</li> + * <li><b>Enhanced Error Messages:</b> Add context-specific error messages with parameter substitution</li> + * <li><b>Fluent Configuration:</b> Chain configuration calls for readable test setup</li> + * <li><b>Assertion Context:</b> Provide additional context for complex assertion scenarios</li> + * </ul> + * + * <h5 class='section'>Basic Usage:</h5> + * <p class='bjava'> + * <jc>// Simple usage with default settings</jc> + * assertBean(args(), myBean, <js>"name,age"</js>, <js>"John,30"</js>); + * + * <jc>// Custom error message</jc> + * assertBean(args().setMessage(<js>"User validation failed"</js>), + * user, <js>"email,active"</js>, <js>"j...@example.com,true"</js>); + * </p> + * + * <h5 class='section'>Custom Bean Converter:</h5> + * <p class='bjava'> + * <jc>// Use custom converter for specialized object handling</jc> + * var customConverter = BasicBeanConverter.builder() + * .addStringifier(MyClass.class, obj -> obj.getDisplayName()) + * .build(); + * + * assertBean(args().setBeanConverter(customConverter), + * myCustomObject, <js>"property"</js>, <js>"expectedValue"</js>); + * </p> + * + * <h5 class='section'>Advanced Error Messages:</h5> + * <p class='bjava'> + * <jc>// Parameterized error messages</jc> + * assertBean(args().setMessage(<js>"Validation failed for user {0}"</js>, userId), + * user, <js>"status"</js>, <js>"ACTIVE"</js>); + * + * <jc>// Dynamic error message with supplier</jc> + * assertBean(args().setMessage(() -> <js>"Test failed at "</js> + Instant.now()), + * result, <js>"success"</js>, <js>"true"</js>); + * </p> + * + * <h5 class='section'>Fluent Configuration:</h5> + * <p class='bjava'> + * <jc>// Chain multiple configuration options</jc> + * var testArgs = args() + * .setBeanConverter(customConverter) + * .setMessage(<js>"Integration test failed for module {0}"</js>, moduleName); + * + * assertBean(testArgs, moduleConfig, <js>"enabled,version"</js>, <js>"true,2.1.0"</js>); + * assertBeans(testArgs, moduleList, <js>"name,status"</js>, + * <js>"ModuleA,ACTIVE"</js>, <js>"ModuleB,ACTIVE"</js>); + * </p> + * + * <h5 class='section'>Error Message Composition:</h5> + * <p>When assertion failures occur, error messages are intelligently composed:</p> + * <ul> + * <li><b>Base Message:</b> Custom message set via {@link #setMessage(String, Object)} or {@link #setMessage(Supplier)}</li> + * <li><b>Assertion Context:</b> Specific context provided by individual assertion methods</li> + * <li><b>Composite Format:</b> <js>"{base message}, Caused by: {assertion context}"</js></li> + * </ul> + * + * <p class='bjava'> + * <jc>// Example error message composition:</jc> + * <jc>// Base: "User validation failed for user 123"</jc> + * <jc>// Context: "Bean assertion failed."</jc> + * <jc>// Result: "User validation failed for user 123, Caused by: Bean assertion failed."</jc> + * </p> + * + * <h5 class='section'>Thread Safety:</h5> + * <p>This class is <b>not thread-safe</b> and is intended for single-threaded test execution. + * Each test method should create its own instance using {@link Assertions2#args()} or create + * a new instance directly with {@code new AssertionArgs()}.</p> + * + * <h5 class='section'>Immutability Considerations:</h5> + * <p>While this class uses fluent setters that return {@code this} for chaining, the instance + * is mutable. For reusable configurations across multiple tests, consider creating a factory + * method that returns pre-configured instances.</p> + * + * @see Assertions2#args() + * @see BeanConverter + * @see BasicBeanConverter + */ +public class AssertionArgs { + + private BeanConverter beanConverter; + private Supplier<String> messageSupplier; + + /** + * Creates a new instance with default settings. + * + * <p>Instances start with no custom bean converter and no custom error message. + * All assertion methods will use default behavior until configured otherwise.</p> + */ + public AssertionArgs() { /* no-op */ } + + /** + * Sets a custom {@link BeanConverter} for object introspection and property access. + * + * <p>The custom converter allows fine-tuned control over how objects are converted to strings, + * how collections are listified, and how nested properties are accessed. This is particularly + * useful for:</p> + * <ul> + * <li><b>Custom Object Types:</b> Objects that don't follow standard JavaBean patterns</li> + * <li><b>Specialized Formatting:</b> Custom string representations for assertion comparisons</li> + * <li><b>Performance Optimization:</b> Cached or optimized property access strategies</li> + * <li><b>Domain-Specific Logic:</b> Business-specific property resolution rules</li> + * </ul> + * + * <h5 class='section'>Example:</h5> + * <p class='bjava'> + * <jc>// Create converter with custom stringifiers</jc> + * var converter = BasicBeanConverter.builder() + * .addStringifier(LocalDate.class, date -> date.format(DateTimeFormatter.ISO_LOCAL_DATE)) + * .addStringifier(Money.class, money -> money.getAmount().toPlainString()) + * .build(); + * + * <jc>// Use in assertions</jc> + * assertBean(args().setBeanConverter(converter), + * order, <js>"date,total"</js>, <js>"2023-12-01,99.99"</js>); + * </p> + * + * @param value The custom bean converter to use. If null, assertions will fall back to the default converter. + * @return This instance for method chaining. + */ + public AssertionArgs setBeanConverter(BeanConverter value) { + beanConverter = value; + return this; + } + + /** + * Gets the configured bean converter, if any. + * + * @return An Optional containing the custom converter, or empty if using default behavior. + */ + protected Optional<BeanConverter> getBeanConverter() { + return ofNullable(beanConverter); + } + + /** + * Sets a custom error message supplier for assertion failures. + * + * <p>The supplier allows for dynamic message generation, including context that may only + * be available at the time of assertion failure. This is useful for:</p> + * <ul> + * <li><b>Timestamps:</b> Including the exact time of failure</li> + * <li><b>Test State:</b> Including runtime state information</li> + * <li><b>Expensive Operations:</b> Deferring costly string operations until needed</li> + * <li><b>Conditional Messages:</b> Different messages based on runtime conditions</li> + * </ul> + * + * <h5 class='section'>Example:</h5> + * <p class='bjava'> + * <jc>// Dynamic message with timestamp</jc> + * assertBean(args().setMessage(() -> <js>"Test failed at "</js> + Instant.now()), + * result, <js>"status"</js>, <js>"SUCCESS"</js>); + * + * <jc>// Message with expensive computation</jc> + * assertBean(args().setMessage(() -> <js>"Failed after "</js> + computeTestDuration() + <js>" ms"</js>), + * response, <js>"error"</js>, <js>"null"</js>); + * </p> + * + * @param value The message supplier. Called only when an assertion fails. + * @return This instance for method chaining. + */ + public AssertionArgs setMessage(Supplier<String> value) { + messageSupplier = value; + return this; + } + + /** + * Sets a parameterized error message for assertion failures. + * + * <p>This method uses {@link MessageFormat} to substitute parameters into the message template. + * The formatting occurs immediately when this method is called, not when the assertion fails.</p> + * + * <h5 class='section'>Parameter Substitution:</h5> + * <p>Uses standard MessageFormat patterns:</p> + * <ul> + * <li><code>{0}</code> - First parameter</li> + * <li><code>{1}</code> - Second parameter</li> + * <li><code>{0,number,#}</code> - Formatted number</li> + * <li><code>{0,date,short}</code> - Formatted date</li> + * </ul> + * + * <h5 class='section'>Examples:</h5> + * <p class='bjava'> + * <jc>// Simple parameter substitution</jc> + * assertBean(args().setMessage(<js>"User {0} validation failed"</js>, userId), + * user, <js>"active"</js>, <js>"true"</js>); + * + * <jc>// Multiple parameters</jc> + * assertBean(args().setMessage(<js>"Test {0} failed on iteration {1}"</js>, testName, iteration), + * result, <js>"success"</js>, <js>"true"</js>); + * + * <jc>// Number formatting</jc> + * assertBean(args().setMessage(<js>"Expected {0,number,#.##} but got different value"</js>, expectedValue), + * actual, <js>"value"</js>, <js>"123.45"</js>); + * </p> + * + * @param message The message template with MessageFormat placeholders. + * @param args The parameters to substitute into the message template. + * @return This instance for method chaining. + */ + public AssertionArgs setMessage(String message, Object... args) { + messageSupplier = () -> MessageFormat.format(message, args); + return this; + } + + /** + * Gets the base message supplier for composition with assertion-specific messages. + * + * @return The configured message supplier, or null if no custom message was set. + */ + protected Supplier<String> getMessage() { + return messageSupplier; + } + + /** + * Composes the final error message by combining custom and assertion-specific messages. + * + * <p>This method implements the message composition strategy used throughout the assertion framework:</p> + * <ul> + * <li><b>No Custom Message:</b> Returns the assertion-specific message as-is</li> + * <li><b>With Custom Message:</b> Returns <code>"{custom}, Caused by: {assertion}"</code></li> + * </ul> + * + * <p>This allows tests to provide high-level context while preserving the specific + * technical details about what assertion failed.</p> + * + * @param msg The assertion-specific message template. + * @param args Parameters for the assertion-specific message. + * @return A supplier that produces the composed error message. + */ + protected Supplier<String> getMessage(String msg, Object...args) { + return messageSupplier == null ? fs(msg, args) : fs("{0}, Caused by: {1}", messageSupplier.get(), f(msg, args)); + } +} diff --git a/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs_Test.java b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs_Test.java new file mode 100644 index 000000000..033c5a8d9 --- /dev/null +++ b/juneau-utest/src/test/java/org/apache/juneau/junit/AssertionArgs_Test.java @@ -0,0 +1,508 @@ +// *************************************************************************************************************************** +// * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file * +// * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file * +// * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance * +// * with the License. You may obtain a copy of the License at * +// * * +// * http://www.apache.org/licenses/LICENSE-2.0 * +// * * +// * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an * +// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * +// * specific language governing permissions and limitations under the License. * +// *************************************************************************************************************************** +package org.apache.juneau.junit; + +import static org.apache.juneau.junit.Assertions2.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.text.*; +import java.time.*; +import java.util.*; +import java.util.function.*; + +import org.junit.jupiter.api.*; + +/** + * Unit tests for {@link AssertionArgs}. + * + * <p>Tests the configuration and behavior of the assertion arguments class including + * bean converter customization, error message composition, and fluent API functionality.</p> + */ +class AssertionArgs_Test { + + // Test objects for assertions + static class TestBean { + private String name; + private int age; + private boolean active; + + public TestBean(String name, int age, boolean active) { + this.name = name; + this.age = age; + this.active = active; + } + + public String getName() { return name; } + public int getAge() { return age; } + public boolean isActive() { return active; } + } + + static class CustomObject { + private String value; + + public CustomObject(String value) { + this.value = value; + } + + public String getValue() { return value; } + + @Override + public String toString() { + return "CustomObject[" + value + "]"; + } + } + + @Test + void a01_defaultConstruction() { + var args = new AssertionArgs(); + + // Should have no custom converter + assertTrue(args.getBeanConverter().isEmpty()); + + // Should have no custom message + assertNull(args.getMessage()); + } + + @Test + void a02_fluentAPIReturnsThis() { + var args = new AssertionArgs(); + var mockConverter = createMockConverter(); + + // Fluent methods should return the same instance + assertSame(args, args.setBeanConverter(mockConverter)); + assertSame(args, args.setMessage("test message")); + assertSame(args, args.setMessage(() -> "dynamic message")); + } + + @Test + void b01_beanConverterConfiguration() { + var args = new AssertionArgs(); + var mockConverter = createMockConverter(); + + // Initially empty + assertTrue(args.getBeanConverter().isEmpty()); + + // Set converter + args.setBeanConverter(mockConverter); + assertTrue(args.getBeanConverter().isPresent()); + assertSame(mockConverter, args.getBeanConverter().get()); + + // Set to null should clear + args.setBeanConverter(null); + assertTrue(args.getBeanConverter().isEmpty()); + } + + @Test + void b02_customConverterInAssertion() { + // Create a mock custom converter for testing + var customConverter = createCustomConverter(); + + var args = args().setBeanConverter(customConverter); + var obj = new TestBeanWithCustomObject("test", new CustomObject("value")); + + // Should use custom converter for stringification + assertBean(args, obj, "custom", "CUSTOM:value"); + } + + static class TestBeanWithCustomObject { + private String name; + private CustomObject custom; + + public TestBeanWithCustomObject(String name, CustomObject custom) { + this.name = name; + this.custom = custom; + } + + public String getName() { return name; } + public CustomObject getCustom() { return custom; } + } + + @Test + void c01_messageSupplierConfiguration() { + var args = new AssertionArgs(); + + // Initially null + assertNull(args.getMessage()); + + // Set supplier + Supplier<String> supplier = () -> "test message"; + args.setMessage(supplier); + assertNotNull(args.getMessage()); + assertEquals("test message", args.getMessage().get()); + + // Set different supplier + args.setMessage(() -> "different message"); + assertEquals("different message", args.getMessage().get()); + } + + @Test + void c02_parameterizedMessageConfiguration() { + var args = new AssertionArgs(); + + // Simple parameter substitution + args.setMessage("Hello {0}", "World"); + assertEquals("Hello World", args.getMessage().get()); + + // Multiple parameters + args.setMessage("User {0} has {1} points", "John", 100); + assertEquals("User John has 100 points", args.getMessage().get()); + + // Number formatting + args.setMessage("Value: {0,number,#.##}", 123.456); + assertEquals("Value: 123.46", args.getMessage().get()); + } + + @Test + void c03_dynamicMessageSupplier() { + var counter = new int[1]; // Mutable counter for testing + var args = new AssertionArgs(); + + args.setMessage(() -> "Call #" + (++counter[0])); + + // Each call should increment the counter + assertEquals("Call #1", args.getMessage().get()); + assertEquals("Call #2", args.getMessage().get()); + assertEquals("Call #3", args.getMessage().get()); + } + + @Test + void d01_messageCompositionWithoutCustomMessage() { + var args = new AssertionArgs(); + + // No custom message, should return assertion message as-is + var composedMessage = args.getMessage("Bean assertion failed"); + assertEquals("Bean assertion failed", composedMessage.get()); + + // With parameters + var composedWithParams = args.getMessage("Element at index {0} did not match", 5); + assertEquals("Element at index 5 did not match", composedWithParams.get()); + } + + @Test + void d02_messageCompositionWithCustomMessage() { + var args = new AssertionArgs(); + args.setMessage("User validation failed"); + + // Should compose: custom + assertion + var composedMessage = args.getMessage("Bean assertion failed"); + assertEquals("User validation failed, Caused by: Bean assertion failed", composedMessage.get()); + + // With parameters in assertion message + var composedWithParams = args.getMessage("Element at index {0} did not match", 3); + assertEquals("User validation failed, Caused by: Element at index 3 did not match", composedWithParams.get()); + } + + @Test + void d03_messageCompositionWithParameterizedCustomMessage() { + var args = new AssertionArgs(); + args.setMessage("Test {0} failed on iteration {1}", "UserValidation", 42); + + var composedMessage = args.getMessage("Bean assertion failed"); + assertEquals("Test UserValidation failed on iteration 42, Caused by: Bean assertion failed", composedMessage.get()); + } + + @Test + void d04_messageCompositionWithDynamicCustomMessage() { + var timestamp = Instant.now().toString(); + var args = new AssertionArgs(); + args.setMessage(() -> "Test failed at " + timestamp); + + var composedMessage = args.getMessage("Bean assertion failed"); + assertEquals("Test failed at " + timestamp + ", Caused by: Bean assertion failed", composedMessage.get()); + } + + @Test + void e01_fluentConfigurationChaining() { + var converter = createMockConverter(); + + // Chain multiple configurations + var args = new AssertionArgs() + .setBeanConverter(converter) + .setMessage("Integration test failed for module {0}", "AuthModule"); + + // Verify both configurations applied + assertTrue(args.getBeanConverter().isPresent()); + assertSame(converter, args.getBeanConverter().get()); + assertEquals("Integration test failed for module AuthModule", args.getMessage().get()); + } + + @Test + void e02_configurationOverwriting() { + var args = new AssertionArgs(); + var converter1 = createMockConverter(); + var converter2 = createMockConverter(); + + // Set initial values + args.setBeanConverter(converter1) + .setMessage("First message"); + + // Overwrite with new values + args.setBeanConverter(converter2) + .setMessage("Second message"); + + // Should have latest values + assertSame(converter2, args.getBeanConverter().get()); + assertEquals("Second message", args.getMessage().get()); + } + + @Test + void f01_integrationWithAssertBean() { + var bean = new TestBean("John", 30, true); + var args = args().setMessage("User test failed"); + + // Should work with custom message + assertBean(args, bean, "name,age,active", "John,30,true"); + + // Test assertion failure message composition + var exception = assertThrows(AssertionError.class, () -> { + assertBean(args, bean, "name", "Jane"); + }); + + assertTrue(exception.getMessage().contains("User test failed")); + assertTrue(exception.getMessage().contains("Caused by:")); + } + + @Test + void f02_integrationWithAssertBeans() { + var beans = List.of( + new TestBean("Alice", 25, true), + new TestBean("Bob", 35, false) + ); + var args = args().setMessage("Batch validation failed"); + + // Should work with custom message + assertBeans(args, beans, "name,age", "Alice,25", "Bob,35"); + + // Test assertion failure message composition + var exception = assertThrows(AssertionError.class, () -> { + assertBeans(args, beans, "name", "Charlie", "David"); + }); + + assertTrue(exception.getMessage().contains("Batch validation failed")); + assertTrue(exception.getMessage().contains("Caused by:")); + } + + @Test + void f03_integrationWithAssertList() { + var list = List.of("apple", "banana", "cherry"); + var args = args().setMessage("List validation failed"); + + // Should work with custom message + assertList(args, list, "apple", "banana", "cherry"); + + // Test assertion failure message composition + var exception = assertThrows(AssertionError.class, () -> { + assertList(args, list, "orange", "banana", "cherry"); + }); + + assertTrue(exception.getMessage().contains("List validation failed")); + assertTrue(exception.getMessage().contains("Caused by:")); + } + + @Test + void g01_edgeCaseNullValues() { + var args = new AssertionArgs(); + + // Null converter should work + args.setBeanConverter(null); + assertTrue(args.getBeanConverter().isEmpty()); + + // Null message supplier should work + args.setMessage((Supplier<String>) null); + assertNull(args.getMessage()); + } + + @Test + void g02_edgeCaseEmptyMessages() { + var args = new AssertionArgs(); + + // Empty string message + args.setMessage(""); + assertEquals("", args.getMessage().get()); + + // Empty supplier result + args.setMessage(() -> ""); + assertEquals("", args.getMessage().get()); + + // Composition with empty custom message + var composedMessage = args.getMessage("Bean assertion failed"); + assertEquals(", Caused by: Bean assertion failed", composedMessage.get()); + } + + @Test + void g03_edgeCaseComplexParameterFormatting() { + var args = new AssertionArgs(); + var date = new Date(); + + // Date formatting + args.setMessage("Test executed on {0,date,short}", date); + var expectedDatePart = DateFormat.getDateInstance(DateFormat.SHORT).format(date); + assertTrue(args.getMessage().get().contains(expectedDatePart)); + + // Complex number formatting + args.setMessage("Processing {0,number,percent} complete", 0.85); + assertTrue(args.getMessage().get().contains("85%")); + } + + @Test + void h01_threadSafetyDocumentationCompliance() { + // This test documents that AssertionArgs is NOT thread-safe + // Each thread should create its own instance + + var sharedArgs = new AssertionArgs(); + var results = Collections.synchronizedList(new ArrayList<String>()); + + // Simulate multiple threads modifying the same instance + var threads = new Thread[5]; + for (int i = 0; i < threads.length; i++) { + final int threadId = i; + threads[i] = new Thread(() -> { + sharedArgs.setMessage("Thread " + threadId + " message"); + // Small delay to increase chance of race condition + try { Thread.sleep(1); } catch (InterruptedException e) {} + results.add(sharedArgs.getMessage().get()); + }); + } + + // Start all threads + for (var thread : threads) { + thread.start(); + } + + // Wait for completion + for (var thread : threads) { + try { thread.join(); } catch (InterruptedException e) {} + } + + // Due to race conditions, we may not get the expected messages + // This demonstrates why each test should create its own instance + assertEquals(5, results.size()); + // Note: We don't assert specific values due to race conditions + } + + @Test + void h02_recommendedUsagePattern() { + // Demonstrate the recommended pattern: create new instance per test + + // Test 1: User validation + var userArgs = args().setMessage("User validation test"); + var user = new TestBean("Alice", 25, true); + assertBean(userArgs, user, "name,active", "Alice,true"); + + // Test 2: Product validation (separate instance) + var productArgs = args().setMessage("Product validation test"); + var products = List.of("Laptop", "Phone", "Tablet"); + assertList(productArgs, products, "Laptop", "Phone", "Tablet"); + + // Each test has its own configuration without interference + assertEquals("User validation test", userArgs.getMessage().get()); + assertEquals("Product validation test", productArgs.getMessage().get()); + } + + // Helper method to create a mock converter for testing + private BeanConverter createMockConverter() { + return new BeanConverter() { + @Override + public String stringify(Object o) { + return String.valueOf(o); + } + + @Override + public List<Object> listify(Object o) { + if (o instanceof List) return (List<Object>) o; + return List.of(o); + } + + @Override + public boolean canListify(Object o) { + return true; + } + + @Override + public Object swap(Object o) { + return o; + } + + @Override + public Object getProperty(Object object, String name) { + // Simple mock implementation + if ("name".equals(name) && object instanceof TestBean) { + return ((TestBean) object).getName(); + } + if ("custom".equals(name) && object instanceof TestBeanWithCustomObject) { + return ((TestBeanWithCustomObject) object).getCustom(); + } + return null; + } + + @Override + public <T> T getSetting(String key, T defaultValue) { + return defaultValue; + } + + @Override + public String getNested(Object o, NestedTokenizer.Token token) { + var propValue = getProperty(o, token.getValue()); + return stringify(propValue); + } + }; + } + + // Helper method to create a custom converter for testing + private BeanConverter createCustomConverter() { + return new BeanConverter() { + @Override + public String stringify(Object o) { + if (o instanceof CustomObject) { + return "CUSTOM:" + ((CustomObject) o).getValue(); + } + return String.valueOf(o); + } + + @Override + public List<Object> listify(Object o) { + if (o instanceof List) return (List<Object>) o; + return List.of(o); + } + + @Override + public boolean canListify(Object o) { + return true; + } + + @Override + public Object swap(Object o) { + return o; + } + + @Override + public Object getProperty(Object object, String name) { + if ("custom".equals(name) && object instanceof TestBeanWithCustomObject) { + return ((TestBeanWithCustomObject) object).getCustom(); + } + return null; + } + + @Override + public <T> T getSetting(String key, T defaultValue) { + return defaultValue; + } + + @Override + public String getNested(Object o, NestedTokenizer.Token token) { + var propValue = getProperty(o, token.getValue()); + return stringify(propValue); + } + }; + } +} diff --git a/juneau-utest/src/test/java/org/apache/juneau/junit/Assertions2.java b/juneau-utest/src/test/java/org/apache/juneau/junit/Assertions2.java new file mode 100644 index 000000000..f2ba32295 --- /dev/null +++ b/juneau-utest/src/test/java/org/apache/juneau/junit/Assertions2.java @@ -0,0 +1,696 @@ +package org.apache.juneau.junit; + +import static java.util.stream.Collectors.*; +import static org.apache.juneau.junit.Utils.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import java.util.function.*; +import java.util.stream.*; + +import org.opentest4j.*; + +/** +* Comprehensive utility class for Bean-Centric Tests (BCT) and general testing operations. +* +* <p>This class provides the core testing infrastructure for Apache Juneau, with particular emphasis +* on the Bean-Centric Testing (BCT) framework. BCT enables sophisticated assertion patterns for +* testing object properties, collections, maps, and complex nested structures with minimal code.</p> +* +* <h5 class='section'>Bean-Centric Testing (BCT) Framework:</h5> +* <p>The BCT framework consists of several key components:</p> +* <ul> +* <li><b>{@link BeanConverter}:</b> Core interface for object conversion and property access</li> +* <li><b>{@link BasicBeanConverter}:</b> Default implementation with extensible type handlers</li> +* <li><b>Assertion Methods:</b> High-level testing methods that leverage the converter framework</li> +* </ul> +* +* <h5 class='section'>Primary BCT Assertion Methods:</h5> +* <dl> +* <dt><b>{@link #assertBean(Object, String, String)}</b></dt> +* <dd>Tests object properties with nested syntax support and collection iteration</dd> +* +* <dt><b>{@link #assertMap(Map, String, String)}</b></dt> +* <dd>Tests map entries with the same nested property syntax as assertBean</dd> +* +* <dt><b>{@link #assertMapped(Object, java.util.function.BiFunction, String, String)}</b></dt> +* <dd>Tests custom property access using BiFunction for non-standard objects</dd> +* +* <dt><b>{@link #assertList(List, Object...)}</b></dt> +* <dd>Tests list/collection elements with varargs for expected values</dd> +* +* <dt><b>{@link #assertBeans(Collection, String, String...)}</b></dt> +* <dd>Tests collections of objects by extracting and comparing specific fields</dd> +* </dl> +* +* <h5 class='section'>BCT Advanced Features:</h5> +* <ul> +* <li><b>Nested Property Syntax:</b> "address{street,city}" for testing nested objects</li> +* <li><b>Collection Iteration:</b> "#{property}" syntax for testing all elements</li> +* <li><b>Universal Size Properties:</b> "length" and "size" work on all collection types</li> +* <li><b>Array/List Access:</b> Numeric indices for element-specific testing</li> +* <li><b>Method Chaining:</b> Fluent setters can be tested directly</li> +* <li><b>Direct Field Access:</b> Public fields accessed without getters</li> +* <li><b>Map Key Access:</b> Including special "<NULL>" syntax for null keys</li> +* </ul> +* +* <h5 class='section'>Converter Extensibility:</h5> +* <p>The BCT framework is built on the extensible {@link BasicBeanConverter} which allows:</p> +* <ul> +* <li><b>Custom Stringifiers:</b> Type-specific string conversion logic</li> +* <li><b>Custom Listifiers:</b> Collection-type conversion for iteration</li> +* <li><b>Custom Swapifiers:</b> Object transformation before conversion</li> +* <li><b>Configurable Settings:</b> Formatting, delimiters, and display options</li> +* </ul> +* +* <h5 class='section'>Usage Examples:</h5> +* +* <p><b>Basic Property Testing:</b></p> +* <p class='bjava'> +* <jc>// Test multiple properties</jc> +* assertBean(user, <js>"name,age,active"</js>, <js>"John,30,true"</js>); +* +* <jc>// Test nested properties</jc> +* assertBean(user, <js>"address{street,city}"</js>, <js>"{123 Main St,Springfield}"</js>); +* </p> +* +* <p><b>Collection and Array Testing:</b></p> +* <p class='bjava'> +* <jc>// Test collection size and iterate over all elements</jc> +* assertBean(order, <js>"items{length,#{name}}"</js>, <js>"{3,[{Laptop},{Phone},{Tablet}]}"</js>); +* +* <jc>// Test specific array elements</jc> +* assertBean(data, <js>"values{0,1,2}"</js>, <js>"{100,200,300}"</js>); +* </p> +* +* <p><b>Map and Collection Testing:</b></p> +* <p class='bjava'> +* <jc>// Test map entries</jc> +* assertMap(config, <js>"timeout,retries"</js>, <js>"30000,3"</js>); +* +* <jc>// Test list elements</jc> +* assertList(tags, <js>"red"</js>, <js>"green"</js>, <js>"blue"</js>); +* </p> +* +* <p><b>Custom Property Access:</b></p> +* <p class='bjava'> +* <jc>// Test with custom accessor function</jc> +* assertMapped(myObject, (obj, prop) -> obj.getProperty(prop), +* <js>"prop1,prop2"</js>, <js>"value1,value2"</js>); +* </p> +* +* <h5 class='section'>Performance and Thread Safety:</h5> +* <p>The BCT framework is designed for high performance with:</p> +* <ul> +* <li><b>Caching:</b> Type-to-handler mappings cached for fast lookup</li> +* <li><b>Thread Safety:</b> All operations are thread-safe for concurrent testing</li> +* <li><b>Minimal Allocation:</b> Efficient object reuse and minimal temporary objects</li> +* </ul> +* +* @see BeanConverter +* @see BasicBeanConverter +*/ +public class Assertions2 { + + private static final BeanConverter DEFAULT_CONVERTER = BasicBeanConverter.DEFAULT; + + public static AssertionArgs args() { + return new AssertionArgs(); + } + + /** + * Asserts that the fields/properties on the specified bean are the specified values after being converted to {@link Utils#r readable} strings. + * + * <p>This is the primary method for Bean-Centric Tests (BCT), supporting extensive property validation + * patterns including nested objects, collections, arrays, method chaining, direct field access, collection iteration + * with <js>#{property}</js> syntax, and universal <js>length</js>/<js>size</js> properties for all collection types.</p> + * + * <p>The method uses the {@link BasicBeanConverter#DEFAULT} converter internally for object introspection + * and value extraction. The converter provides sophisticated property access through the {@link BeanConverter} + * interface, supporting multiple fallback mechanisms for accessing object properties and values.</p> + * + * <h5 class='section'>Basic Usage:</h5> + * <p class='bjava'> + * <jc>// Test multiple properties</jc> + * assertBean(myBean, <js>"prop1,prop2,prop3"</js>, <js>"val1,val2,val3"</js>); + * + * <jc>// Test single property</jc> + * assertBean(myBean, <js>"name"</js>, <js>"John"</js>); + * </p> + * + * <h5 class='section'>Nested Property Testing:</h5> + * <p class='bjava'> + * <jc>// Test nested bean properties</jc> + * assertBean(myBean, <js>"address{street,city,state}"</js>, <js>"{123 Main St,Springfield,IL}"</js>); + * + * <jc>// Test arbitrarily deep nesting</jc> + * assertBean(myBean, <js>"person{address{geo{lat,lon}}}"</js>, <js>"{{{{40.7,-74.0}}}}"</js>); + * </p> + * + * <h5 class='section'>Array, List, and Stream Testing:</h5> + * <p class='bjava'> + * <jc>// Test array/list elements by index</jc> + * assertBean(myBean, <js>"items{0,1,2}"</js>, <js>"{item1,item2,item3}"</js>); + * + * <jc>// Test nested properties within array elements</jc> + * assertBean(myBean, <js>"orders{0{id,total}}"</js>, <js>"{{123,99.95}}"</js>); + * + * <jc>// Test array length property</jc> + * assertBean(myBean, <js>"items{length}"</js>, <js>"{5}"</js>); + * + * <jc>// Works with any iterable type including Streams</jc> + * assertBean(myBean, <js>"userStream{#{name}}"</js>, <js>"[{Alice},{Bob}]"</js>); + * </p> + * + * <h5 class='section'>Collection Iteration Syntax:</h5> + * <p class='bjava'> + * <jc>// Test properties across ALL elements in a collection using #{...} syntax</jc> + * assertBean(myBean, <js>"userList{#{name}}"</js>, <js>"[{John},{Jane},{Bob}]"</js>); + * + * <jc>// Test multiple properties from each element</jc> + * assertBean(myBean, <js>"orderList{#{id,status}}"</js>, <js>"[{123,ACTIVE},{124,PENDING}]"</js>); + * + * <jc>// Works with nested properties within each element</jc> + * assertBean(myBean, <js>"customers{#{address{city}}}"</js>, <js>"[{{New York}},{{Los Angeles}}]"</js>); + * + * <jc>// Works with arrays and any iterable collection type (including Streams)</jc> + * assertBean(config, <js>"itemArray{#{type}}"</js>, <js>"[{String},{Integer},{Boolean}]"</js>); + * assertBean(data, <js>"statusSet{#{name}}"</js>, <js>"[{ACTIVE},{PENDING},{CANCELLED}]"</js>); + * assertBean(processor, <js>"dataStream{#{value}}"</js>, <js>"[{A},{B},{C}]"</js>); + * </p> + * + * <h5 class='section'>Universal Collection Size Properties:</h5> + * <p class='bjava'> + * <jc>// Both 'length' and 'size' work universally across all collection types</jc> + * assertBean(myBean, <js>"myArray{length}"</js>, <js>"{5}"</js>); <jc>// Arrays</jc> + * assertBean(myBean, <js>"myArray{size}"</js>, <js>"{5}"</js>); <jc>// Also works for arrays</jc> + * + * assertBean(myBean, <js>"myList{size}"</js>, <js>"{3}"</js>); <jc>// Collections</jc> + * assertBean(myBean, <js>"myList{length}"</js>, <js>"{3}"</js>); <jc>// Also works for collections</jc> + * + * assertBean(myBean, <js>"myMap{size}"</js>, <js>"{7}"</js>); <jc>// Maps</jc> + * assertBean(myBean, <js>"myMap{length}"</js>, <js>"{7}"</js>); <jc>// Also works for maps</jc> + * </p> + * + * <h5 class='section'>Class Name Testing:</h5> + * <p class='bjava'> + * <jc>// Test class properties (prefer simple names for maintainability)</jc> + * assertBean(myBean, <js>"obj{class{simpleName}}"</js>, <js>"{{MyClass}}"</js>); + * + * <jc>// Test full class names when needed</jc> + * assertBean(myBean, <js>"obj{class{name}}"</js>, <js>"{{com.example.MyClass}}"</js>); + * </p> + * + * <h5 class='section'>Method Chaining Support:</h5> + * <p class='bjava'> + * <jc>// Test fluent setter chains (returns same object)</jc> + * assertBean( + * item.setType(<js>"foo"</js>).setFormat(<js>"bar"</js>).setDefault(<js>"baz"</js>), + * <js>"type,format,default"</js>, + * <js>"foo,bar,baz"</js> + * ); + * </p> + * + * <h5 class='section'>Advanced Collection Analysis:</h5> + * <p class='bjava'> + * <jc>// Combine size/length, metadata, and content iteration in single assertions</jc> + * assertBean(myBean, <js>"users{length,class{simpleName},#{name}}"</js>, + * <js>"{3,{ArrayList},[{John},{Jane},{Bob}]}"</js>); + * + * <jc>// Comprehensive collection validation with multiple iteration patterns</jc> + * assertBean(order, <js>"items{size,#{name},#{price}}"</js>, + * <js>"{3,[{Laptop},{Phone},{Tablet}],[{999.99},{599.99},{399.99}]}"</js>); + * + * <jc>// Perfect for validation testing - verify error count and details</jc> + * assertBean(result, <js>"errors{length,#{field},#{code}}"</js>, + * <js>"{2,[{email},{password}],[{E001},{E002}]}"</js>); + * + * <jc>// Mixed collection types with consistent syntax</jc> + * assertBean(response, <js>"results{size},metadata{length}"</js>, <js>"{25},{4}"</js>); + * </p> + * + * <h5 class='section'>Direct Field Access:</h5> + * <p class='bjava'> + * <jc>// Test public fields directly (no getters required)</jc> + * assertBean(myBean, <js>"f1,f2,f3"</js>, <js>"val1,val2,val3"</js>); + * + * <jc>// Test field properties with chaining</jc> + * assertBean(myBean, <js>"f1{length},f2{class{simpleName}}"</js>, <js>"{5},{{String}}"</js>); + * </p> + * + * <h5 class='section'>Map Testing:</h5> + * <p class='bjava'> + * <jc>// Test map values by key</jc> + * assertBean(myBean, <js>"configMap{timeout,retries}"</js>, <js>"{30000,3}"</js>); + * + * <jc>// Test map size</jc> + * assertBean(myBean, <js>"settings{size}"</js>, <js>"{5}"</js>); + * + * <jc>// Test null keys using special <NULL> syntax</jc> + * assertBean(myBean, <js>"mapWithNullKey{<NULL>}"</js>, <js>"{nullKeyValue}"</js>); + * </p> + * + * <h5 class='section'>Collection and Boolean Values:</h5> + * <p class='bjava'> + * <jc>// Test boolean values</jc> + * assertBean(myBean, <js>"enabled,visible"</js>, <js>"true,false"</js>); + * + * <jc>// Test enum collections</jc> + * assertBean(myBean, <js>"statuses"</js>, <js>"[ACTIVE,PENDING]"</js>); + * </p> + * + * <h5 class='section'>Value Syntax Rules:</h5> + * <ul> + * <li><b>Simple values:</b> <js>"value"</js> for direct property values</li> + * <li><b>Nested values:</b> <js>"{value}"</js> for single-level nested properties</li> + * <li><b>Deep nested values:</b> <js>"{{value}}"</js>, <js>"{{{value}}}"</js> for multiple nesting levels</li> + * <li><b>Array/Collection values:</b> <js>"[item1,item2]"</js> for collections</li> + * <li><b>Collection iteration:</b> <js>"#{property}"</js> iterates over ALL collection elements, returns <js>"[{val1},{val2}]"</js></li> + * <li><b>Universal size properties:</b> <js>"length"</js> and <js>"size"</js> work on arrays, collections, and maps</li> + * <li><b>Boolean values:</b> <js>"true"</js>, <js>"false"</js></li> + * <li><b>Null values:</b> <js>"null"</js></li> + * </ul> + * + * <h5 class='section'>Property Access Priority:</h5> + * <ol> + * <li><b>Collection/Array access:</b> Numeric indices for arrays/lists (e.g., <js>"0"</js>, <js>"1"</js>)</li> + * <li><b>Universal size properties:</b> <js>"length"</js> and <js>"size"</js> for arrays, collections, and maps</li> + * <li><b>Map key access:</b> Direct key lookup for Map objects (including <js>"<NULL>"</js> for null keys)</li> + * <li><b>is{Property}()</b> methods (for boolean properties)</li> + * <li><b>get{Property}()</b> methods</li> + * <li><b>Public fields</b> (direct field access)</li> + * </ol> + * + * @param actual The bean object to test. Must not be null. + * @param fields Comma-delimited list of property names to test. Supports nested syntax with {}. + * @param expected Comma-delimited list of expected values. Must match the order of fields. + * @throws NullPointerException if the bean is null + * @throws AssertionError if any property values don't match expected values + * @see BeanConverter + * @see BasicBeanConverter + */ + public static void assertBean(Object actual, String fields, String expected) { + assertBean(args(), actual, fields, expected); + } + + public static void assertBean(AssertionArgs args, Object actual, String fields, String expected) { + assertNotNull(actual, "Actual was null."); + assertArgNotNull("args", args); + assertArgNotNull("fields", fields); + assertArgNotNull("expected", expected); + assertEquals( + expected, + tokenize(fields).stream().map(x -> args.getBeanConverter().orElse(DEFAULT_CONVERTER).getNested(actual, x)).collect(joining(",")), + args.getMessage("Bean assertion failed.") + ); + } + + /** + * Asserts that multiple beans in a collection have the expected property values. + * + * <p>This method validates that each bean in a collection has the specified property values, + * using the same property access logic as {@link #assertBean(Object, String, String)}. + * It's perfect for testing collections of similar objects or validation results.</p> + * + * <h5 class='section'>Basic Usage:</h5> + * <p class='bjava'> + * <jc>// Test list of user beans</jc> + * assertBeans(userList, <js>"name,age"</js>, + * <js>"John,25"</js>, <js>"Jane,30"</js>, <js>"Bob,35"</js>); + * </p> + * + * <h5 class='section'>Complex Property Testing:</h5> + * <p class='bjava'> + * <jc>// Test nested properties across multiple beans</jc> + * assertBeans(orderList, <js>"id,customer{name,email}"</js>, + * <js>"1,{John,j...@example.com}"</js>, + * <js>"2,{Jane,j...@example.com}"</js>); + * + * <jc>// Test collection properties within beans</jc> + * assertBeans(cartList, <js>"items{0{name}},total"</js>, + * <js>"{{Laptop}},999.99"</js>, + * <js>"{{Phone}},599.99"</js>); + * </p> + * + * <h5 class='section'>Validation Testing:</h5> + * <p class='bjava'> + * <jc>// Test validation results</jc> + * assertBeans(validationErrors, <js>"field,message,code"</js>, + * <js>"email,Invalid email format,E001"</js>, + * <js>"age,Must be 18 or older,E002"</js>); + * </p> + * + * <h5 class='section'>Collection Iteration Testing:</h5> + * <p class='bjava'> + * <jc>// Test collection iteration within beans (#{...} syntax)</jc> + * assertBeans(departmentList, <js>"name,employees{#{name}}"</js>, + * <js>"Engineering,[{Alice},{Bob},{Charlie}]"</js>, + * <js>"Marketing,[{David},{Eve}]"</js>); + * </p> + * + * <h5 class='section'>Parser Result Testing:</h5> + * <p class='bjava'> + * <jc>// Test parsed object collections</jc> + * var parsed = JsonParser.DEFAULT.parse(jsonArray, MyBean[].class); + * assertBeans(Arrays.asList(parsed), <js>"prop1,prop2"</js>, + * <js>"val1,val2"</js>, <js>"val3,val4"</js>); + * </p> + * + * @param listOfBeans The collection of beans to check. Must not be null. + * @param fields A comma-delimited list of bean property names (supports nested syntax). + * @param values Array of expected value strings, one per bean. Each string contains comma-delimited values matching the fields. + * @throws AssertionError if the collection size doesn't match values array length or if any bean properties don't match + * @see #assertBean(Object, String, String) + */ + public static void assertBeans(Object actual, String fields, String...expected) { + assertBeans(args(), actual, fields, expected); + } + + public static void assertBeans(AssertionArgs args, Object actual, String fields, String...expected) { + assertNotNull(actual, "Value was null."); + assertArgNotNull("args", args); + assertArgNotNull("fields", fields); + assertArgNotNull("expected", expected); + + var converter = args.getBeanConverter().orElse(DEFAULT_CONVERTER); + var tokens = tokenize(fields); + var errors = new ArrayList<AssertionFailedError>(); + var actualList = converter.listify(actual); + + if (ne(expected.length, actualList.size())) { + errors.add(assertEqualsFailed(expected.length, actualList.size(), args.getMessage("Wrong number of beans."))); + } else { + for (var i = 0; i < actualList.size(); i++) { + var i2 = i; + var e = converter.stringify(expected[i]); + var a = tokens.stream().map(x -> converter.getNested(actualList.get(i2), x)).collect(joining(",")); + if (ne(e, a)) { + errors.add(assertEqualsFailed(e, a, args.getMessage("Bean at row <{0}> did not match.", i))); + } + } + } + + if (errors.isEmpty()) return; + + var actualStrings = new ArrayList<String>(); + for (var o : actualList) { + actualStrings.add(tokens.stream().map(x -> converter.getNested(o, x)).collect(joining(","))); + } + + if (errors.size() == 1) throw errors.get(0); + + throw assertEqualsFailed( + Stream.of(expected).map(Utils::escapeForJava).collect(joining("\", \"", "\"", "\"")), + actualStrings.stream().map(Utils::escapeForJava).collect(joining("\", \"", "\"", "\"")), + args.getMessage("{0} bean assertions failed:\n{1}", errors.size(), errors.stream().map(x -> x.getMessage()).collect(joining("\n"))) + ); + } + + /** + * Asserts that mapped property access on an object returns expected values using a custom BiFunction. + * + * <p>This is the most powerful and flexible BCT method, designed for testing objects that don't follow + * standard JavaBean patterns or require custom property access logic. The BiFunction allows complete + * control over how properties are retrieved from the target object.</p> + * + * <p>When the BiFunction throws an exception, it's automatically caught and the exception's + * simple class name becomes the property value for comparison (e.g., "NullPointerException").</p> + * + * <p>This method creates an intermediate LinkedHashMap to collect all property values before + * delegating to assertMap(Map, String, String). This ensures consistent ordering + * and supports the full nested property syntax. The {@link BasicBeanConverter#DEFAULT} is used + * for value stringification and nested property access.</p> + * + * @param <T> The type of object being tested + * @param actual The object to test properties on + * @param function The BiFunction that extracts property values. Receives (object, propertyName) and returns the property value. + * @param properties Comma-delimited list of property names to test + * @param expected Comma-delimited list of expected values (exceptions become simple class names) + * @throws AssertionError if any mapped property values don't match expected values + * @see #assertBean(Object, String, String) + * @see #assertMap(Map, String, String) + * @see BeanConverter + * @see BasicBeanConverter + */ + public static <T> void assertMapped(T actual, BiFunction<T,String,Object> function, String properties, String expected) { + assertMapped(args(), actual, function, properties, expected); + } + + public static <T> void assertMapped(AssertionArgs args, T actual, BiFunction<T,String,Object> function, String properties, String expected) { + assertNotNull(actual, "Value was null."); + assertArgNotNull("args", args); + assertArgNotNull("function", function); + assertArgNotNull("properties", properties); + assertArgNotNull("expected", expected); + + var m = new LinkedHashMap<String,Object>(); + for (var p : tokenize(properties)) { + var pv = p.getValue(); + m.put(pv, safe(() -> function.apply(actual, pv))); + } + + assertBean(args, m, properties, expected); + } + + /** + * Asserts an object matches the expected string after it's been made {@link Utils#r readable}. + */ + public static void assertContains(String expected, Object actual) { + assertContains(args(), expected, actual); + } + + public static void assertContains(AssertionArgs args, String expected, Object actual) { + assertArgNotNull("args", args); + assertArgNotNull("expected", expected); + assertArgNotNull("actual", actual); + assertNotNull(actual, "Value was null."); + + var a = args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(actual); + assertTrue(a.contains(expected), args.getMessage("String did not contain expected substring. ==> expected: <{0}> but was: <{1}>", expected, a)); + } + + /** + * Similar to {@link #assertContains(String, Object)} but allows the expected to be a comma-delimited list of strings that + * all must match. + * @param expected + * @param actual + */ + public static void assertContainsAll(Object actual, String...expected) { + assertContainsAll(args(), actual, expected); + } + + public static void assertContainsAll(AssertionArgs args, Object actual, String...expected) { + assertArgNotNull("args", args); + assertArgNotNull("expected", expected); + assertNotNull(actual, "Value was null."); + + var a = args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(actual); + for (var e : expected) + assertTrue(a.contains(e), args.getMessage("String did not contain expected substring. ==> expected: <{0}> but was: <{1}>", e, a)); + } + + /** + * Asserts that a collection is not null and empty. + */ + public static void assertEmpty(Object value) { + assertEmpty(args(), value); + } + + public static void assertEmpty(AssertionArgs args, Object value) { + assertArgNotNull("args", args); + assertNotNull(value, "Value was null."); + + if (value instanceof Optional v2) { + assertTrue(v2.isEmpty(), "Optional was not empty"); + return; + } + if (value instanceof Map v2) { + assertTrue(v2.isEmpty(), "Map was not empty"); + return; + } + + var converter = args.getBeanConverter().orElse(DEFAULT_CONVERTER); + + assertTrue(converter.canListify(value), args.getMessage("Value cannot be converted to a list. Class=<{0}>", value.getClass().getSimpleName())); + assertTrue(converter.listify(value).isEmpty(), args.getMessage("Value was not empty.")); + } + + /** + * Asserts that a List contains the expected values using flexible comparison logic. + * + * <p>This is the primary method for testing all collection-like types. For non-List collections, use + * {@link #l(Object)} to convert them to Lists first. This unified approach eliminates the need for + * separate assertion methods for arrays, sets, and other collection types.</p> + * + * <h5 class='section'>Testing Non-List Collections:</h5> + * <p class='bjava'> + * <jc>// Test a Set using l() conversion</jc> + * Set<String> <jv>mySet</jv> = Set.of(<js>"a"</js>, <js>"b"</js>, <js>"c"</js>); + * assertList(l(<jv>mySet</jv>), <js>"a"</js>, <js>"b"</js>, <js>"c"</js>); + * + * <jc>// Test an array using l() conversion</jc> + * String[] <jv>myArray</jv> = {<js>"x"</js>, <js>"y"</js>, <js>"z"</js>}; + * assertList(l(<jv>myArray</jv>), <js>"x"</js>, <js>"y"</js>, <js>"z"</js>); + * + * <jc>// Test a Stream using l() conversion</jc> + * Stream<String> <jv>myStream</jv> = Stream.of(<js>"foo"</js>, <js>"bar"</js>); + * assertList(l(<jv>myStream</jv>), <js>"foo"</js>, <js>"bar"</js>); + * </p> + * + * <h5 class='section'>Comparison Modes:</h5> + * <p>The method supports three different ways to compare expected vs actual values:</p> + * + * <h6 class='section'>1. String Comparison (Readable Format):</h6> + * <p class='bjava'> + * <jc>// Elements are converted to {@link Utils#r readable} format and compared as strings</jc> + * assertList(List.of(1, 2, 3), <js>"1"</js>, <js>"2"</js>, <js>"3"</js>); + * assertList(List.of("a", "b"), <js>"a"</js>, <js>"b"</js>); + * </p> + * + * <h6 class='section'>2. Predicate Testing (Functional Validation):</h6> + * <p class='bjava'> + * <jc>// Use Predicate<T> for functional testing</jc> + * Predicate<Integer> <jv>greaterThanOne</jv> = <jv>x</jv> -> <jv>x</jv> > 1; + * assertList(List.of(2, 3, 4), <jv>greaterThanOne</jv>, <jv>greaterThanOne</jv>, <jv>greaterThanOne</jv>); + * + * <jc>// Mix predicates with other comparison types</jc> + * Predicate<String> <jv>startsWithA</jv> = <jv>s</jv> -> <jv>s</jv>.startsWith(<js>"a"</js>); + * assertList(List.of(<js>"apple"</js>, <js>"banana"</js>), <jv>startsWithA</jv>, <js>"banana"</js>); + * </p> + * + * <h6 class='section'>3. Object Equality (Direct Comparison):</h6> + * <p class='bjava'> + * <jc>// Non-String, non-Predicate objects use Objects.equals() comparison</jc> + * assertList(List.of(1, 2, 3), 1, 2, 3); <jc>// Integer objects</jc> + * assertList(List.of(<jv>myBean1</jv>, <jv>myBean2</jv>), <jv>myBean1</jv>, <jv>myBean2</jv>); <jc>// Custom objects</jc> + * </p> + * + * @param actual The List to test. Must not be null. For other collection types, use {@link #l(Object)} to convert first. + * @param expected Multiple arguments of expected values. + * Can be Strings (readable format comparison), Predicates (functional testing), or Objects (direct equality). + * @throws AssertionError if the List size or contents don't match expected values + * @see #l(Object) for converting other collection types to Lists + */ + public static <T> void assertList(Object actual, Object...expected) { + assertList(args(), actual, expected); + } + + @SuppressWarnings("unchecked") + public static <T> void assertList(AssertionArgs args, Object actual, Object...expected) { + assertArgNotNull("args", args); + assertArgNotNull("expected", expected); + assertNotNull(actual, "Value was null."); + + var converter = args.getBeanConverter().orElse(DEFAULT_CONVERTER); + var list = converter.listify(actual); + assertEquals(expected.length, list.size(), args.getMessage("Wrong list length.")); + + for (var i = 0; i < expected.length; i++) { + var x = list.get(i); + var e = expected[i]; + if (e instanceof String e2) { + assertEquals(e2, converter.stringify(x), args.getMessage("Element at index {0} did not match.", i)); + } else if (e instanceof Predicate e2) { + assertTrue(e2.test(x), args.getMessage("Element at index {0} did pass predicate. ==> actual: <{0}>", i, converter.stringify(x))); + } else { + assertEquals(e, x, args.getMessage("Element at index {0} did not match. ==> expected: <{1}({2})> but was: <{3}(4)>", i, e, t(e), x, t(x))); + } + } + } + + /** + * Asserts that a collection is not null and not empty. + */ + public static void assertNotEmpty(Object value) { + assertNotEmpty(args(), value); + } + + public static void assertNotEmpty(AssertionArgs args, Object value) { + assertArgNotNull("args", args); + assertNotNull(value, "Value was null."); + + if (value instanceof Optional v2) { + assertFalse(v2.isEmpty(), "Optional was empty"); + return; + } + if (value instanceof Map v2) { + assertFalse(v2.isEmpty(), "Map was empty"); + return; + } + + assertFalse(args.getBeanConverter().orElse(DEFAULT_CONVERTER).listify(value).isEmpty(), args.getMessage("Value was empty.")); + } + + /** + * Asserts that a collection-like object or string is not null and of the specified size. + * + * <p>This method can validate the size of various types of objects:</p> + * <ul> + * <li><b>String:</b> Validates character length</li> + * <li><b>Collection-like objects:</b> Any object that can be converted to a List via {@link #toList(Object)}</li> + * </ul> + * + * <h5 class='section'>Usage Examples:</h5> + * <p class='bjava'> + * <jc>// Test string length</jc> + * assertSize(5, <js>"hello"</js>); + * + * <jc>// Test collection size</jc> + * assertSize(3, List.of(<js>"a"</js>, <js>"b"</js>, <js>"c"</js>)); + * + * <jc>// Test array size</jc> + * assertSize(2, <jk>new</jk> String[]{<js>"x"</js>, <js>"y"</js>}); + * </p> + * + * @param expected The expected size/length. + * @param actual The object to test. Must not be null. + * @throws AssertionError if the object is null or not the expected size. + */ + public static void assertSize(int expected, Object actual) { + assertSize(args(), expected, actual); + } + + public static void assertSize(AssertionArgs args, int expected, Object actual) { + assertArgNotNull("args", args); + assertNotNull(actual, "Value was null."); + + if (actual instanceof String a) { + assertEquals(expected, a.length(), args.getMessage("Value not expected size. value: <{0}>", a)); + return; + } + + var size = args.getBeanConverter().orElse(DEFAULT_CONVERTER).listify(actual).size(); + assertEquals(expected, size, args.getMessage("Value not expected size.")); + } + + /** + * Asserts an object matches the expected string after it's been made {@link Utils#r readable}. + */ + public static void assertString(String expected, Object actual) { + assertString(args(), expected, actual); + } + + public static void assertString(AssertionArgs args, String expected, Object actual) { + assertArgNotNull("args", args); + assertNotNull(actual, "Value was null."); + + assertEquals(expected, args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(actual), args.getMessage()); + } + + /** + * Asserts value when stringified matches the specified pattern. + */ + public static void assertMatches(String pattern, Object value) { + assertMatches(args(), pattern, value); + } + + public static void assertMatches(AssertionArgs args, String pattern, Object value) { + assertArgNotNull("args", args); + assertArgNotNull("pattern", pattern); + assertNotNull(value, "Value was null."); + + var v = args.getBeanConverter().orElse(DEFAULT_CONVERTER).stringify(value); + var m = getMatchPattern3(pattern).matcher(v); + assertTrue(m.matches(), args.getMessage("Pattern didn't match. ==> pattern: <{0}> but was: <{1}>", pattern, v)); + } +} diff --git a/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java b/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java index 9b29b735c..c1e057456 100644 --- a/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java +++ b/juneau-utest/src/test/java/org/apache/juneau/junit/Utils.java @@ -12,10 +12,15 @@ // *************************************************************************************************************************** package org.apache.juneau.junit; +import static java.util.Optional.*; + import java.lang.reflect.*; import java.text.*; import java.util.*; import java.util.function.*; +import java.util.regex.*; + +import org.opentest4j.*; public class Utils { public static <T> T safe(ThrowingSupplier<T> s) { @@ -32,6 +37,14 @@ public class Utils { return args.length == 0 ? msg : MessageFormat.format(msg, args); } + public static Supplier<String> fs(String msg, Object...args) { + return ()->f(msg, args); + } + + public static String t(Object o) { + return o == null ? null : o.getClass().getSimpleName(); + } + public static List<Object> arrayToList(Object o) { var l = new ArrayList<>(); for (var i = 0; i < Array.getLength(o); i++) @@ -46,8 +59,105 @@ public class Utils { return test.test(o1, o2); } - @SuppressWarnings("unlikely-arg-type") - public static <T,U> boolean eq(T o1, U o2) { + public static <T> boolean eq(T o1, T o2) { return Objects.equals(o1, o2); } + + public static <T> boolean ne(T o1, T o2) { + return ! eq(o1, o2); + } + + /** + * Throws an {@link IllegalArgumentException} if the specified argument is <jk>null</jk>. + * + * <h5 class='section'>Example:</h5> + * <p class='bjava'> + * <jk>import static</jk> org.apache.juneau.internal.ArgUtils.*; + * + * <jk>public</jk> String setFoo(String <jv>foo</jv>) { + * <jsm>assertArgNotNull</jsm>(<js>"foo"</js>, <jv>foo</jv>); + * ... + * } + * </p> + * + * @param <T> The argument data type. + * @param name The argument name. + * @param o The object to check. + * @return The same argument. + * @throws IllegalArgumentException Constructed exception. + */ + public static final <T> T assertArgNotNull(String name, T o) throws IllegalArgumentException { + assertArg(o != null, "Argument ''{0}'' cannot be null.", name); + return o; + } + + public static final void assertArg(boolean expression, String msg, Object...args) throws IllegalArgumentException { + if (! expression) + throw new IllegalArgumentException(MessageFormat.format(msg, args)); + } + + /** + * Converts a string containing <js>"*"</js> meta characters with a regular expression pattern. + * + * @param s The string to create a pattern from. + * @return A regular expression pattern. + */ + public static Pattern getMatchPattern3(String s) { + return getMatchPattern3(s, 0); + } + + /** + * Converts a string containing <js>"*"</js> meta characters with a regular expression pattern. + * + * @param s The string to create a pattern from. + * @param flags Regular expression flags. + * @return A regular expression pattern. + */ + public static Pattern getMatchPattern3(String s, int flags) { + if (s == null) + return null; + var sb = new StringBuilder(); + sb.append("\\Q"); + for (var i = 0; i < s.length(); i++) { + var c = s.charAt(i); + if (c == '*') + sb.append("\\E").append(".*").append("\\Q"); + else if (c == '?') + sb.append("\\E").append(".").append("\\Q"); + else + sb.append(c); + } + sb.append("\\E"); + return Pattern.compile(sb.toString(), flags); + } + + public static List<NestedTokenizer.Token> tokenize(String fields) { + return NestedTokenizer.tokenize(fields); + } + + public static AssertionFailedError assertEqualsFailed(Object expected, Object actual, Supplier<String> messageSupplier) { + return new AssertionFailedError(ofNullable(messageSupplier).map(x -> x.get()).orElse("Equals assertion failed.") + f(" ==> expected: <{0}> but was: <{1}>", expected, actual), expected, actual); + } + + public static String escapeForJava(String s) { + var sb = new StringBuilder(); + for (var c : s.toCharArray()) { + switch (c) { + case '\"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + case '\f': sb.append("\\f"); break; + case '\b': sb.append("\\b"); break; + default: + if (c < 0x20 || c > 0x7E) { + sb.append(String.format("\\u%04x", (int)c)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } } diff --git a/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java b/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java index cbd4dc43f..c04054797 100755 --- a/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java +++ b/juneau-utest/src/test/java/org/apache/juneau/objecttools/ObjectRest_Test.java @@ -16,6 +16,7 @@ import static org.apache.juneau.TestUtils.*; import static org.junit.jupiter.api.Assertions.*; import java.util.*; +import java.util.function.*; import org.apache.juneau.*; import org.apache.juneau.annotation.*; @@ -362,7 +363,15 @@ class ObjectRest_Test extends SimpleTestBase { "f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8", "true,false,false,false,true,true,true,true,true,true,true"); - assertMapped(model, ObjectRest::getMap, + BiFunction<ObjectRest,String,Object> f1 = (r,p) -> { + try { + return r.getMap(p); + } catch (Exception e) { + return e.getClass().getSimpleName(); + } + }; + + assertMapped(model, f1, "f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8", "<null>,InvalidDataConversionException,InvalidDataConversionException,InvalidDataConversionException,<null>,<null>,<null>,<null>,<null>,<null>,<null>"); @@ -378,15 +387,39 @@ class ObjectRest_Test extends SimpleTestBase { assertEquals("{a:'b'}", model.getMap("f7", m).toString()); assertEquals("{a:'b'}", model.getMap("f8", m).toString()); - assertMapped(model, (r,p) -> r.getMap(p, m), + BiFunction<ObjectRest,String,Object> f2 = (r,p) -> { + try { + return r.getMap(p, m); + } catch (Exception e) { + return e.getClass().getSimpleName(); + } + }; + + assertMapped(model, f2, "f1,f2,f2a,f3,f3a,f4,f4a,f5,f6,f7,f8", "{a=b},InvalidDataConversionException,{a=b},InvalidDataConversionException,{a=b},InvalidDataConversionException,{a=b},{a=b},{a=b},{a=b},{a=b}"); - assertMapped(model, ObjectRest::getJsonMap, + BiFunction<ObjectRest,String,Object> f3 = (r,p) -> { + try { + return r.getJsonMap(p); + } catch (Exception e) { + return e.getClass().getSimpleName(); + } + }; + + assertMapped(model, f3, "f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8", "<null>,InvalidDataConversionException,InvalidDataConversionException,InvalidDataConversionException,<null>,<null>,<null>,<null>,<null>,<null>,<null>"); - assertMapped(model, (r,p) -> r.getJsonMap(p, m), + BiFunction<ObjectRest,String,Object> f4 = (r,p) -> { + try { + return r.getJsonMap(p, m); + } catch (Exception e) { + return e.getClass().getSimpleName(); + } + }; + + assertMapped(model, f4, "f1,f2,f3,f4,f2a,f3a,f4a,f5,f6,f7,f8", "{a=b},InvalidDataConversionException,InvalidDataConversionException,InvalidDataConversionException,{a=b},{a=b},{a=b},{a=b},{a=b},{a=b},{a=b}");