diff --git a/NOTICE.md b/NOTICE.md index 520713de1c3b..47f2ae4ed1a9 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -4,5 +4,5 @@ Open Source Licenses This product may include a number of subcomponents with separate copyright notices and license terms. Your use of the source code for these subcomponents is subject to the terms and conditions of the -subcomponent's license, as noted in the LICENSE-.md +subcomponent's license, as noted in the `LICENSE-[.md]` files. diff --git a/documentation/documentation.gradle.kts b/documentation/documentation.gradle.kts index 09263cde06dd..08cb319460ea 100644 --- a/documentation/documentation.gradle.kts +++ b/documentation/documentation.gradle.kts @@ -446,11 +446,11 @@ tasks { ).asPath } })) - addStringOption("-add-modules", "info.picocli,org.opentest4j.reporting.events") + addStringOption("-add-modules", "info.picocli,org.opentest4j.reporting.events,de.siegmar.fastcsv") addOption(ModuleSpecificJavadocFileOption("-add-reads", mapOf( "org.junit.platform.console" to provider { "info.picocli" }, "org.junit.platform.reporting" to provider { "org.opentest4j.reporting.events" }, - "org.junit.jupiter.params" to provider { "univocity.parsers" } + "org.junit.jupiter.params" to provider { "de.siegmar.fastcsv" } ))) } diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc index 5cf7d8a9560f..8e50b81851a2 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc @@ -115,6 +115,18 @@ repository on GitHub. * The contracts for the `Executable` parameters of Kotlin-specific `assertTimeout` functions were changed from `callsInPlace(executable, EXACTLY_ONCE)` to `callsInPlace(executable, AT_MOST_ONCE)` which might result in compilation errors. +* As a result of migrating from + https://github.com/uniVocity/univocity-parsers[univocity-parsers] to + https://fastcsv.org/[FastCSV] for `@CsvSource` and `@CsvFileSource`, root causes and + messages of exceptions thrown for malformed CSV input may differ in some cases. While + the overall parsing behavior remains consistent, this may affect custom error handling + that relies on specific exception types or messages. +* The `CsvFileSource.lineSeparator()` parameter has been removed. The line separator is + now automatically detected, meaning that any of `\r`, `\n`, or `\r\n` is treated as a + line separator. +* Parameters such as `ignoreLeadingAndTrailingWhitespace()`, `nullValues()`, and others in + `@CsvSource` and `@CsvFileSource` now apply to header fields as well as to regular + fields. * The `junit-jupiter-migrationsupport` artifact and its contained classes are now deprecated and will be removed in the next major version. * The type bounds of the following methods have been changed to be more flexible and allow @@ -129,6 +141,11 @@ repository on GitHub. * Kotlin's `suspend` modifier may now be applied to test and lifecycle methods. * The `Arguments` interface for parameterized tests is now officially a `@FunctionalInterface`. +* The implementation of `@CsvSource` and `@CsvFileSource` was migrated from the no longer + maintained https://github.com/uniVocity/univocity-parsers[univocity-parsers] to + https://fastcsv.org/[FastCSV]. + This improves the consistency of CSV input handling, including for malformed entries, + and provides better error reporting and overall performance. [[release-notes-6.0.0-M1-junit-vintage]] diff --git a/gradle/base/dsl-extensions/src/main/kotlin/junitbuild/extensions/TaskExtensions.kt b/gradle/base/dsl-extensions/src/main/kotlin/junitbuild/extensions/TaskExtensions.kt index 9cd596dfbfda..ec9a6bc492c9 100644 --- a/gradle/base/dsl-extensions/src/main/kotlin/junitbuild/extensions/TaskExtensions.kt +++ b/gradle/base/dsl-extensions/src/main/kotlin/junitbuild/extensions/TaskExtensions.kt @@ -1,7 +1,21 @@ package junitbuild.extensions import org.gradle.api.Task +import org.gradle.api.file.ArchiveOperations import org.gradle.internal.os.OperatingSystem +import org.gradle.kotlin.dsl.newInstance +import javax.inject.Inject fun Task.trackOperationSystemAsInput() = inputs.property("os", OperatingSystem.current().familyName) + +fun Task.withArchiveOperations(action: (ArchiveOperations) -> T): T = + archiveOperations.run { action(this) } + +private val Task.archiveOperations: ArchiveOperations + get() = project.objects.newInstance(DummyObject::class).archiveOperations + +private abstract class DummyObject { + @get:Inject + abstract val archiveOperations: ArchiveOperations +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b41788a4bdb3..6199dc006cee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checks classgraph = { module = "io.github.classgraph:classgraph", version = "4.8.179" } commons-io = { module = "commons-io:commons-io", version = "2.19.0" } errorProne-core = { module = "com.google.errorprone:error_prone_core", version = "2.38.0" } +fastcsv = { module = "de.siegmar:fastcsv", version = "4.0.0" } groovy4 = { module = "org.apache.groovy:groovy", version = "4.0.27" } groovy2-bom = { module = "org.codehaus.groovy:groovy-bom", version = "2.5.23" } hamcrest = { module = "org.hamcrest:hamcrest", version = "3.0" } @@ -71,7 +72,6 @@ slf4j-julBinding = { module = "org.slf4j:slf4j-jdk14", version = "2.0.17" } snapshotTests-junit5 = { module = "de.skuzzle.test:snapshot-tests-junit5", version.ref = "snapshotTests" } snapshotTests-xml = { module = "de.skuzzle.test:snapshot-tests-xml", version.ref = "snapshotTests" } spock1 = { module = "org.spockframework:spock-core", version = "1.3-groovy-2.5" } -univocity-parsers = { module = "com.sonofab1rd:univocity-parsers", version = "2.10.2" } xmlunit-assertj = { module = "org.xmlunit:xmlunit-assertj3", version.ref = "xmlunit" } xmlunit-placeholders = { module = "org.xmlunit:xmlunit-placeholders", version.ref = "xmlunit" } testingAnnotations = { module = "com.gradle:develocity-testing-annotations", version = "2.0.1" } @@ -99,7 +99,6 @@ buildParameters = { id = "org.gradlex.build-parameters", version = "1.4.4" } commonCustomUserData = { id = "com.gradle.common-custom-user-data-gradle-plugin", version = "2.3" } develocity = { id = "com.gradle.develocity", version = "4.0.2" } errorProne = { id = "net.ltgt.errorprone", version = "4.2.0" } -extraJavaModuleInfo = { id = "org.gradlex.extra-java-module-info", version = "1.12" } foojayResolver = { id = "org.gradle.toolchains.foojay-resolver", version = "1.0.0" } gitPublish = { id = "org.ajoberstar.git-publish", version = "5.1.1" } jmh = { id = "me.champeau.jmh", version = "0.7.3" } diff --git a/junit-jupiter-params/LICENSE-univocity-parsers.md b/junit-jupiter-params/LICENSE-univocity-parsers.md deleted file mode 100644 index f58ac2e9c2cf..000000000000 --- a/junit-jupiter-params/LICENSE-univocity-parsers.md +++ /dev/null @@ -1,168 +0,0 @@ -Apache License -============== - -_Version 2.0, January 2004_ -_<>_ - -### Terms and Conditions for use, reproduction, and distribution - -#### 1. Definitions - -“License” shall mean the terms and conditions for use, reproduction, and -distribution as defined by Sections 1 through 9 of this document. - -“Licensor” shall mean the copyright owner or entity authorized by the copyright -owner that is granting the License. - -“Legal Entity” shall mean the union of the acting entity and all other entities -that control, are controlled by, or are under common control with that entity. -For the purposes of this definition, “control” means **(i)** the power, direct or -indirect, to cause the direction or management of such entity, whether by -contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the -outstanding shares, or **(iii)** beneficial ownership of such entity. - -“You” (or “Your”) shall mean an individual or Legal Entity exercising -permissions granted by this License. - -“Source” form shall mean the preferred form for making modifications, including -but not limited to software source code, documentation source, and configuration -files. - -“Object” form shall mean any form resulting from mechanical transformation or -translation of a Source form, including but not limited to compiled object code, -generated documentation, and conversions to other media types. - -“Work” shall mean the work of authorship, whether in Source or Object form, made -available under the License, as indicated by a copyright notice that is included -in or attached to the work (an example is provided in the Appendix below). - -“Derivative Works” shall mean any work, whether in Source or Object form, that -is based on (or derived from) the Work and for which the editorial revisions, -annotations, elaborations, or other modifications represent, as a whole, an -original work of authorship. For the purposes of this License, Derivative Works -shall not include works that remain separable from, or merely link (or bind by -name) to the interfaces of, the Work and Derivative Works thereof. - -“Contribution” shall mean any work of authorship, including the original version -of the Work and any modifications or additions to that Work or Derivative Works -thereof, that is intentionally submitted to Licensor for inclusion in the Work -by the copyright owner or by an individual or Legal Entity authorized to submit -on behalf of the copyright owner. For the purposes of this definition, -“submitted” means any form of electronic, verbal, or written communication sent -to the Licensor or its representatives, including but not limited to -communication on electronic mailing lists, source code control systems, and -issue tracking systems that are managed by, or on behalf of, the Licensor for -the purpose of discussing and improving the Work, but excluding communication -that is conspicuously marked or otherwise designated in writing by the copyright -owner as “Not a Contribution.” - -“Contributor” shall mean Licensor and any individual or Legal Entity on behalf -of whom a Contribution has been received by Licensor and subsequently -incorporated within the Work. - -#### 2. Grant of Copyright License - -Subject to the terms and conditions of this License, each Contributor hereby -grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, -irrevocable copyright license to reproduce, prepare Derivative Works of, -publicly display, publicly perform, sublicense, and distribute the Work and such -Derivative Works in Source or Object form. - -#### 3. Grant of Patent License - -Subject to the terms and conditions of this License, each Contributor hereby -grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, -irrevocable (except as stated in this section) patent license to make, have -made, use, offer to sell, sell, import, and otherwise transfer the Work, where -such license applies only to those patent claims licensable by such Contributor -that are necessarily infringed by their Contribution(s) alone or by combination -of their Contribution(s) with the Work to which such Contribution(s) was -submitted. If You institute patent litigation against any entity (including a -cross-claim or counterclaim in a lawsuit) alleging that the Work or a -Contribution incorporated within the Work constitutes direct or contributory -patent infringement, then any patent licenses granted to You under this License -for that Work shall terminate as of the date such litigation is filed. - -#### 4. Redistribution - -You may reproduce and distribute copies of the Work or Derivative Works thereof -in any medium, with or without modifications, and in Source or Object form, -provided that You meet the following conditions: - -* **(a)** You must give any other recipients of the Work or Derivative Works a copy of -this License; and -* **(b)** You must cause any modified files to carry prominent notices stating that You -changed the files; and -* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, -all copyright, patent, trademark, and attribution notices from the Source form -of the Work, excluding those notices that do not pertain to any part of the -Derivative Works; and -* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any -Derivative Works that You distribute must include a readable copy of the -attribution notices contained within such NOTICE file, excluding those notices -that do not pertain to any part of the Derivative Works, in at least one of the -following places: within a NOTICE text file distributed as part of the -Derivative Works; within the Source form or documentation, if provided along -with the Derivative Works; or, within a display generated by the Derivative -Works, if and wherever such third-party notices normally appear. The contents of -the NOTICE file are for informational purposes only and do not modify the -License. You may add Your own attribution notices within Derivative Works that -You distribute, alongside or as an addendum to the NOTICE text from the Work, -provided that such additional attribution notices cannot be construed as -modifying the License. - -You may add Your own copyright statement to Your modifications and may provide -additional or different license terms and conditions for use, reproduction, or -distribution of Your modifications, or for any such Derivative Works as a whole, -provided Your use, reproduction, and distribution of the Work otherwise complies -with the conditions stated in this License. - -#### 5. Submission of Contributions - -Unless You explicitly state otherwise, any Contribution intentionally submitted -for inclusion in the Work by You to the Licensor shall be under the terms and -conditions of this License, without any additional terms or conditions. -Notwithstanding the above, nothing herein shall supersede or modify the terms of -any separate license agreement you may have executed with Licensor regarding -such Contributions. - -#### 6. Trademarks - -This License does not grant permission to use the trade names, trademarks, -service marks, or product names of the Licensor, except as required for -reasonable and customary use in describing the origin of the Work and -reproducing the content of the NOTICE file. - -#### 7. Disclaimer of Warranty - -Unless required by applicable law or agreed to in writing, Licensor provides the -Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, -including, without limitation, any warranties or conditions of TITLE, -NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are -solely responsible for determining the appropriateness of using or -redistributing the Work and assume any risks associated with Your exercise of -permissions under this License. - -#### 8. Limitation of Liability - -In no event and under no legal theory, whether in tort (including negligence), -contract, or otherwise, unless required by applicable law (such as deliberate -and grossly negligent acts) or agreed to in writing, shall any Contributor be -liable to You for damages, including any direct, indirect, special, incidental, -or consequential damages of any character arising as a result of this License or -out of the use or inability to use the Work (including but not limited to -damages for loss of goodwill, work stoppage, computer failure or malfunction, or -any and all other commercial damages or losses), even if such Contributor has -been advised of the possibility of such damages. - -#### 9. Accepting Warranty or Additional Liability - -While redistributing the Work or Derivative Works thereof, You may choose to -offer, and charge a fee for, acceptance of support, warranty, indemnity, or -other liability obligations and/or rights consistent with this License. However, -in accepting such obligations, You may act only on Your own behalf and on Your -sole responsibility, not on behalf of any other Contributor, and only if You -agree to indemnify, defend, and hold each Contributor harmless for any liability -incurred by, or claims asserted against, such Contributor by reason of your -accepting any such warranty or additional liability. diff --git a/junit-jupiter-params/junit-jupiter-params.gradle.kts b/junit-jupiter-params/junit-jupiter-params.gradle.kts index 45b9b56127bb..6cb6fde4aa2a 100644 --- a/junit-jupiter-params/junit-jupiter-params.gradle.kts +++ b/junit-jupiter-params/junit-jupiter-params.gradle.kts @@ -8,7 +8,6 @@ plugins { id("junitbuild.shadow-conventions") id("junitbuild.jmh-conventions") `java-test-fixtures` - alias(libs.plugins.extraJavaModuleInfo) } description = "JUnit Jupiter Params" @@ -20,7 +19,7 @@ dependencies { compileOnlyApi(libs.apiguardian) compileOnly(libs.jspecify) - shadowed(libs.univocity.parsers) + shadowed(libs.fastcsv) compileOnly(kotlin("stdlib")) @@ -28,11 +27,6 @@ dependencies { osgiVerification(projects.junitPlatformLauncher) } -extraJavaModuleInfo { - automaticModule(libs.univocity.parsers, "univocity.parsers") - failOnMissingModuleInfo = false -} - tasks { jar { bundle { @@ -45,17 +39,22 @@ tasks { """) } } - shadowJar { - relocate("com.univocity", "org.junit.jupiter.params.shadow.com.univocity") - from(projectDir) { - include("LICENSE-univocity-parsers.md") - into("META-INF") + val extractFastCSVLicense by registering(Sync::class) { + from(zipTree(configurations.shadowedClasspath.flatMap { it.elements }.map { it.single { file -> file.asFile.name.contains("fastcsv") } })) { + include("META-INF/LICENSE") + rename { "LICENSE-fastcsv" } } + into(layout.buildDirectory.dir("fastcsv")) + } + shadowJar { + relocate("de.siegmar.fastcsv", "org.junit.jupiter.params.shadow.de.siegmar.fastcsv") + exclude("META-INF/LICENSE") + from(extractFastCSVLicense) } compileJava { options.compilerArgs.addAll(listOf( - "--add-modules", "univocity.parsers", - "--add-reads", "${javaModuleName}=univocity.parsers" + "--add-modules", "de.siegmar.fastcsv", + "--add-reads", "${javaModuleName}=de.siegmar.fastcsv" )) } compileJmhJava { @@ -68,8 +67,8 @@ tasks { } javadoc { (options as StandardJavadocDocletOptions).apply { - addStringOption("-add-modules", "univocity.parsers") - addStringOption("-add-reads", "${javaModuleName}=univocity.parsers") + addStringOption("-add-modules", "de.siegmar.fastcsv") + addStringOption("-add-reads", "${javaModuleName}=de.siegmar.fastcsv") } } } diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java index 9a165795369e..9a7c4ee47184 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvArgumentsProvider.java @@ -10,19 +10,13 @@ package org.junit.jupiter.params.provider; -import static java.util.Objects.requireNonNull; -import static org.junit.jupiter.params.provider.CsvParserFactory.createParserFor; - -import java.io.StringReader; import java.lang.annotation.Annotation; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; -import com.univocity.parsers.csv.CsvParser; +import de.siegmar.fastcsv.reader.CsvRecord; +import de.siegmar.fastcsv.reader.NamedCsvRecord; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Named; @@ -37,82 +31,43 @@ */ class CsvArgumentsProvider extends AnnotationBasedArgumentsProvider { - private static final String LINE_SEPARATOR = "\n"; - @Override protected Stream provideArguments(ParameterDeclarations parameters, ExtensionContext context, CsvSource csvSource) { - Set nullValues = Set.of(csvSource.nullValues()); - CsvParser csvParser = createParserFor(csvSource); - final boolean textBlockDeclared = !csvSource.textBlock().isEmpty(); - Preconditions.condition(csvSource.value().length > 0 ^ textBlockDeclared, - () -> "@CsvSource must be declared with either `value` or `textBlock` but not both"); - return textBlockDeclared ? parseTextBlock(csvSource, csvParser, nullValues) - : parseValueArray(csvSource, csvParser, nullValues); - } + CsvReaderFactory.validate(csvSource); + + List arguments = new ArrayList<>(); - private Stream parseTextBlock(CsvSource csvSource, CsvParser csvParser, Set nullValues) { - String textBlock = csvSource.textBlock(); - boolean useHeadersInDisplayName = csvSource.useHeadersInDisplayName(); - List argumentsList = new ArrayList<>(); - - try { - List<@Nullable String[]> csvRecords = parseAll(csvParser, textBlock); - String[] headers = useHeadersInDisplayName ? getHeaders(csvParser) : null; - - AtomicInteger index = new AtomicInteger(0); - for (var csvRecord : csvRecords) { - index.incrementAndGet(); - Preconditions.notNull(csvRecord, - () -> "Record at index " + index + " contains invalid CSV: \"\"\"\n" + textBlock + "\n\"\"\""); - argumentsList.add(processCsvRecord(csvRecord, nullValues, useHeadersInDisplayName, headers)); + try (var reader = CsvReaderFactory.createReaderFor(csvSource, getData(csvSource))) { + boolean useHeadersInDisplayName = csvSource.useHeadersInDisplayName(); + for (CsvRecord record : reader) { + arguments.add(processCsvRecord(record, useHeadersInDisplayName)); } } catch (Throwable throwable) { throw handleCsvException(throwable, csvSource); } - return argumentsList.stream(); + return arguments.stream(); } - @SuppressWarnings("NullAway") - private static List<@Nullable String[]> parseAll(CsvParser csvParser, String textBlock) { - return csvParser.parseAll(new StringReader(textBlock)); - } + private static String getData(CsvSource csvSource) { + var values = csvSource.value(); + Preconditions.condition(values.length > 0 ^ !csvSource.textBlock().isEmpty(), + () -> "@CsvSource must be declared with either `value` or `textBlock` but not both"); - private Stream parseValueArray(CsvSource csvSource, CsvParser csvParser, Set nullValues) { - boolean useHeadersInDisplayName = csvSource.useHeadersInDisplayName(); - List argumentsList = new ArrayList<>(); - - try { - String[] headers = null; - AtomicInteger index = new AtomicInteger(0); - for (String input : csvSource.value()) { - index.incrementAndGet(); - String[] csvRecord = csvParser.parseLine(input + LINE_SEPARATOR); - // Lazily retrieve headers if necessary. - if (useHeadersInDisplayName && headers == null) { - headers = getHeaders(csvParser); - continue; - } - Preconditions.notNull(csvRecord, - () -> "Record at index " + index + " contains invalid CSV: \"" + input + "\""); - argumentsList.add(processCsvRecord(csvRecord, nullValues, useHeadersInDisplayName, headers)); - } + if (!csvSource.textBlock().isEmpty()) { + return csvSource.textBlock(); } - catch (Throwable throwable) { - throw handleCsvException(throwable, csvSource); + else { + for (int i = 0; i < values.length; i++) { + int finalI = i; + Preconditions.notBlank(values[i], + () -> "CSV record at index %d must not be blank".formatted(finalI + 1)); + } + return String.join("\n", values); } - - return argumentsList.stream(); - } - - // Cannot get parsed headers until after parsing has started. - static String[] getHeaders(CsvParser csvParser) { - return Arrays.stream(csvParser.getContext().parsedHeaders())// - .map(String::trim)// - .toArray(String[]::new); } /** @@ -120,33 +75,38 @@ static String[] getHeaders(CsvParser csvParser) { * {@link Named} if necessary (for CSV header support), and returns the * CSV record wrapped in an {@link Arguments} instance. */ - static Arguments processCsvRecord(@Nullable String[] csvRecord, Set nullValues, - boolean useHeadersInDisplayName, String @Nullable [] headers) { - - // Nothing to process? - if (nullValues.isEmpty() && !useHeadersInDisplayName) { - return Arguments.of((Object[]) csvRecord); - } + static Arguments processCsvRecord(CsvRecord record, boolean useHeadersInDisplayName) { + List fields = record.getFields(); + List headers = useHeadersInDisplayName ? getHeaders(record) : List.of(); - Preconditions.condition(!useHeadersInDisplayName || (csvRecord.length <= requireNonNull(headers).length), - () -> "The number of columns (%d) exceeds the number of supplied headers (%d) in CSV record: %s".formatted( - csvRecord.length, requireNonNull(headers).length, Arrays.toString(csvRecord))); + Preconditions.condition(!useHeadersInDisplayName || fields.size() <= headers.size(), // + () -> String.format( // + "The number of columns (%d) exceeds the number of supplied headers (%d) in CSV record: %s", // + fields.size(), headers.size(), fields)); // @Nullable - Object[] arguments = new Object[csvRecord.length]; - for (int i = 0; i < csvRecord.length; i++) { - Object column = csvRecord[i]; - if (column != null && nullValues.contains(column)) { - column = null; - } + Object[] arguments = new Object[fields.size()]; + + for (int i = 0; i < fields.size(); i++) { + Object argument = resolveNullMarker(fields.get(i)); if (useHeadersInDisplayName) { - column = asNamed(requireNonNull(headers)[i] + " = " + column, column); + String header = resolveNullMarker(headers.get(i)); + argument = asNamed(header + " = " + argument, argument); } - arguments[i] = column; + arguments[i] = argument; } + return Arguments.of(arguments); } + private static List getHeaders(CsvRecord record) { + return ((NamedCsvRecord) record).getHeader(); + } + + private static @Nullable String resolveNullMarker(String record) { + return record == CsvReaderFactory.DefaultFieldModifier.NULL_MARKER ? null : record; + } + private static Named<@Nullable Object> asNamed(String name, @Nullable Object column) { return Named.<@Nullable Object> of(name, column); } diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java index fe5bc1583084..128416d91bd7 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileArgumentsProvider.java @@ -10,29 +10,21 @@ package org.junit.jupiter.params.provider; -import static java.util.Objects.requireNonNull; -import static java.util.Spliterators.spliteratorUnknownSize; -import static java.util.stream.StreamSupport.stream; -import static org.junit.jupiter.params.provider.CsvArgumentsProvider.getHeaders; -import static org.junit.jupiter.params.provider.CsvArgumentsProvider.handleCsvException; -import static org.junit.jupiter.params.provider.CsvArgumentsProvider.processCsvRecord; -import static org.junit.jupiter.params.provider.CsvParserFactory.createParserFor; - import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; -import java.util.Iterator; import java.util.List; -import java.util.Set; import java.util.Spliterator; +import java.util.function.Consumer; import java.util.stream.Stream; +import java.util.stream.StreamSupport; -import com.univocity.parsers.csv.CsvParser; +import de.siegmar.fastcsv.reader.CsvReader; +import de.siegmar.fastcsv.reader.CsvRecord; -import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.support.ParameterDeclarations; import org.junit.platform.commons.JUnitException; @@ -57,8 +49,10 @@ class CsvFileArgumentsProvider extends AnnotationBasedArgumentsProvider provideArguments(ParameterDeclarations parameters, ExtensionContext context, CsvFileSource csvFileSource) { + Charset charset = getCharsetFrom(csvFileSource); - CsvParser csvParser = createParserFor(csvFileSource); + + CsvReaderFactory.validate(csvFileSource); Stream resources = Arrays.stream(csvFileSource.resources()).map(inputStreamProvider::classpathResource); Stream files = Arrays.stream(csvFileSource.files()).map(inputStreamProvider::file); @@ -68,12 +62,12 @@ protected Stream provideArguments(ParameterDeclarations par return Preconditions.notEmpty(sources, "Resources or files must not be empty") .stream() .map(source -> source.open(context)) - .map(inputStream -> beginParsing(inputStream, csvFileSource, csvParser, charset)) - .flatMap(parser -> toStream(parser, csvFileSource)); + .map(inputStream -> CsvReaderFactory.createReaderFor(csvFileSource, inputStream, charset)) + .flatMap(reader -> toStream(reader, csvFileSource)); // @formatter:on } - private Charset getCharsetFrom(CsvFileSource csvFileSource) { + private static Charset getCharsetFrom(CsvFileSource csvFileSource) { try { return Charset.forName(csvFileSource.encoding()); } @@ -82,81 +76,57 @@ private Charset getCharsetFrom(CsvFileSource csvFileSource) { } } - private CsvParser beginParsing(InputStream inputStream, CsvFileSource csvFileSource, CsvParser csvParser, - Charset charset) { - try { - csvParser.beginParsing(inputStream, charset); - } - catch (Throwable throwable) { - throw handleCsvException(throwable, csvFileSource); - } - return csvParser; - } - - private Stream toStream(CsvParser csvParser, CsvFileSource csvFileSource) { - CsvParserIterator iterator = new CsvParserIterator(csvParser, csvFileSource); - return stream(spliteratorUnknownSize(iterator, Spliterator.ORDERED), false) // - .skip(csvFileSource.numLinesToSkip()) // + private static Stream toStream(CsvReader reader, CsvFileSource csvFileSource) { + var spliterator = CsvExceptionHandlingSpliterator.delegatingTo(reader.spliterator(), csvFileSource); + boolean useHeadersInDisplayName = csvFileSource.useHeadersInDisplayName(); + // @formatter:off + return StreamSupport.stream(spliterator, false) + .skip(csvFileSource.numLinesToSkip()) + .map(record -> CsvArgumentsProvider.processCsvRecord( + record, useHeadersInDisplayName) + ) .onClose(() -> { try { - csvParser.stopParsing(); + reader.close(); } catch (Throwable throwable) { - throw handleCsvException(throwable, csvFileSource); + throw CsvArgumentsProvider.handleCsvException(throwable, csvFileSource); } }); + // @formatter:on } - private static class CsvParserIterator implements Iterator { - - private final CsvParser csvParser; - private final CsvFileSource csvFileSource; - private final boolean useHeadersInDisplayName; - private final Set nullValues; - - @Nullable - private Arguments nextArguments; + private record CsvExceptionHandlingSpliterator(Spliterator delegate, CsvFileSource csvFileSource) + implements Spliterator { - private String @Nullable [] headers; + static CsvExceptionHandlingSpliterator delegatingTo(Spliterator delegate, + CsvFileSource csvFileSource) { + return new CsvExceptionHandlingSpliterator<>(delegate, csvFileSource); + } - CsvParserIterator(CsvParser csvParser, CsvFileSource csvFileSource) { - this.csvParser = csvParser; - this.csvFileSource = csvFileSource; - this.useHeadersInDisplayName = csvFileSource.useHeadersInDisplayName(); - this.nullValues = Set.of(csvFileSource.nullValues()); - advance(); + @Override + public boolean tryAdvance(final Consumer action) { + try { + return delegate.tryAdvance(action); + } + catch (Throwable throwable) { + throw CsvArgumentsProvider.handleCsvException(throwable, csvFileSource); + } } @Override - public boolean hasNext() { - return this.nextArguments != null; + public Spliterator trySplit() { + return delegate.trySplit(); } @Override - public Arguments next() { - Arguments result = this.nextArguments; - advance(); - return requireNonNull(result); + public long estimateSize() { + return delegate.estimateSize(); } - private void advance() { - try { - String[] csvRecord = this.csvParser.parseNext(); - if (csvRecord != null) { - // Lazily retrieve headers if necessary. - if (this.useHeadersInDisplayName && this.headers == null) { - this.headers = getHeaders(this.csvParser); - } - this.nextArguments = processCsvRecord(csvRecord, this.nullValues, this.useHeadersInDisplayName, - this.headers); - } - else { - this.nextArguments = null; - } - } - catch (Throwable throwable) { - throw handleCsvException(throwable, this.csvFileSource); - } + @Override + public int characteristics() { + return delegate.characteristics(); } } diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java index 3f06e2ff62f0..b51f27b61637 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvFileSource.java @@ -41,6 +41,9 @@ *

