diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java index 80376859b3da..131a1fe63a81 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/aggregator/DefaultArgumentsAccessor.java @@ -21,6 +21,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.converter.DefaultArgumentConverter; +import org.junit.platform.commons.support.conversion.TypeDescriptor; import org.junit.platform.commons.util.ClassUtils; import org.junit.platform.commons.util.Preconditions; @@ -47,7 +48,7 @@ public static DefaultArgumentsAccessor create(ExtensionContext context, int invo BiFunction<@Nullable Object, Class, @Nullable Object> converter = (source, targetType) -> new DefaultArgumentConverter(context) // - .convert(source, targetType, classLoader); + .convert(source, TypeDescriptor.forClass(targetType), classLoader); return new DefaultArgumentsAccessor(converter, invocationIndex, arguments); } diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java index 46c29d6a9cc0..e05029c6012d 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/converter/DefaultArgumentConverter.java @@ -30,6 +30,7 @@ import org.junit.jupiter.params.support.FieldContext; import org.junit.platform.commons.support.conversion.ConversionException; import org.junit.platform.commons.support.conversion.ConversionSupport; +import org.junit.platform.commons.support.conversion.TypeDescriptor; import org.junit.platform.commons.util.ReflectionUtils; /** @@ -43,7 +44,7 @@ * {@link File}, {@link BigDecimal}, {@link BigInteger}, {@link Currency}, * {@link Locale}, {@link URI}, {@link URL}, {@link UUID}, etc. * - *

If the source and target types are identical the source object will not + *

If the source and target types are identical, the source object will not * be modified. * * @since 5.0 @@ -82,48 +83,42 @@ public DefaultArgumentConverter(ExtensionContext context) { @Override public final @Nullable Object convert(@Nullable Object source, ParameterContext context) { - Class targetType = context.getParameter().getType(); ClassLoader classLoader = getClassLoader(context.getDeclaringExecutable().getDeclaringClass()); - return convert(source, targetType, classLoader); + return convert(source, TypeDescriptor.forParameter(context.getParameter()), classLoader); } @Override public final @Nullable Object convert(@Nullable Object source, FieldContext context) throws ArgumentConversionException { - - Class targetType = context.getField().getType(); ClassLoader classLoader = getClassLoader(context.getField().getDeclaringClass()); - return convert(source, targetType, classLoader); + return convert(source, TypeDescriptor.forField(context.getField()), classLoader); } - public final @Nullable Object convert(@Nullable Object source, Class targetType, ClassLoader classLoader) { + public final @Nullable Object convert(@Nullable Object source, TypeDescriptor targetType, ClassLoader classLoader) { if (source == null) { if (targetType.isPrimitive()) { throw new ArgumentConversionException( - "Cannot convert null to primitive value of type " + targetType.getTypeName()); + "Cannot convert null to primitive value of type " + targetType.getType().getTypeName()); } return null; } - if (ReflectionUtils.isAssignableTo(source, targetType)) { + if (ReflectionUtils.isAssignableTo(source, targetType.getType())) { return source; } - if (source instanceof String string) { - if (targetType == Locale.class && getLocaleConversionFormat() == LocaleConversionFormat.BCP_47) { - return Locale.forLanguageTag(string); - } - - try { - return convert(string, targetType, classLoader); - } - catch (ConversionException ex) { - throw new ArgumentConversionException(ex.getMessage(), ex); - } + if (source instanceof String // + && targetType.getType() == Locale.class // + && getLocaleConversionFormat() == LocaleConversionFormat.BCP_47) { + return Locale.forLanguageTag((String) source); } - throw new ArgumentConversionException("No built-in converter for source type %s and target type %s".formatted( - source.getClass().getTypeName(), targetType.getTypeName())); + try { + return delegateConversion(source, targetType, classLoader); + } + catch (ConversionException ex) { + throw new ArgumentConversionException(ex.getMessage(), ex); + } } private LocaleConversionFormat getLocaleConversionFormat() { @@ -132,7 +127,7 @@ private LocaleConversionFormat getLocaleConversionFormat() { } @Nullable - Object convert(@Nullable String source, Class targetType, ClassLoader classLoader) { + Object delegateConversion(@Nullable Object source, TypeDescriptor targetType, ClassLoader classLoader) { return ConversionSupport.convert(source, targetType, classLoader); } diff --git a/junit-platform-commons/src/main/java/module-info.java b/junit-platform-commons/src/main/java/module-info.java index 092c654cdd51..6b156c764c27 100644 --- a/junit-platform-commons/src/main/java/module-info.java +++ b/junit-platform-commons/src/main/java/module-info.java @@ -58,5 +58,6 @@ org.junit.platform.suite.engine, org.junit.platform.testkit, org.junit.vintage.engine; + uses org.junit.platform.commons.support.conversion.Converter; uses org.junit.platform.commons.support.scanning.ClasspathScanner; } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionContext.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionContext.java new file mode 100644 index 000000000000..75eb57a90879 --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionContext.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.commons.support.conversion; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; + +/** + * {@code ConversionContext} encapsulates the context in which the + * current conversion is being executed. + * + *

{@link Converter Converters} are provided an instance of + * {@code ConversionContext} to perform their work. + * + * @param sourceType + * @param targetType + * @param classLoader + * + * @since 6.0 + * @see Converter + */ +@API(status = EXPERIMENTAL, since = "6.0") +public record ConversionContext(TypeDescriptor sourceType, TypeDescriptor targetType, ClassLoader classLoader) { + + /** + * + * @param source + * @param targetType + * @param classLoader + */ + public ConversionContext(@Nullable Object source, TypeDescriptor targetType, ClassLoader classLoader) { + this(TypeDescriptor.forInstance(source), targetType, classLoader); + } + +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java index fe3923f9cb4d..e43facc09dd9 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/ConversionSupport.java @@ -10,15 +10,15 @@ package org.junit.platform.commons.support.conversion; +import static org.apiguardian.api.API.Status.DEPRECATED; import static org.apiguardian.api.API.Status.EXPERIMENTAL; -import static org.junit.platform.commons.util.ReflectionUtils.getWrapperType; -import java.util.List; -import java.util.Optional; +import java.util.ServiceLoader; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; -import org.junit.platform.commons.util.ClassLoaderUtils; /** * {@code ConversionSupport} provides static utility methods for converting a @@ -29,17 +29,6 @@ @API(status = EXPERIMENTAL, since = "1.11") public final class ConversionSupport { - private static final List stringToObjectConverters = List.of( // - new StringToBooleanConverter(), // - new StringToCharacterConverter(), // - new StringToNumberConverter(), // - new StringToClassConverter(), // - new StringToEnumConverter(), // - new StringToJavaTimeConverter(), // - new StringToCommonJavaTypesConverter(), // - new FallbackStringToObjectConverter() // - ); - private ConversionSupport() { /* no-op */ } @@ -48,43 +37,6 @@ private ConversionSupport() { * Convert the supplied source {@code String} into an instance of the specified * target type. * - *

If the target type is {@code String}, the source {@code String} will not - * be modified. - * - *

Some forms of conversion require a {@link ClassLoader}. If none is - * provided, the {@linkplain ClassLoaderUtils#getDefaultClassLoader() default - * ClassLoader} will be used. - * - *

This method is able to convert strings into primitive types and their - * corresponding wrapper types ({@link Boolean}, {@link Character}, {@link Byte}, - * {@link Short}, {@link Integer}, {@link Long}, {@link Float}, and - * {@link Double}), enum constants, date and time types from the - * {@code java.time} package, as well as common Java types such as {@link Class}, - * {@link java.io.File}, {@link java.nio.file.Path}, {@link java.nio.charset.Charset}, - * {@link java.math.BigDecimal}, {@link java.math.BigInteger}, - * {@link java.util.Currency}, {@link java.util.Locale}, {@link java.util.UUID}, - * {@link java.net.URI}, and {@link java.net.URL}. - * - *

If the target type is not covered by any of the above, a convention-based - * conversion strategy will be used to convert the source {@code String} into the - * given target type by invoking a static factory method or factory constructor - * defined in the target type. The search algorithm used in this strategy is - * outlined below. - * - *

Search Algorithm

- * - *
    - *
  1. Search for a single, non-private static factory method in the target - * type that converts from a String to the target type. Use the factory method - * if present.
  2. - *
  3. Search for a single, non-private constructor in the target type that - * accepts a String. Use the constructor if present.
  4. - *
- * - *

If multiple suitable factory methods are discovered they will be ignored. - * If neither a single factory method nor a single constructor is found, the - * convention-based conversion strategy will not apply. - * * @param source the source {@code String} to convert; may be {@code null} * but only if the target type is a reference type * @param targetType the target type the source should be converted into; @@ -96,49 +48,49 @@ private ConversionSupport() { * type is a reference type * * @since 1.11 + * @see DefaultConverter + * @deprecated Use {@link #convert(Object, TypeDescriptor, ClassLoader)} instead. */ - @SuppressWarnings("unchecked") + @Deprecated + @API(status = DEPRECATED, since = "6.0") public static @Nullable T convert(@Nullable String source, Class targetType, @Nullable ClassLoader classLoader) { - if (source == null) { - if (targetType.isPrimitive()) { - throw new ConversionException( - "Cannot convert null to primitive value of type " + targetType.getTypeName()); - } - return null; - } - - if (String.class.equals(targetType)) { - return (T) source; - } + return convert(source, TypeDescriptor.forClass(targetType), classLoader); + } - Class targetTypeToUse = toWrapperType(targetType); - Optional converter = stringToObjectConverters.stream().filter( - candidate -> candidate.canConvertTo(targetTypeToUse)).findFirst(); - if (converter.isPresent()) { - try { - ClassLoader classLoaderToUse = classLoader != null ? classLoader - : ClassLoaderUtils.getDefaultClassLoader(); - return (T) converter.get().convert(source, targetTypeToUse, classLoaderToUse); - } - catch (Exception ex) { - if (ex instanceof ConversionException conversionException) { - // simply rethrow it - throw conversionException; - } - // else - throw new ConversionException( - "Failed to convert String \"%s\" to type %s".formatted(source, targetType.getTypeName()), ex); - } - } + /** + * Convert the supplied source object into an instance of the specified + * target type. + * + * @param source the source object to convert; may be {@code null} + * but only if the target type is a reference type + * @param targetType the target type the source should be converted into; + * never {@code null} + * @param classLoader the {@code ClassLoader} to use; may be {@code null} to + * use the default {@code ClassLoader} + * @param the type of the target + * @return the converted object; may be {@code null} but only if the target + * type is a reference type + * + * @since 6.0 + */ + @API(status = EXPERIMENTAL, since = "6.0") + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static @Nullable T convert(@Nullable Object source, TypeDescriptor targetType, + @Nullable ClassLoader classLoader) { + ConversionContext context = new ConversionContext(source, targetType, classLoader); + ServiceLoader serviceLoader = ServiceLoader.load(Converter.class, context.classLoader()); - throw new ConversionException( - "No built-in converter for source type java.lang.String and target type " + targetType.getTypeName()); - } + Converter converter = Stream.concat( // + StreamSupport.stream(serviceLoader.spliterator(), false), // + Stream.of(DefaultConverter.INSTANCE)) // + .filter(candidate -> candidate.canConvert(context)) // + .findFirst() // + .orElseThrow(() -> new ConversionException( + "No registered or built-in converter for source '%s' and target type %s".formatted( // + source, targetType.getTypeName()))); - private static Class toWrapperType(Class targetType) { - Class wrapperType = getWrapperType(targetType); - return wrapperType != null ? wrapperType : targetType; + return (T) converter.convert(source, context); } } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/Converter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/Converter.java new file mode 100644 index 000000000000..08f673d036ca --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/Converter.java @@ -0,0 +1,65 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.commons.support.conversion; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; + +/** + * {@code Converter} is an abstraction that allows an input object to + * be converted to an instance of a different class. + * + *

Implementations are loaded via the {@link java.util.ServiceLoader} and must + * follow the service provider requirements. They should not make any assumptions + * regarding when they are instantiated or how often they are called. Since + * instances may potentially be cached and called from different threads, they + * should be thread-safe. + * + *

Extend {@link TypedConverter} if your implementation always converts + * from a given source type into a given target type and does not need access to + * the {@link ClassLoader} to perform the conversion. + * + * @param + * @param + * + * @since 6.0 + * @see ConversionSupport + * @see TypedConverter + */ +@API(status = EXPERIMENTAL, since = "6.0") +public interface Converter { + + /** + * Determine if the supplied conversion context is supported. + * + * @param context the context for the conversion; never {@code null} + * @return {@code true} if the conversion is supported + */ + boolean canConvert(ConversionContext context); + + /** + * Convert the supplied source object according to the supplied conversion context. + *

This method will only be invoked if {@link #canConvert(ConversionContext)} + * returned {@code true} for the same context. + * + * @param source the source object to convert; may be {@code null} + * but only if the target type is a reference type + * @param context the context for the conversion; never {@code null} + * @return the converted object; may be {@code null} but only if the target + * type is a reference type + * @throws ConversionException if an error occurs during the conversion + */ + @Nullable + T convert(@Nullable S source, ConversionContext context) throws ConversionException; + +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/DefaultConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/DefaultConverter.java new file mode 100644 index 000000000000..fac33e7736fc --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/DefaultConverter.java @@ -0,0 +1,166 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.commons.support.conversion; + +import static org.apiguardian.api.API.Status.INTERNAL; + +import java.io.File; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URI; +import java.net.URL; +import java.util.Currency; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.util.ClassLoaderUtils; + +/** + * {@code DefaultConversionService} is the default implementation of the + * {@link Converter} API. + * + *

The {@code DefaultConversionService} is able to convert from strings to a + * number of primitive types and their corresponding wrapper types (Byte, Short, + * Integer, Long, Float, and Double), date and time types from the + * {@code java.time} package, and some additional common Java types such as + * {@link File}, {@link BigDecimal}, {@link BigInteger}, {@link Currency}, + * {@link Locale}, {@link URI}, {@link URL}, {@link UUID}, etc. + * + *

If the source and target types are identical, the source object will not + * be modified. + * + * @since 6.0 + */ +@API(status = INTERNAL, since = "6.0") +public class DefaultConverter implements Converter { + + static final DefaultConverter INSTANCE = new DefaultConverter(); + + private static final List> stringToObjectConverters = List.of(new StringToBooleanConverter(), // + new StringToCharacterConverter(), // + new StringToNumberConverter(), // + new StringToClassConverter(), // + new StringToEnumConverter(), // + new StringToJavaTimeConverter(), // + new StringToCommonJavaTypesConverter(), // + new FallbackStringToObjectConverter() // + ); + + private DefaultConverter() { + // nothing to initialize + } + + /** + * Determine if the supplied conversion context is supported. + *

FIXME add more content from {@link Converter#convert} about the conversion algorithm + * + * @param context the context for the conversion; never {@code null} + * @return {@code true} if the conversion is supported + */ + @Override + public boolean canConvert(ConversionContext context) { + if (context.sourceType() == TypeDescriptor.NONE) { + return !context.targetType().isPrimitive(); + } + + if (!(String.class.equals(context.sourceType().getType()))) { + return false; + } + + if (String.class.equals(context.targetType().getType())) { + return true; + } + + return stringToObjectConverters.stream().anyMatch(candidate -> candidate.canConvert(context)); + } + + /** + * Convert the supplied source {@link String} into an instance of the specified + * target type. + *

If the target type is {@code String}, the source {@code String} will not + * be modified. + *

Some forms of conversion require a {@link ClassLoader}. If none is + * provided, the {@linkplain ClassLoaderUtils#getDefaultClassLoader() default + * ClassLoader} will be used. + *

This method is able to convert strings into primitive types and their + * corresponding wrapper types ({@link Boolean}, {@link Character}, {@link Byte}, + * {@link Short}, {@link Integer}, {@link Long}, {@link Float}, and + * {@link Double}), enum constants, date and time types from the + * {@code java.time} package, as well as common Java types such as {@link Class}, + * {@link java.io.File}, {@link java.nio.file.Path}, {@link java.nio.charset.Charset}, + * {@link java.math.BigDecimal}, {@link java.math.BigInteger}, + * {@link java.util.Currency}, {@link java.util.Locale}, {@link java.util.UUID}, + * {@link java.net.URI}, and {@link java.net.URL}. + *

If the target type is not covered by any of the above, a convention-based + * conversion strategy will be used to convert the source {@code String} into the + * given target type by invoking a static factory method or factory constructor + * defined in the target type. The search algorithm used in this strategy is + * outlined below. + *

Search Algorithm

+ *
    + *
  1. Search for a single, non-private static factory method in the target + * type that converts from a String to the target type. Use the factory method + * if present.
  2. + *
  3. Search for a single, non-private constructor in the target type that + * accepts a String. Use the constructor if present.
  4. + *
+ *

If multiple suitable factory methods are discovered, they will be ignored. + * If neither a single factory method nor a single constructor is found, the + * convention-based conversion strategy will not apply. + * + * @param source the source {@link String} to convert; may be {@code null} + * but only if the target type is a reference type + * @param context the context for the conversion; never {@code null} + * @return the converted object; may be {@code null} but only if the target + * type is a reference type + * @throws ConversionException if an error occurs during the conversion + */ + @Override + public @Nullable Object convert(@Nullable String source, ConversionContext context) throws ConversionException { + if (source == null) { + if (context.targetType().isPrimitive()) { + throw new ConversionException( + "Cannot convert null to primitive value of type " + context.targetType().getTypeName()); + } + return null; + } + + if (String.class.equals(context.targetType().getType())) { + return source; + } + + Optional> converter = stringToObjectConverters.stream().filter( + candidate -> candidate.canConvert(context)).findFirst(); + if (converter.isPresent()) { + try { + return converter.get().convert(source, context); + } + catch (Exception ex) { + if (ex instanceof ConversionException) { + // simply rethrow it + throw (ConversionException) ex; + } + // else + throw new ConversionException( + "Failed to convert String \"%s\" to type %s".formatted(source, context.targetType().getTypeName()), + ex); + } + } + + throw new ConversionException("No built-in converter for source type java.lang.String and target type " + + context.targetType().getTypeName()); + } + +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverter.java index 916406e3fbcb..2a4949f15875 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverter.java @@ -30,7 +30,7 @@ import org.junit.platform.commons.util.Preconditions; /** - * {@code FallbackStringToObjectConverter} is a {@link StringToObjectConverter} + * {@code FallbackStringToObjectConverter} is a {@link StringToTargetTypeConverter} * that provides a fallback conversion strategy for converting from a * {@link String} to a given target type by invoking a static factory method * or factory constructor defined in the target type. @@ -52,7 +52,7 @@ * @since 1.11 * @see ConversionSupport */ -class FallbackStringToObjectConverter implements StringToObjectConverter { +class FallbackStringToObjectConverter extends StringToTargetTypeConverter { /** * Implementation of the NULL Object Pattern. @@ -71,12 +71,12 @@ class FallbackStringToObjectConverter implements StringToObjectConverter { = new ConcurrentHashMap<>(64); @Override - public boolean canConvertTo(Class targetType) { + boolean canConvert(Class targetType) { return findFactoryExecutable(targetType) != NULL_EXECUTABLE; } @Override - public @Nullable Object convert(String source, Class targetType) throws Exception { + @Nullable Object convert(String source, Class targetType) { Function executable = findFactoryExecutable(targetType); Preconditions.condition(executable != NULL_EXECUTABLE, "Illegal state: convert() must not be called if canConvert() returned false"); diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToBooleanConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToBooleanConverter.java index 4bfefc7b48b1..eb9df420305e 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToBooleanConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToBooleanConverter.java @@ -10,17 +10,19 @@ package org.junit.platform.commons.support.conversion; +import org.jspecify.annotations.Nullable; import org.junit.platform.commons.util.Preconditions; -class StringToBooleanConverter implements StringToObjectConverter { +class StringToBooleanConverter extends StringToWrapperTypeConverter { @Override - public boolean canConvertTo(Class targetType) { + boolean canConvert(Class targetType) { return targetType == Boolean.class; } @Override - public Object convert(String source, Class targetType) { + @Nullable + Boolean convert(@Nullable String source, Class targetType) throws ConversionException { boolean isTrue = "true".equalsIgnoreCase(source); Preconditions.condition(isTrue || "false".equalsIgnoreCase(source), () -> "String must be 'true' or 'false' (ignoring case): " + source); diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCharacterConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCharacterConverter.java index 0f5729a228fc..8597918dbd1a 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCharacterConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCharacterConverter.java @@ -10,17 +10,19 @@ package org.junit.platform.commons.support.conversion; +import org.jspecify.annotations.Nullable; import org.junit.platform.commons.util.Preconditions; -class StringToCharacterConverter implements StringToObjectConverter { +class StringToCharacterConverter extends StringToWrapperTypeConverter { @Override - public boolean canConvertTo(Class targetType) { + boolean canConvert(Class targetType) { return targetType == Character.class; } @Override - public Object convert(String source, Class targetType) { + @Nullable + Character convert(@Nullable String source, Class targetType) throws ConversionException { Preconditions.condition(source.length() == 1, () -> "String must have length of 1: " + source); return source.charAt(0); } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToClassConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToClassConverter.java index a2d5cbb9322e..1eceb64a292c 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToClassConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToClassConverter.java @@ -13,22 +13,17 @@ import org.jspecify.annotations.Nullable; import org.junit.platform.commons.support.ReflectionSupport; -class StringToClassConverter implements StringToObjectConverter { +class StringToClassConverter implements Converter> { @Override - public boolean canConvertTo(Class targetType) { - return targetType == Class.class; + public boolean canConvert(ConversionContext context) { + return context.targetType().getType() == Class.class; } @Override - public Object convert(String source, Class targetType) throws Exception { - throw new UnsupportedOperationException("Invoke convert(String, Class, ClassLoader) instead"); - } - - @Override - public @Nullable Object convert(String className, Class targetType, ClassLoader classLoader) throws Exception { + public @Nullable Class convert(@Nullable String className, ConversionContext context) { // @formatter:off - return ReflectionSupport.tryToLoadClass(className, classLoader) + return ReflectionSupport.tryToLoadClass(className, context.classLoader()) .getOrThrow(cause -> new ConversionException( "Failed to convert String \"" + className + "\" to type java.lang.Class", cause)); // @formatter:on diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCommonJavaTypesConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCommonJavaTypesConverter.java index 7eb2bd31bd16..b93c67e276e9 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCommonJavaTypesConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToCommonJavaTypesConverter.java @@ -23,9 +23,10 @@ import java.util.UUID; import java.util.function.Function; +import org.jspecify.annotations.Nullable; import org.junit.platform.commons.util.Preconditions; -class StringToCommonJavaTypesConverter implements StringToObjectConverter { +class StringToCommonJavaTypesConverter extends StringToTargetTypeConverter { @SuppressWarnings("deprecation") private static final Map, Function> CONVERTERS = Map.of( // @@ -43,14 +44,14 @@ class StringToCommonJavaTypesConverter implements StringToObjectConverter { ); @Override - public boolean canConvertTo(Class targetType) { + boolean canConvert(@Nullable Class targetType) { return CONVERTERS.containsKey(targetType); } @Override - public Object convert(String source, Class targetType) throws Exception { + Object convert(@Nullable String source, @Nullable Class targetType) { Function converter = Preconditions.notNull(CONVERTERS.get(targetType), - () -> "No registered converter for %s".formatted(targetType.getName())); + () -> "No registered converter for %s".formatted(targetType != null ? targetType.getName() : null)); return converter.apply(source); } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToEnumConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToEnumConverter.java index ee18f8f8b1e3..455cbbb91fa7 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToEnumConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToEnumConverter.java @@ -10,16 +10,20 @@ package org.junit.platform.commons.support.conversion; -class StringToEnumConverter implements StringToObjectConverter { +import org.jspecify.annotations.Nullable; + +@SuppressWarnings("rawtypes") +class StringToEnumConverter extends StringToTargetTypeConverter { @Override - public boolean canConvertTo(Class targetType) { + boolean canConvert(Class targetType) { return targetType.isEnum(); } + @SuppressWarnings("unchecked") @Override - @SuppressWarnings({ "unchecked", "rawtypes" }) - public Object convert(String source, Class targetType) throws Exception { + @Nullable + Enum convert(@Nullable String source, Class targetType) throws ConversionException { return Enum.valueOf(targetType, source); } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToJavaTimeConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToJavaTimeConverter.java index c49bd2bc8bdf..b70ef94feb77 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToJavaTimeConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToJavaTimeConverter.java @@ -29,9 +29,10 @@ import java.util.Map; import java.util.function.Function; +import org.jspecify.annotations.Nullable; import org.junit.platform.commons.util.Preconditions; -class StringToJavaTimeConverter implements StringToObjectConverter { +class StringToJavaTimeConverter extends StringToTargetTypeConverter { private static final Map, Function> CONVERTERS = Map.ofEntries( // entry(Duration.class, Duration::parse), // @@ -51,12 +52,13 @@ class StringToJavaTimeConverter implements StringToObjectConverter { ); @Override - public boolean canConvertTo(Class targetType) { + boolean canConvert(Class targetType) { return CONVERTERS.containsKey(targetType); } @Override - public Object convert(String source, Class targetType) throws Exception { + @Nullable + Object convert(@Nullable String source, Class targetType) throws ConversionException { Function converter = Preconditions.notNull(CONVERTERS.get(targetType), () -> "No registered converter for %s".formatted(targetType.getName())); return converter.apply(source); diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToNumberConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToNumberConverter.java index f21ee0429c1b..02580032e72e 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToNumberConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToNumberConverter.java @@ -15,11 +15,12 @@ import java.util.Map; import java.util.function.Function; +import org.jspecify.annotations.Nullable; import org.junit.platform.commons.util.Preconditions; -class StringToNumberConverter implements StringToObjectConverter { +class StringToNumberConverter extends StringToWrapperTypeConverter { - private static final Map, Function> CONVERTERS = Map.of( // + private static final Map, Function> CONVERTERS = Map.of( // Byte.class, Byte::decode, // Short.class, Short::decode, // Integer.class, Integer::decode, // @@ -34,13 +35,14 @@ class StringToNumberConverter implements StringToObjectConverter { ); @Override - public boolean canConvertTo(Class targetType) { + boolean canConvert(@Nullable Class targetType) { return CONVERTERS.containsKey(targetType); } @Override - public Object convert(String source, Class targetType) { - Function converter = Preconditions.notNull(CONVERTERS.get(targetType), + @Nullable + Number convert(@Nullable String source, Class targetType) throws ConversionException { + Function converter = Preconditions.notNull(CONVERTERS.get(targetType), () -> "No registered converter for %s".formatted(targetType.getName())); return converter.apply(source.replace("_", "")); } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToTargetTypeConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToTargetTypeConverter.java new file mode 100644 index 000000000000..30341a39078f --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToTargetTypeConverter.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.commons.support.conversion; + +import org.jspecify.annotations.Nullable; + +/** + * Internal API for converting arguments of type {@link String} to a specified + * target type. + */ +abstract class StringToTargetTypeConverter implements Converter { + + @Override + public final boolean canConvert(ConversionContext context) { + return canConvert(context.targetType().getType()); + } + + /** + * Determine if this converter can convert from a {@link String} to the + * supplied target type. + */ + abstract boolean canConvert(Class targetType); + + @Override + public final T convert(@Nullable String source, ConversionContext context) { + return convert(source, context.targetType().getType()); + } + + /** + * Convert the supplied {@link String} to the supplied target type. + * + *

This method will only be invoked if {@link #canConvert(Class)} + * returned {@code true} for the same target type. + */ + abstract T convert(@Nullable String source, Class targetType); + +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToObjectConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToWrapperTypeConverter.java similarity index 51% rename from junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToObjectConverter.java rename to junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToWrapperTypeConverter.java index 6c3bfffae363..be18fee5b96c 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToObjectConverter.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/StringToWrapperTypeConverter.java @@ -14,42 +14,39 @@ /** * Internal API for converting arguments of type {@link String} to a specified - * target type. + * wrapper type. */ -interface StringToObjectConverter { +abstract class StringToWrapperTypeConverter implements Converter { + + @Override + public final boolean canConvert(ConversionContext context) { + Class targetTypeToUse = context.targetType().getWrapperType() // + .orElseGet(() -> context.targetType().getType()); + return canConvert(targetTypeToUse); + } /** * Determine if this converter can convert from a {@link String} to the * supplied target type (which is guaranteed to be a wrapper type for * primitives — for example, {@link Integer} instead of {@code int}). */ - boolean canConvertTo(Class targetType); + abstract boolean canConvert(Class targetType); - /** - * Convert the supplied {@link String} to the supplied target type (which is - * guaranteed to be a wrapper type for primitives — for example, - * {@link Integer} instead of {@code int}). - * - *

This method will only be invoked in {@link #canConvertTo(Class)} - * returned {@code true} for the same target type. - */ - @Nullable - Object convert(String source, Class targetType) throws Exception; + @Override + public final @Nullable T convert(@Nullable String source, ConversionContext context) throws ConversionException { + Class targetTypeToUse = context.targetType().getWrapperType() // + .orElseGet(() -> context.targetType().getType()); + return convert(source, targetTypeToUse); + } /** * Convert the supplied {@link String} to the supplied target type (which is * guaranteed to be a wrapper type for primitives — for example, * {@link Integer} instead of {@code int}). * - *

This method will only be invoked in {@link #canConvertTo(Class)} + *

This method will only be invoked if {@link #canConvert(Class)} * returned {@code true} for the same target type. - * - *

The default implementation simply delegates to {@link #convert(String, Class)}. - * Can be overridden by concrete implementations of this interface that need - * access to the supplied {@link ClassLoader}. */ - default @Nullable Object convert(String source, Class targetType, ClassLoader classLoader) throws Exception { - return convert(source, targetType); - } + abstract @Nullable T convert(@Nullable String source, Class targetType) throws ConversionException; } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/TypeDescriptor.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/TypeDescriptor.java new file mode 100644 index 000000000000..449c49ab7595 --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/TypeDescriptor.java @@ -0,0 +1,111 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.commons.support.conversion; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.lang.reflect.Field; +import java.lang.reflect.Parameter; +import java.util.Objects; +import java.util.Optional; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.ReflectionUtils; + +/** + * + * + * @since 6.0 + */ +@API(status = EXPERIMENTAL, since = "6.0") +public final class TypeDescriptor { + + /** + * Internal marker for descriptors created from a {@code null} value. + */ + private static final Class NULL_TYPE = Void.class; + + /** + * {@code TypeDescriptor} returned when no value is available. + */ + public static final TypeDescriptor NONE = new TypeDescriptor(NULL_TYPE); + + private final Class type; + + public static TypeDescriptor forClass(Class clazz) { + Preconditions.condition(clazz != NULL_TYPE, () -> "clazz must not be " + NULL_TYPE); + return new TypeDescriptor(clazz); + } + + public static TypeDescriptor forInstance(@Nullable Object instance) { + return instance != null ? forClass(instance.getClass()) : NONE; + } + + public static TypeDescriptor forField(Field field) { + Preconditions.notNull(field, "field must not be null"); + return forClass(field.getType()); + } + + public static TypeDescriptor forParameter(Parameter parameter) { + Preconditions.notNull(parameter, "parameter must not be null"); + return forClass(parameter.getType()); + } + + private TypeDescriptor(Class type) { + this.type = type; + } + + public @Nullable Class getType() { + return this != NONE ? type : null; + } + + /** + * Get the wrapper type of this type descriptor, if available. + * + *

If this type descriptor represents a primitive type, this method + * returns the corresponding wrapped type. Otherwise, this method returns + * {@link Optional#empty() empty()}. + * + * @return an {@code Optional} containing the wrapper type; never + * {@code null} but potentially empty + */ + public Optional> getWrapperType() { + return Optional.ofNullable(ReflectionUtils.getWrapperType(type)); + } + + public boolean isPrimitive() { + return type.isPrimitive(); + } + + public String getTypeName() { + return type.getName(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TypeDescriptor that = (TypeDescriptor) o; + return Objects.equals(this.type, that.type); + } + + @Override + public int hashCode() { + return Objects.hashCode(type); + } + +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/TypedConverter.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/TypedConverter.java new file mode 100644 index 000000000000..eb48cdf76fe8 --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/support/conversion/TypedConverter.java @@ -0,0 +1,68 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.commons.support.conversion; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.util.Preconditions; + +/** + * {@code TypedConversionService} is an abstract base class for + * {@link Converter} implementations that always convert objects of a + * given source type into a given target type. + * + * @param the type of the source argument to convert + * @param the type of the target object to create from the source + * @since 6.0 + */ +@API(status = EXPERIMENTAL, since = "6.0") +public abstract class TypedConverter implements Converter { + + private final Class sourceType; + private final Class targetType; + + /** + * Create a new {@code TypedConversionService}. + * + * @param sourceType the type of the argument to convert; never {@code null} + * @param targetType the type of the target object to create from the source; + * never {@code null} + */ + protected TypedConverter(Class sourceType, Class targetType) { + this.sourceType = Preconditions.notNull(sourceType, "sourceType must not be null"); + this.targetType = Preconditions.notNull(targetType, "targetType must not be null"); + } + + @Override + public final boolean canConvert(ConversionContext context) { + // FIXME add test cases with subtypes + return this.sourceType == context.sourceType().getType() && this.targetType == context.targetType().getType(); + } + + @Override + public final @Nullable T convert(@Nullable S source, ConversionContext context) { + return convert(source); + } + + /** + * Convert the supplied {@code source} object of type {@code S} into an object + * of type {@code T}. + * + * @param source the source object to convert; may be {@code null} + * @return the converted object; may be {@code null} but only if the target + * type is a reference type + * @throws ConversionException if an error occurs during the conversion + */ + protected abstract @Nullable T convert(@Nullable S source) throws ConversionException; + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java index 405828e97fbb..faaa645bf0af 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/DefaultArgumentConverterTests.java @@ -36,6 +36,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.commons.support.ReflectionSupport; import org.junit.platform.commons.support.conversion.ConversionException; +import org.junit.platform.commons.support.conversion.TypeDescriptor; import org.junit.platform.commons.test.TestClassLoader; import org.junit.platform.commons.util.ClassLoaderUtils; @@ -90,26 +91,17 @@ void throwsExceptionForNullToPrimitiveTypeConversion(Class type) { .isThrownBy(() -> convert(null, type)) // .withMessage("Cannot convert null to primitive value of type " + type.getCanonicalName()); - verify(underTest, never()).convert(any(), any(), any(ClassLoader.class)); - } - - @Test - void throwsExceptionForNonStringsConversion() { - assertThatExceptionOfType(ArgumentConversionException.class) // - .isThrownBy(() -> convert(new Enigma(), String.class)) // - .withMessage("No built-in converter for source type %s and target type java.lang.String", - Enigma.class.getName()); - - verify(underTest, never()).convert(any(), any(), any(ClassLoader.class)); + verify(underTest, never()).delegateConversion(any(), any(), any(ClassLoader.class)); } @Test void delegatesStringsConversion() { - doReturn(null).when(underTest).convert(any(), any(), any(ClassLoader.class)); + doReturn(null).when(underTest).delegateConversion(any(), any(), any(ClassLoader.class)); convert("value", int.class); - verify(underTest).convert("value", int.class, getClassLoader(DefaultArgumentConverterTests.class)); + verify(underTest).delegateConversion("value", TypeDescriptor.forClass(int.class), + getClassLoader(DefaultArgumentConverterTests.class)); } @Test @@ -139,20 +131,22 @@ void delegatesLocaleConversionWithExplicitIso639Format() { convert("en", Locale.class); - verify(underTest).convert("en", Locale.class, getClassLoader(DefaultArgumentConverterTests.class)); + verify(underTest).convert("en", TypeDescriptor.forClass(Locale.class), + getClassLoader(DefaultArgumentConverterTests.class)); } @Test void throwsExceptionForDelegatedConversionFailure() { ConversionException exception = new ConversionException("fail"); - doThrow(exception).when(underTest).convert(any(), any(), any(ClassLoader.class)); + doThrow(exception).when(underTest).delegateConversion(any(), any(), any(ClassLoader.class)); assertThatExceptionOfType(ArgumentConversionException.class) // .isThrownBy(() -> convert("value", int.class)) // .withCause(exception) // .withMessage(exception.getMessage()); - verify(underTest).convert("value", int.class, getClassLoader(DefaultArgumentConverterTests.class)); + verify(underTest).delegateConversion("value", TypeDescriptor.forClass(int.class), + getClassLoader(DefaultArgumentConverterTests.class)); } @Test @@ -165,14 +159,14 @@ void delegatesStringToClassWithCustomTypeFromDifferentClassLoaderConversion() th var declaringExecutable = ReflectionSupport.findMethod(customType, "foo").orElseThrow(); assertThat(declaringExecutable.getDeclaringClass().getClassLoader()).isSameAs(testClassLoader); - doReturn(customType).when(underTest).convert(any(), any(), any(ClassLoader.class)); + doReturn(customType).when(underTest).delegateConversion(any(), any(), any(ClassLoader.class)); var clazz = (Class) convert(customTypeName, Class.class, testClassLoader); assertThat(clazz).isNotEqualTo(Enigma.class); assertThat(clazz).isNotNull().isEqualTo(customType); assertThat(clazz.getClassLoader()).isSameAs(testClassLoader); - verify(underTest).convert(customTypeName, Class.class, testClassLoader); + verify(underTest).delegateConversion(customTypeName, TypeDescriptor.forClass(Class.class), testClassLoader); } } @@ -185,7 +179,7 @@ private void assertConverts(@Nullable Object input, Class targetClass, @Nulla .describedAs(input + " --(" + targetClass.getName() + ")--> " + expectedOutput) // .isEqualTo(expectedOutput); - verify(underTest, never()).convert(any(), any(), any(ClassLoader.class)); + verify(underTest, never()).delegateConversion(any(), any(), any(ClassLoader.class)); } private @Nullable Object convert(@Nullable Object input, Class targetClass) { @@ -193,7 +187,7 @@ private void assertConverts(@Nullable Object input, Class targetClass, @Nulla } private @Nullable Object convert(@Nullable Object input, Class targetClass, ClassLoader classLoader) { - return underTest.convert(input, targetClass, classLoader); + return underTest.convert(input, TypeDescriptor.forClass(targetClass), classLoader); } @SuppressWarnings("unused") diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/LocaleConverter.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/LocaleConverter.java new file mode 100644 index 000000000000..fb2ba359447a --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/converter/LocaleConverter.java @@ -0,0 +1,30 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.params.converter; + +import java.util.Locale; + +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.support.conversion.TypedConverter; + +// FIXME move to ConversionSupportIntegrationTests +public class LocaleConverter extends TypedConverter { + + public LocaleConverter() { + super(String.class, Locale.class); + } + + @Override + protected @Nullable Locale convert(@Nullable String source) { + return source != null ? Locale.forLanguageTag(source) : null; + } + +} diff --git a/jupiter-tests/src/test/resources/META-INF/services/org.junit.platform.commons.support.conversion.Converter b/jupiter-tests/src/test/resources/META-INF/services/org.junit.platform.commons.support.conversion.Converter new file mode 100644 index 000000000000..562269b9c9d3 --- /dev/null +++ b/jupiter-tests/src/test/resources/META-INF/services/org.junit.platform.commons.support.conversion.Converter @@ -0,0 +1 @@ +org.junit.jupiter.params.converter.LocaleConverter diff --git a/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/ConversionSupportTests.java b/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/DefaultConverterTests.java similarity index 83% rename from platform-tests/src/test/java/org/junit/platform/commons/support/conversion/ConversionSupportTests.java rename to platform-tests/src/test/java/org/junit/platform/commons/support/conversion/DefaultConverterTests.java index 594cbe9f118f..cc310289f444 100644 --- a/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/ConversionSupportTests.java +++ b/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/DefaultConverterTests.java @@ -50,11 +50,11 @@ import org.junit.platform.commons.util.ClassLoaderUtils; /** - * Unit tests for {@link ConversionSupport}. + * Unit tests for {@link DefaultConverter}. * - * @since 5.12 + * @since 6.0 */ -class ConversionSupportTests { +class DefaultConverterTests { @Test void isAwareOfNull() { @@ -105,45 +105,61 @@ void convertsStringsToPrimitiveWrapperTypes() { @ValueSource(classes = { char.class, boolean.class, short.class, byte.class, int.class, long.class, float.class, double.class, void.class }) void throwsExceptionForNullToPrimitiveTypeConversion(Class type) { + TypeDescriptor typeDescriptor = TypeDescriptor.forClass(type); + + assertThat(canConvert(null, typeDescriptor)).isFalse(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert(null, type)) // - .withMessage("Cannot convert null to primitive value of type " + type.getCanonicalName()); + .isThrownBy(() -> convert(null, typeDescriptor)) // + .withMessage("Cannot convert null to primitive value of type %s", type.getCanonicalName()); } @ParameterizedTest(name = "[{index}] {0}") @ValueSource(classes = { Boolean.class, Character.class, Short.class, Byte.class, Integer.class, Long.class, Float.class, Double.class }) void throwsExceptionWhenConvertingTheWordNullToPrimitiveWrapperType(Class type) { + TypeDescriptor typeDescriptor = TypeDescriptor.forClass(type); + + assertThat(canConvert("null", typeDescriptor)).isTrue(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert("null", type)) // + .isThrownBy(() -> convert("null", typeDescriptor)) // .withMessage("Failed to convert String \"null\" to type " + type.getCanonicalName()); + + assertThat(canConvert("NULL", typeDescriptor)).isTrue(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert("NULL", type)) // + .isThrownBy(() -> convert("NULL", typeDescriptor)) // .withMessage("Failed to convert String \"NULL\" to type " + type.getCanonicalName()); } @Test void throwsExceptionOnInvalidStringForPrimitiveTypes() { + TypeDescriptor charDescriptor = TypeDescriptor.forClass(char.class); + + assertThat(canConvert("ab", charDescriptor)).isTrue(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert("ab", char.class)) // + .isThrownBy(() -> convert("ab", charDescriptor)) // .withMessage("Failed to convert String \"ab\" to type char") // .havingCause() // .withMessage("String must have length of 1: ab"); + TypeDescriptor booleanDescriptor = TypeDescriptor.forClass(boolean.class); + + assertThat(canConvert("tru", booleanDescriptor)).isTrue(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert("tru", boolean.class)) // + .isThrownBy(() -> convert("tru", booleanDescriptor)) // .withMessage("Failed to convert String \"tru\" to type boolean") // .havingCause() // .withMessage("String must be 'true' or 'false' (ignoring case): tru"); + assertThat(canConvert("null", booleanDescriptor)).isTrue(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert("null", boolean.class)) // + .isThrownBy(() -> convert("null", booleanDescriptor)) // .withMessage("Failed to convert String \"null\" to type boolean") // .havingCause() // .withMessage("String must be 'true' or 'false' (ignoring case): null"); + assertThat(canConvert("NULL", booleanDescriptor)).isTrue(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert("NULL", boolean.class)) // + .isThrownBy(() -> convert("NULL", booleanDescriptor)) // .withMessage("Failed to convert String \"NULL\" to type boolean") // .havingCause() // .withMessage("String must be 'true' or 'false' (ignoring case): NULL"); @@ -151,8 +167,11 @@ void throwsExceptionOnInvalidStringForPrimitiveTypes() { @Test void throwsExceptionWhenImplicitConversionIsUnsupported() { + TypeDescriptor typeDescriptor = TypeDescriptor.forClass(Enigma.class); + + assertThat(canConvert("foo", typeDescriptor)).isFalse(); assertThatExceptionOfType(ConversionException.class) // - .isThrownBy(() -> convert("foo", Enigma.class)) // + .isThrownBy(() -> convert("foo", typeDescriptor)) // .withMessage("No built-in converter for source type java.lang.String and target type %s", Enigma.class.getName()); } @@ -232,7 +251,10 @@ void convertsStringToClassWithCustomTypeFromDifferentClassLoader() throws Except var declaringExecutable = ReflectionSupport.findMethod(customType, "foo").orElseThrow(); assertThat(declaringExecutable.getDeclaringClass().getClassLoader()).isSameAs(testClassLoader); - var clazz = (Class) convert(customTypeName, Class.class, classLoader(declaringExecutable)); + var typeDescriptor = TypeDescriptor.forClass(Class.class); + assertThat(canConvert(customTypeName, typeDescriptor)).isTrue(); + + var clazz = (Class) convert(customTypeName, typeDescriptor, classLoader(declaringExecutable)); assertThat(clazz).isNotNull().isNotEqualTo(Enigma.class).isEqualTo(customType); assertThat(clazz.getClassLoader()).isSameAs(testClassLoader); } @@ -309,23 +331,31 @@ void convertsStringToUUID() { // ------------------------------------------------------------------------- private void assertConverts(@Nullable String input, Class targetClass, @Nullable Object expectedOutput) { - var result = convert(input, targetClass); + TypeDescriptor typeDescriptor = TypeDescriptor.forClass(targetClass); + + assertThat(canConvert(input, typeDescriptor)).isTrue(); + + var result = convert(input, typeDescriptor); assertThat(result) // .describedAs(input + " --(" + targetClass.getName() + ")--> " + expectedOutput) // .isEqualTo(expectedOutput); } - private @Nullable Object convert(@Nullable String input, Class targetClass) { + private boolean canConvert(@Nullable String input, TypeDescriptor targetClass) { + return DefaultConverter.INSTANCE.canConvert(new ConversionContext(input, targetClass, classLoader())); + } + + private @Nullable Object convert(@Nullable String input, TypeDescriptor targetClass) { return convert(input, targetClass, classLoader()); } - private @Nullable Object convert(@Nullable String input, Class targetClass, ClassLoader classLoader) { - return ConversionSupport.convert(input, targetClass, classLoader); + private @Nullable Object convert(@Nullable String input, TypeDescriptor targetClass, ClassLoader classLoader) { + return DefaultConverter.INSTANCE.convert(input, new ConversionContext(input, targetClass, classLoader)); } private static ClassLoader classLoader() { - Method declaringExecutable = ReflectionSupport.findMethod(ConversionSupportTests.class, "foo").orElseThrow(); + Method declaringExecutable = ReflectionSupport.findMethod(DefaultConverterTests.class, "foo").orElseThrow(); return classLoader(declaringExecutable); } diff --git a/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverterTests.java b/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverterTests.java index 5bfc428ec8d7..c1c630d91c00 100644 --- a/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverterTests.java +++ b/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/FallbackStringToObjectConverterTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import org.junit.platform.commons.support.conversion.FallbackStringToObjectConverter.IsFactoryConstructor; import org.junit.platform.commons.support.conversion.FallbackStringToObjectConverter.IsFactoryMethod; +import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.ReflectionUtils; /** @@ -87,13 +88,13 @@ void convertsStringToNewspaperViaConstructorIgnoringMultipleFactoryMethods() thr @Test @DisplayName("Cannot convert String to Diary because Diary has neither a static factory method nor a factory constructor") void cannotConvertStringToDiary() { - assertThat(converter.canConvertTo(Diary.class)).isFalse(); + assertThat(converter.canConvert(Diary.class)).isFalse(); } @Test @DisplayName("Cannot convert String to Magazine because Magazine has multiple static factory methods") void cannotConvertStringToMagazine() { - assertThat(converter.canConvertTo(Magazine.class)).isFalse(); + assertThat(converter.canConvert(Magazine.class)).isFalse(); } // ------------------------------------------------------------------------- @@ -120,7 +121,7 @@ private static Method magazineMethod(String methodName) { } private static void assertConverts(String input, Class targetType, Object expectedOutput) throws Exception { - assertThat(converter.canConvertTo(targetType)).isTrue(); + assertThat(converter.canConvert(targetType)).isTrue(); var result = converter.convert(input, targetType); diff --git a/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/TypeDescriptorTests.java b/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/TypeDescriptorTests.java new file mode 100644 index 000000000000..7fd62634e7ec --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/commons/support/conversion/TypeDescriptorTests.java @@ -0,0 +1,28 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.commons.support.conversion; + +import static org.junit.jupiter.api.EqualsAndHashCodeAssertions.assertEqualsAndHashCode; + +import org.junit.jupiter.api.Test; + +class TypeDescriptorTests { + + @Test + void equalsAndHashCode() { + var typeDescriptor1 = TypeDescriptor.forClass(String.class); + var typeDescriptor2 = TypeDescriptor.forClass(String.class); + var typeDescriptor3 = TypeDescriptor.forClass(Object.class); + + assertEqualsAndHashCode(typeDescriptor1, typeDescriptor2, typeDescriptor3); + } + +} diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt index 79178a9ec365..6fe597294f3d 100644 --- a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt +++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt @@ -13,6 +13,7 @@ requires kotlin.stdlib static requires kotlinx.coroutines.core static requires org.apiguardian.api static transitive requires org.jspecify static transitive +uses org.junit.platform.commons.support.conversion.Converter uses org.junit.platform.commons.support.scanning.ClasspathScanner qualified exports org.junit.platform.commons.logging to org.junit.jupiter.api org.junit.jupiter.engine org.junit.jupiter.migrationsupport org.junit.jupiter.params org.junit.platform.console org.junit.platform.engine org.junit.platform.launcher org.junit.platform.reporting org.junit.platform.suite.api org.junit.platform.suite.engine org.junit.platform.testkit org.junit.vintage.engine qualified exports org.junit.platform.commons.util to org.junit.jupiter.api org.junit.jupiter.engine org.junit.jupiter.migrationsupport org.junit.jupiter.params org.junit.platform.console org.junit.platform.engine org.junit.platform.jfr org.junit.platform.launcher org.junit.platform.reporting org.junit.platform.suite.api org.junit.platform.suite.commons org.junit.platform.suite.engine org.junit.platform.testkit org.junit.vintage.engine