The column delimiter (which defaults to a comma ({@code ,})) can be customized * via either {@link #delimiter} or {@link #delimiterString}. * + *

The line separator is detected automatically, meaning that any of + * {@code "\r"}, {@code "\n"}, or {@code "\r\n"} is treated as a line separator. + * *

In contrast to the default syntax used in {@code @CsvSource}, {@code @CsvFileSource} * uses a double quote ({@code "}) as its quote character by default, but this can * be changed via {@link #quoteCharacter}. An empty, quoted value ({@code ""}) @@ -101,14 +104,6 @@ */ String encoding() default "UTF-8"; - /** - * The line separator to use when reading the CSV files; must consist of 1 - * or 2 characters, typically {@code "\r"}, {@code "\n"}, or {@code "\r\n"}. - * - *

Defaults to {@code "\n"}. - */ - String lineSeparator() default "\n"; - /** * Configures whether the first CSV record should be treated as header names * for columns. diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvParserFactory.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvParserFactory.java deleted file mode 100644 index ba1b1c0c34ac..000000000000 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvParserFactory.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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.provider; - -import java.lang.annotation.Annotation; - -import com.univocity.parsers.csv.CsvParser; -import com.univocity.parsers.csv.CsvParserSettings; - -import org.junit.platform.commons.util.Preconditions; - -/** - * @since 5.6 - */ -class CsvParserFactory { - - private static final String DEFAULT_DELIMITER = ","; - private static final String LINE_SEPARATOR = "\n"; - private static final char EMPTY_CHAR = '\0'; - private static final boolean COMMENT_PROCESSING_FOR_CSV_FILE_SOURCE = true; - - static CsvParser createParserFor(CsvSource annotation) { - String delimiter = selectDelimiter(annotation, annotation.delimiter(), annotation.delimiterString()); - boolean commentProcessingEnabled = !annotation.textBlock().isEmpty(); - return createParser(delimiter, LINE_SEPARATOR, annotation.quoteCharacter(), annotation.emptyValue(), - annotation.maxCharsPerColumn(), commentProcessingEnabled, annotation.useHeadersInDisplayName(), - annotation.ignoreLeadingAndTrailingWhitespace()); - } - - static CsvParser createParserFor(CsvFileSource annotation) { - String delimiter = selectDelimiter(annotation, annotation.delimiter(), annotation.delimiterString()); - return createParser(delimiter, annotation.lineSeparator(), annotation.quoteCharacter(), annotation.emptyValue(), - annotation.maxCharsPerColumn(), COMMENT_PROCESSING_FOR_CSV_FILE_SOURCE, - annotation.useHeadersInDisplayName(), annotation.ignoreLeadingAndTrailingWhitespace()); - } - - private static String selectDelimiter(Annotation annotation, char delimiter, String delimiterString) { - Preconditions.condition(delimiter == EMPTY_CHAR || delimiterString.isEmpty(), - () -> "The delimiter and delimiterString attributes cannot be set simultaneously in " + annotation); - - if (delimiter != EMPTY_CHAR) { - return String.valueOf(delimiter); - } - if (!delimiterString.isEmpty()) { - return delimiterString; - } - return DEFAULT_DELIMITER; - } - - private static CsvParser createParser(String delimiter, String lineSeparator, char quote, String emptyValue, - int maxCharsPerColumn, boolean commentProcessingEnabled, boolean headerExtractionEnabled, - boolean ignoreLeadingAndTrailingWhitespace) { - return new CsvParser(createParserSettings(delimiter, lineSeparator, quote, emptyValue, maxCharsPerColumn, - commentProcessingEnabled, headerExtractionEnabled, ignoreLeadingAndTrailingWhitespace)); - } - - private static CsvParserSettings createParserSettings(String delimiter, String lineSeparator, char quote, - String emptyValue, int maxCharsPerColumn, boolean commentProcessingEnabled, boolean headerExtractionEnabled, - boolean ignoreLeadingAndTrailingWhitespace) { - - CsvParserSettings settings = new CsvParserSettings(); - settings.setHeaderExtractionEnabled(headerExtractionEnabled); - settings.getFormat().setDelimiter(delimiter); - settings.getFormat().setLineSeparator(lineSeparator); - settings.getFormat().setQuote(quote); - settings.getFormat().setQuoteEscape(quote); - settings.setEmptyValue(emptyValue); - settings.setCommentProcessingEnabled(commentProcessingEnabled); - settings.setAutoConfigurationEnabled(false); - settings.setIgnoreLeadingWhitespaces(ignoreLeadingAndTrailingWhitespace); - settings.setIgnoreTrailingWhitespaces(ignoreLeadingAndTrailingWhitespace); - Preconditions.condition(maxCharsPerColumn > 0 || maxCharsPerColumn == -1, - () -> "maxCharsPerColumn must be a positive number or -1: " + maxCharsPerColumn); - settings.setMaxCharsPerColumn(maxCharsPerColumn); - // Do not use the built-in support for skipping rows/lines since it will - // throw an IllegalArgumentException if the file does not contain at least - // the number of specified lines to skip. - // settings.setNumberOfRowsToSkip(annotation.numLinesToSkip()); - return settings; - } - -} diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvReaderFactory.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvReaderFactory.java new file mode 100644 index 000000000000..24420b9eef51 --- /dev/null +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvReaderFactory.java @@ -0,0 +1,177 @@ +/* + * 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.provider; + +import static de.siegmar.fastcsv.reader.CommentStrategy.NONE; +import static de.siegmar.fastcsv.reader.CommentStrategy.SKIP; + +import java.io.InputStream; +import java.lang.annotation.Annotation; +import java.nio.charset.Charset; +import java.util.Set; +import java.util.UUID; + +import de.siegmar.fastcsv.reader.CsvCallbackHandler; +import de.siegmar.fastcsv.reader.CsvReader; +import de.siegmar.fastcsv.reader.CsvRecord; +import de.siegmar.fastcsv.reader.CsvRecordHandler; +import de.siegmar.fastcsv.reader.FieldModifier; +import de.siegmar.fastcsv.reader.NamedCsvRecordHandler; + +import org.junit.platform.commons.util.Preconditions; + +/** + * @since 6.0 + */ +class CsvReaderFactory { + + private static final String DEFAULT_DELIMITER = ","; + private static final char EMPTY_CHAR = '\0'; + private static final boolean SKIP_EMPTY_LINES = true; + private static final boolean TRIM_WHITESPACES_AROUND_QUOTES = true; + private static final boolean ALLOW_EXTRA_FIELDS = true; + private static final boolean ALLOW_MISSING_FIELDS = true; + private static final boolean ALLOW_DUPLICATE_HEADER_FIELDS = true; + private static final int MAX_FIELDS = 512; + private static final int MAX_RECORD_SIZE = Integer.MAX_VALUE; + + static void validate(CsvSource csvSource) { + validateMaxCharsPerColumn(csvSource.maxCharsPerColumn()); + validateDelimiter(csvSource.delimiter(), csvSource.delimiterString(), csvSource); + } + + static void validate(CsvFileSource csvFileSource) { + validateMaxCharsPerColumn(csvFileSource.maxCharsPerColumn()); + validateDelimiter(csvFileSource.delimiter(), csvFileSource.delimiterString(), csvFileSource); + } + + private static void validateMaxCharsPerColumn(int maxCharsPerColumn) { + Preconditions.condition(maxCharsPerColumn > 0 || maxCharsPerColumn == -1, + () -> "maxCharsPerColumn must be a positive number or -1: " + maxCharsPerColumn); + } + + private static void validateDelimiter(char delimiter, String delimiterString, Annotation annotation) { + Preconditions.condition(delimiter == EMPTY_CHAR || delimiterString.isEmpty(), + () -> "The delimiter and delimiterString attributes cannot be set simultaneously in " + annotation); + } + + static CsvReader createReaderFor(CsvSource csvSource, String data) { + String delimiter = selectDelimiter(csvSource.delimiter(), csvSource.delimiterString()); + // @formatter:off + var builder = CsvReader.builder() + .skipEmptyLines(SKIP_EMPTY_LINES) + .trimWhitespacesAroundQuotes(TRIM_WHITESPACES_AROUND_QUOTES) + .allowExtraFields(ALLOW_EXTRA_FIELDS) + .allowMissingFields(ALLOW_MISSING_FIELDS) + .fieldSeparator(delimiter) + .quoteCharacter(csvSource.quoteCharacter()) + .commentStrategy(csvSource.textBlock().isEmpty() ? NONE : SKIP); + + var callbackHandler = createCallbackHandler( + csvSource.emptyValue(), + Set.of(csvSource.nullValues()), + csvSource.ignoreLeadingAndTrailingWhitespace(), + csvSource.maxCharsPerColumn(), + csvSource.useHeadersInDisplayName() + ); + // @formatter:on + return builder.build(callbackHandler, data); + } + + static CsvReader createReaderFor(CsvFileSource csvFileSource, InputStream inputStream, + Charset charset) { + + String delimiter = selectDelimiter(csvFileSource.delimiter(), csvFileSource.delimiterString()); + // @formatter:off + var builder = CsvReader.builder() + .skipEmptyLines(SKIP_EMPTY_LINES) + .trimWhitespacesAroundQuotes(TRIM_WHITESPACES_AROUND_QUOTES) + .allowExtraFields(ALLOW_EXTRA_FIELDS) + .allowMissingFields(ALLOW_MISSING_FIELDS) + .fieldSeparator(delimiter) + .quoteCharacter(csvFileSource.quoteCharacter()) + .commentStrategy(SKIP); + + var callbackHandler = createCallbackHandler( + csvFileSource.emptyValue(), + Set.of(csvFileSource.nullValues()), + csvFileSource.ignoreLeadingAndTrailingWhitespace(), + csvFileSource.maxCharsPerColumn(), + csvFileSource.useHeadersInDisplayName() + ); + // @formatter:on + return builder.build(callbackHandler, inputStream, charset); + } + + private static String selectDelimiter(char delimiter, String delimiterString) { + if (delimiter != EMPTY_CHAR) { + return String.valueOf(delimiter); + } + if (!delimiterString.isEmpty()) { + return delimiterString; + } + return DEFAULT_DELIMITER; + } + + private static CsvCallbackHandler createCallbackHandler(String emptyValue, + Set nullValues, boolean ignoreLeadingAndTrailingWhitespaces, int maxCharsPerColumn, + boolean useHeadersInDisplayName) { + + int maxFieldSize = maxCharsPerColumn == -1 ? Integer.MAX_VALUE : maxCharsPerColumn; + FieldModifier modifier = new DefaultFieldModifier(emptyValue, nullValues, ignoreLeadingAndTrailingWhitespaces); + + // @formatter:off + if (useHeadersInDisplayName) { + return NamedCsvRecordHandler.builder() + .allowDuplicateHeaderFields(ALLOW_DUPLICATE_HEADER_FIELDS) + .maxFields(MAX_FIELDS) + .maxRecordSize(MAX_RECORD_SIZE) + .maxFieldSize(maxFieldSize) + .fieldModifier(modifier) + .build(); + } + return CsvRecordHandler.builder() + .maxFields(MAX_FIELDS) + .maxRecordSize(MAX_RECORD_SIZE) + .maxFieldSize(maxFieldSize) + .fieldModifier(modifier) + .build(); + // @formatter:on + } + + record DefaultFieldModifier(String emptyValue, Set nullValues, boolean ignoreLeadingAndTrailingWhitespaces) + implements FieldModifier { + /** + * Represents a {@code null} value and serves as a workaround + * since FastCSV does not allow the modified field value to be {@code null}. + *

+ * The marker is generated with a unique ID to ensure it cannot conflict with actual CSV content. + */ + static final String NULL_MARKER = String.format("", UUID.randomUUID()); + + @Override + public String modify(long unusedStartingLineNumber, int unusedFieldIdx, boolean quoted, String field) { + if (quoted && field.isEmpty() && !emptyValue.isEmpty()) { + return emptyValue; + } + if (!quoted && field.isBlank()) { + return NULL_MARKER; + } + String modifiedField = (!quoted && ignoreLeadingAndTrailingWhitespaces) ? field.strip() : field; + if (nullValues.contains(modifiedField)) { + return NULL_MARKER; + } + return modifiedField; + } + + } + +} diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java index a06ef984acdd..295add81b394 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/CsvSource.java @@ -91,7 +91,8 @@ *

Each value corresponds to a record in a CSV file and will be split using * the specified {@link #delimiter} or {@link #delimiterString}. Note that * the first value may optionally be used to supply CSV headers (see - * {@link #useHeadersInDisplayName}). + * {@link #useHeadersInDisplayName}). Moreover, each specified value must + * not be blank. * *

If text block syntax is supported by your programming language, * you may find it more convenient to declare your CSV content via the diff --git a/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts b/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts index 1ef341f586ff..1faade97a723 100644 --- a/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts +++ b/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts @@ -1,4 +1,4 @@ -import junitbuild.extensions.dependencyProject +import junitbuild.extensions.withArchiveOperations import junitbuild.java.WriteArtifactsFile plugins { @@ -33,19 +33,33 @@ tasks { from(configurations.shadowedClasspath) outputFile = layout.buildDirectory.file("shadowed-artifacts") } + val extractThirdPartyLicenses by registering(Sync::class) { + from(withArchiveOperations { ops -> configurations.shadowedClasspath.flatMap { it.elements }.map { it.map(ops::zipTree) } }) + into(layout.buildDirectory.dir("thirdPartyLicenses")) + include("LICENSE.txt") + include("LICENSE-junit.txt") + include("META-INF/LICENSE-*") + exclude("META-INF/LICENSE-notice.md") + eachFile { + val fileName = relativePath.lastName + relativePath = RelativePath(true, when (fileName) { + "LICENSE.txt" -> "LICENSE-hamcrest" + "LICENSE-junit.txt" -> "LICENSE-junit4" + else -> fileName + }) + } + includeEmptyDirs = false + } shadowJar { // https://github.com/junit-team/junit5/issues/2557 // exclude compiled module declarations from any source (e.g. /*, /META-INF/versions/N/*) exclude("**/module-info.class") // https://github.com/junit-team/junit5/issues/761 // prevent duplicates, add 3rd-party licenses explicitly - exclude("META-INF/LICENSE*.md") - from(dependencyProject(project.projects.junitPlatformConsole).projectDir) { - include("LICENSE-picocli.md") - into("META-INF") - } - from(dependencyProject(project.projects.junitJupiterParams).projectDir) { - include("LICENSE-univocity-parsers.md") + exclude("**/COPYRIGHT*") + exclude("META-INF/LICENSE*") + exclude("LICENSE*.txt") // JUnit 4 and Hamcrest + from(extractThirdPartyLicenses) { into("META-INF") } from(shadowedArtifactsFile) { diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java index 6cc030cf447f..5e230fb965e8 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvArgumentsProviderTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.shadow.de.siegmar.fastcsv.reader.CsvParseException; import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.PreconditionViolationException; @@ -30,12 +31,12 @@ class CsvArgumentsProviderTests { @Test - void throwsExceptionForInvalidCsv() { - var annotation = csvSource("foo", "bar", ""); + void throwsExceptionForBlankLines() { + var annotation = csvSource("foo", "bar", " "); assertThatExceptionOfType(JUnitException.class)// .isThrownBy(() -> provideArguments(annotation).toArray())// - .withMessage("Record at index 3 contains invalid CSV: \"\""); + .withMessage("CSV record at index 3 must not be blank"); } @Test @@ -233,20 +234,20 @@ void throwsExceptionIfBothDelimitersAreSimultaneouslySet() { @Test void defaultEmptyValueAndDefaultNullValue() { - var annotation = csvSource("'', null, , apple"); + var annotation = csvSource("'', null, ,, apple"); var arguments = provideArguments(annotation); - assertThat(arguments).containsExactly(array("", "null", null, "apple")); + assertThat(arguments).containsExactly(array("", "null", null, null, "apple")); } @Test void customEmptyValueAndDefaultNullValue() { - var annotation = csvSource().emptyValue("EMPTY").lines("'', null, , apple").build(); + var annotation = csvSource().emptyValue("EMPTY").lines("'', null, ,, apple").build(); var arguments = provideArguments(annotation); - assertThat(arguments).containsExactly(array("EMPTY", "null", null, "apple")); + assertThat(arguments).containsExactly(array("EMPTY", "null", null, null, "apple")); } @Test @@ -259,6 +260,18 @@ void customNullValues() { assertThat(arguments).containsExactly(array("apple", null, null, "", null, "banana", null)); } + @Test + void customNullValueInHeader() { + var annotation = csvSource().useHeadersInDisplayName(true).nullValues("NIL").textBlock(""" + FRUIT, NIL + apple, 1 + """).build(); + + assertThat(headersToValues(annotation)).containsExactly(// + array("FRUIT = apple", "null = 1")// + ); + } + @Test void convertsEmptyValuesToNullInLinesAfterFirstLine() { var annotation = csvSource("'', ''", " , "); @@ -275,7 +288,7 @@ void throwsExceptionIfSourceExceedsMaxCharsPerColumnConfig() { assertThatExceptionOfType(CsvParsingException.class)// .isThrownBy(() -> provideArguments(annotation).findAny())// .withMessageStartingWith("Failed to parse CSV input configured via Mock for CsvSource")// - .withRootCauseInstanceOf(ArrayIndexOutOfBoundsException.class); + .withRootCauseInstanceOf(CsvParseException.class); } @Test @@ -294,7 +307,7 @@ void throwsExceptionWhenSourceExceedsDefaultMaxCharsPerColumnConfig() { assertThatExceptionOfType(CsvParsingException.class)// .isThrownBy(() -> provideArguments(annotation).findAny())// .withMessageStartingWith("Failed to parse CSV input configured via Mock for CsvSource")// - .withRootCauseInstanceOf(ArrayIndexOutOfBoundsException.class); + .withRootCauseInstanceOf(CsvParseException.class); } @Test @@ -340,31 +353,38 @@ void honorsCommentCharacterWhenUsingTextBlockAttribute() { @Test void supportsCsvHeadersWhenUsingTextBlockAttribute() { - supportsCsvHeaders(csvSource().useHeadersInDisplayName(true).textBlock(""" + var annotation = csvSource().useHeadersInDisplayName(true).textBlock(""" FRUIT, RANK apple, 1 banana, 2 - """).build()); + """).build(); + + assertThat(headersToValues(annotation)).containsExactly(// + array("FRUIT = apple", "RANK = 1"), // + array("FRUIT = banana", "RANK = 2")// + ); } @Test void supportsCsvHeadersWhenUsingValueAttribute() { - supportsCsvHeaders(csvSource().useHeadersInDisplayName(true)// - .lines("FRUIT, RANK", "apple, 1", "banana, 2").build()); + var annotation = csvSource().useHeadersInDisplayName(true)// + .lines("FRUIT, RANK", "apple, 1", "banana, 2").build(); + + assertThat(headersToValues(annotation)).containsExactly(// + array("FRUIT = apple", "RANK = 1"), // + array("FRUIT = banana", "RANK = 2")// + ); } - private void supportsCsvHeaders(CsvSource csvSource) { + private Stream headersToValues(CsvSource csvSource) { var arguments = provideArguments(csvSource); - Stream argumentsAsStrings = arguments.map(array -> { + return arguments.map(array -> { String[] strings = new String[array.length]; for (int i = 0; i < array.length; i++) { strings[i] = String.valueOf(array[i]); } return strings; }); - - assertThat(argumentsAsStrings).containsExactly(array("FRUIT = apple", "RANK = 1"), - array("FRUIT = banana", "RANK = 2")); } @Test diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java index 06d27ddf5ae7..34f03a19d0e1 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/CsvFileArgumentsProviderTests.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvFileArgumentsProvider.InputStreamProvider; +import org.junit.jupiter.params.shadow.de.siegmar.fastcsv.reader.CsvParseException; import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.PreconditionViolationException; @@ -40,11 +41,26 @@ */ class CsvFileArgumentsProviderTests { + @Test + void providesArgumentsForEachSupportedLineSeparator() { + var annotation = csvFileSource()// + .resources("test.csv")// + .build(); + + var arguments = provideArguments(annotation, "foo, bar \n baz, qux \r quux, corge \r\n grault, garply"); + + assertThat(arguments).containsExactly(// + array("foo", "bar"), // + array("baz", "qux"), // + array("quux", "corge"), // + array("grault", "garply")// + ); + } + @Test void providesArgumentsForNewlineAndComma() { var annotation = csvFileSource()// .resources("test.csv")// - .lineSeparator("\n")// .delimiter(',')// .build(); @@ -57,7 +73,6 @@ void providesArgumentsForNewlineAndComma() { void providesArgumentsForCarriageReturnAndSemicolon() { var annotation = csvFileSource()// .resources("test.csv")// - .lineSeparator("\r")// .delimiter(';')// .build(); @@ -379,14 +394,13 @@ void throwsExceptionForInvalidCsvFormat() { assertThat(exception)// .hasMessageStartingWith("Failed to parse CSV input configured via Mock for CsvFileSource")// - .hasRootCauseInstanceOf(ArrayIndexOutOfBoundsException.class); + .hasRootCauseInstanceOf(CsvParseException.class); } @Test void emptyValueIsAnEmptyWithCustomNullValueString() { var annotation = csvFileSource()// .resources("test.csv")// - .lineSeparator("\n")// .delimiter(',')// .nullValues("NIL")// .build(); @@ -483,7 +497,7 @@ void throwsExceptionForExceedsMaxCharsFileWithDefaultConfig(@TempDir Path tempDi assertThat(exception)// .hasMessageStartingWith("Failed to parse CSV input configured via Mock for CsvFileSource")// - .hasRootCauseInstanceOf(ArrayIndexOutOfBoundsException.class); + .hasRootCauseInstanceOf(CsvParseException.class); } @Test diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java index 22434b0f243f..7386fed94278 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/provider/MockCsvAnnotationBuilder.java @@ -144,7 +144,6 @@ static class MockCsvFileSourceBuilder extends MockCsvAnnotationBuilder