| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package org.apache.sis.test; |
| |
| import java.util.Date; |
| import java.util.Locale; |
| import java.util.TimeZone; |
| import java.util.Iterator; |
| import java.util.Random; |
| import java.io.PrintWriter; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.nio.ByteBuffer; |
| import java.nio.file.Path; |
| import java.nio.file.Files; |
| import java.nio.file.StandardOpenOption; |
| import java.nio.channels.Channels; |
| import java.nio.channels.ReadableByteChannel; |
| import java.nio.channels.SeekableByteChannel; |
| import java.lang.reflect.UndeclaredThrowableException; |
| import java.text.Format; |
| import java.text.DateFormat; |
| import java.text.ParseException; |
| import java.text.SimpleDateFormat; |
| import org.opengis.util.InternationalString; |
| import org.opengis.referencing.IdentifiedObject; |
| import org.opengis.metadata.extent.GeographicBoundingBox; |
| import org.apache.sis.util.Debug; |
| import org.apache.sis.util.Static; |
| import org.apache.sis.util.CharSequences; |
| import org.apache.sis.util.ArgumentChecks; |
| import org.apache.sis.util.collection.TreeTable; |
| import org.apache.sis.util.collection.TableColumn; |
| import org.apache.sis.util.collection.TreeTableFormat; |
| import org.apache.sis.util.privy.X364; |
| import static org.apache.sis.util.privy.Constants.UTC; |
| |
| // Test dependencies |
| import static org.junit.jupiter.api.Assertions.*; |
| |
| // Specific to the geoapi-3.1 and geoapi-4.0 branches: |
| import org.opengis.referencing.ObjectDomain; |
| import org.opengis.metadata.extent.GeographicExtent; |
| |
| |
| /** |
| * Miscellaneous utility methods for test cases. |
| * |
| * @author Martin Desruisseaux (Geomatys) |
| */ |
| public final class TestUtilities extends Static { |
| /** |
| * Width of the separator to print to {@link TestCase#out}, in number of characters. |
| */ |
| private static final int SEPARATOR_WIDTH = 80; |
| |
| /** |
| * Date parser and formatter using the {@code "yyyy-MM-dd HH:mm:ss"} pattern |
| * and UTC time zone. |
| */ |
| private static final DateFormat dateFormat; |
| static { |
| dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CANADA); |
| dateFormat.setTimeZone(TimeZone.getTimeZone(UTC)); |
| dateFormat.setLenient(false); |
| }; |
| |
| /** |
| * The {@link TreeTableFormat} to use for unlocalized string representations. |
| * Created when first needed. |
| */ |
| private static Format tableFormat; |
| |
| /** |
| * The thread group for every threads created for testing purpose. |
| */ |
| public static final ThreadGroup THREADS = new ThreadGroup("SIS-Tests"); |
| |
| /** |
| * The seed for the random number generator created by {@link #createRandomNumberGenerator()}, or null if none. |
| * This information is used for printing the seed in case of test failure, in order to allow the developer to |
| * reproduce the failure. |
| */ |
| static final ThreadLocal<Long> randomSeed = new ThreadLocal<>(); |
| |
| /** |
| * Do not allow instantiation of this class. |
| */ |
| private TestUtilities() { |
| } |
| |
| /** |
| * Prints and clear the current content of {@link TestCase#out}, regardless of whether |
| * {@link TestCase#VERBOSE} is {@code true} or {@code false}. This method should rarely |
| * be needed. |
| */ |
| public static void forceFlushOutput() { |
| TestCase.flushOutput(); |
| } |
| |
| /** |
| * If verbose output are enabled, prints the given title to {@link TestCase#out} in a box. |
| * This method is invoked for writing a clear visual separator between the verbose output |
| * of different test cases. This method does nothing if verbose output is not enabled, |
| * because only the output of failed tests should be printed in such case. |
| * |
| * @param title the title to write. |
| */ |
| public static void printSeparator(final String title) { |
| if (TestCase.VERBOSE) { |
| final PrintWriter out = TestCase.out; |
| final boolean isAnsiSupported = X364.isAnsiSupported(); |
| if (isAnsiSupported) { |
| out.print(X364.FOREGROUND_CYAN.sequence()); |
| } |
| out.print('╒'); |
| for (int i=0; i<SEPARATOR_WIDTH-2; i++) { |
| out.print('═'); |
| } |
| out.println('╕'); |
| out.print("│ "); |
| out.print(title); |
| for (int i=title.codePointCount(0, title.length()); i<SEPARATOR_WIDTH-3; i++) { |
| out.print(' '); |
| } |
| out.println('│'); |
| out.print('└'); |
| for (int i=0; i<SEPARATOR_WIDTH-2; i++) { |
| out.print('─'); |
| } |
| out.println('┘'); |
| if (isAnsiSupported) { |
| out.print(X364.FOREGROUND_DEFAULT.sequence()); |
| } |
| } |
| } |
| |
| /** |
| * Returns a new random number generator with a random seed. |
| * If the test succeed, nothing else will happen. But if the test fails, then the seed value will |
| * be logged to the {@link TestCase#out} stream in order to allow the developer to reproduce the |
| * test failure. |
| * |
| * <p>This method shall be invoked only in the body of a test method - the random number generator |
| * is not valid anymore after the test finished.</p> |
| * |
| * <p>This method doesn't need to be used in every cases. For example, test cases using |
| * {@link Random#nextGaussian()} should create their own random numbers generator with |
| * the {@link Random#Random(long)} constructor instead |
| * (see {@link org.apache.sis.math.StatisticsTest} for more explanation). |
| * Or test cases that are mostly insensitive to the exact sequence of numbers |
| * can use the {@link Random#Random()} constructor instead.</p> |
| * |
| * <p>This method is rather for testing relatively complex code which are likely to behave |
| * differently depending on the exact sequence of numbers. We want to use random sequence |
| * of numbers in order to test the code in a wider range of scenarios. However, in case of |
| * test failure, we need to know the <i>seed</i> which has been used in order to allow |
| * the developer to reproduce the test with the exact same sequence of numbers. |
| * Using this method, the seed can be retrieved in the messages sent to the output stream.</p> |
| * |
| * @return a new random number generator initialized with a random seed. |
| */ |
| public static Random createRandomNumberGenerator() { |
| final long seed = StrictMath.round(StrictMath.random() * (1L << 48)); |
| randomSeed.set(seed); |
| return new Random(seed); |
| } |
| |
| /** |
| * Returns a new random number generator with the given seed. This method is used only for debugging a test failure. |
| * The seed given in argument is the value printed by the test runner. This argument shall be removed after the test |
| * has been fixed. |
| * |
| * <p>The work flow is as below:</p> |
| * <ul> |
| * <li>Uses {@link #createRandomNumberGenerator()} (without argument} in tests.</li> |
| * <li>If a test fail, find the seed value printed by the test runner, then insert that value in argument |
| * to {@code createRandomNumberGenerator(…)}.</li> |
| * <li>Debug the test.</li> |
| * <li>Once the test has been fixed, remove the argument from the {@code createRandomNumberGenerator()} call.</li> |
| * </ul> |
| * |
| * @param seed the random generator seed. |
| * @return a new random number generator initialized with the given seed. |
| */ |
| @Debug |
| public static Random createRandomNumberGenerator(final long seed) { |
| randomSeed.set(seed); |
| return new Random(seed); |
| } |
| |
| /** |
| * Parses the date for the given string using the {@code "yyyy-MM-dd HH:mm:ss"} pattern in UTC timezone. |
| * |
| * @param date the date as a {@link String}. |
| * @return the date as a {@link Date}. |
| */ |
| public static Date date(final String date) { |
| ArgumentChecks.ensureNonNull("date", date); |
| final Date t; |
| try { |
| synchronized (dateFormat) { |
| t = dateFormat.parse(date); |
| } |
| } catch (ParseException e) { |
| throw new AssertionError(e); |
| } |
| /* |
| * The milliseconds are not part of the pattern used by this method because they are rarely specified. |
| * If a test needs to specify milliseconds, add the manually here. Note that this naive hack requires |
| * all milliseconds digits to be provided, e.g. ".900" - not ".9". |
| */ |
| final int s = date.lastIndexOf('.'); |
| if (s >= 0) { |
| final int ms = Integer.parseInt(date.substring(s + 1)); |
| t.setTime(t.getTime() + ms); |
| } |
| return t; |
| } |
| |
| /** |
| * Formats the given value using the given formatter, and parses the text back to its value. |
| * If the parsed value is not equal to the original one, an {@link AssertionError} is thrown. |
| * |
| * @param formatter the formatter to use for formatting and parsing. |
| * @param value the value to format. |
| * @return the formatted value. |
| */ |
| public static String formatAndParse(final Format formatter, final Object value) { |
| final String text = formatter.format(value); |
| final Object parsed; |
| try { |
| parsed = formatter.parseObject(text); |
| } catch (ParseException e) { |
| throw new AssertionError(e); |
| } |
| assertEquals(value, parsed, "Parsed text not equal to the original value"); |
| return text; |
| } |
| |
| /** |
| * Returns a unlocalized string representation of {@code NAME}, {@code VALUE} and {@code REMARKS} columns |
| * of the given tree table. They are the columns included in default string representation of metadata. |
| * Dates and times, if any, will be formatted using the {@code "yyyy-MM-dd HH:mm:ss"} pattern in UTC timezone. |
| * This method is used mostly as a convenient way to verify the content of an ISO 19115 metadata object. |
| * |
| * @param table the table for which to get a string representation. |
| * @return a unlocalized string representation of the given tree table. |
| */ |
| public static String formatMetadata(final TreeTable table) { |
| synchronized (TestUtilities.class) { |
| if (tableFormat == null) { |
| final TreeTableFormat f = new TreeTableFormat(null, null); |
| f.setColumns(TableColumn.NAME, TableColumn.VALUE, TableColumn.REMARKS); |
| tableFormat = f; |
| } |
| return tableFormat.format(table); |
| } |
| } |
| |
| /** |
| * Returns the tree structure of the given string representation, without the localized text. |
| * For example, given the following string: |
| * |
| * <pre class="text"> |
| * Citation |
| * ├─Title…………………………………………………… Some title |
| * └─Cited responsible party |
| * └─Individual name……………… Some person of contact</pre> |
| * |
| * this method returns an array containing the following elements: |
| * |
| * <pre class="text"> |
| * "", |
| * " ├─", |
| * " └─", |
| * " └─"</pre> |
| * |
| * This method is used for comparing two trees having string representation in different locales. |
| * In such case, we cannot compare the actual text content. The best we can do is to compare |
| * the tree structure. |
| * |
| * @param tree the string representation of a tree. |
| * @return the structure of the given tree, without text. |
| */ |
| public static CharSequence[] toTreeStructure(final CharSequence tree) { |
| final CharSequence[] lines = CharSequences.split(tree, '\n'); |
| for (int i=0; i<lines.length; i++) { |
| final CharSequence line = lines[i]; |
| final int length = line.length(); |
| for (int j=0; j<length;) { |
| final int c = Character.codePointAt(line, j); |
| if (Character.isLetterOrDigit(c)) { |
| lines[i] = line.subSequence(0, j); |
| break; |
| } |
| j += Character.charCount(c); |
| } |
| } |
| return lines; |
| } |
| |
| /** |
| * Returns the single element from the given array. If the given array is null or |
| * does not contains exactly one element, then an {@link AssertionError} is thrown. |
| * |
| * @param <E> the type of array elements. |
| * @param array the array from which to get the singleton. |
| * @return the singleton element from the array. |
| */ |
| public static <E> E getSingleton(final E[] array) { |
| assertNotNull(array, "Null array."); |
| assertEquals(1, array.length, "Not a singleton array."); |
| return array[0]; |
| } |
| |
| /** |
| * Returns the single element from the given collection. If the given collection is null |
| * or does not contains exactly one element, then an {@link AssertionError} is thrown. |
| * |
| * @param <E> the type of collection elements. |
| * @param collection the collection from which to get the singleton. |
| * @return the singleton element from the collection. |
| */ |
| public static <E> E getSingleton(final Iterable<? extends E> collection) { |
| assertNotNull(collection, "Null collection."); |
| final Iterator<? extends E> it = collection.iterator(); |
| assertTrue(it.hasNext(), "The collection is empty."); |
| final E element = it.next(); |
| assertFalse(it.hasNext(), "The collection has more than one element."); |
| return element; |
| } |
| |
| /** |
| * Returns the scope of the given object. Exactly one scope shall exist. |
| * |
| * @param object the object for which to get the scope. |
| * @return the single scope of the given object. |
| */ |
| public static String getScope(final IdentifiedObject object) { |
| InternationalString scope = getSingleton(object.getDomains()).getScope(); |
| assertNotNull(scope, "Missing scope."); |
| return scope.toString(); |
| } |
| |
| /** |
| * Returns the domain of validity of the given object. Exactly one domain shall exist, |
| * and that domain shall be a geographic bounding box. |
| * |
| * @param object the object for which to get the domain of validity. |
| * @return the single domain of validity of the given object. |
| */ |
| public static GeographicBoundingBox getDomainOfValidity(final IdentifiedObject object) { |
| ObjectDomain domain = getSingleton(object.getDomains()); |
| GeographicExtent extent = getSingleton(domain.getDomainOfValidity().getGeographicElements()); |
| return assertInstanceOf(GeographicBoundingBox.class, extent); |
| } |
| |
| /** |
| * Returns a copy of the given array with the last coordinate values dropped for each coordinates. |
| * |
| * @param coordinates the source coordinates from which to drop the last coordinate values. |
| * @param sourceDim number of dimensions of each point in the {@code coordinates} array. |
| * @param targetDim number of dimensions to retain. |
| * @return copy of the given {@code coordinates} array with only the {@code targetDim} first dimension for each point. |
| */ |
| public static double[] dropLastDimensions(final double[] coordinates, final int sourceDim, final int targetDim) { |
| assertEquals(0, coordinates.length % sourceDim, "Unexpected array length."); |
| final int numPts = coordinates.length / sourceDim; |
| final double[] reduced = new double[numPts * targetDim]; |
| for (int i=0; i<numPts; i++) { |
| System.arraycopy(coordinates, i*sourceDim, reduced, i*targetDim, targetDim); |
| } |
| return reduced; |
| } |
| |
| /** |
| * If the given failure is not null, re-thrown it as an {@link Error} or |
| * {@link RuntimeException}. Otherwise do nothing. |
| * |
| * @param failure the exception to re-thrown if non-null. |
| */ |
| public static void rethrownIfNotNull(final Throwable failure) { |
| if (failure != null) { |
| if (failure instanceof Error e) throw e; |
| if (failure instanceof RuntimeException e) throw e; |
| throw new UndeclaredThrowableException(failure); |
| } |
| } |
| |
| /** |
| * Copies the full content of the given input stream in a temporary file and returns the channel for that file. |
| * The file is opened with {@link StandardOpenOption#DELETE_ON_CLOSE}, together with read and write options. |
| * |
| * @param data the data to copy in the temporary file. |
| * @param suffix suffix (dot included) to append to the temporary file name, or {@code null} if none. |
| * @return a channel opened on a copy of the content of the given test resource. |
| * @throws IOException if an error occurred while copying the data. |
| */ |
| public static SeekableByteChannel createTemporaryFile(final InputStream data, final String suffix) throws IOException { |
| final SeekableByteChannel channel; |
| try (ReadableByteChannel in = Channels.newChannel(data)) { |
| final Path file = Files.createTempFile("SIS", suffix); |
| channel = Files.newByteChannel(file, StandardOpenOption.DELETE_ON_CLOSE, |
| StandardOpenOption.READ, StandardOpenOption.WRITE); |
| final ByteBuffer buffer = ByteBuffer.allocate(4000); |
| while (in.read(buffer) >= 0) { |
| buffer.flip(); |
| channel.write(buffer); |
| buffer.clear(); |
| } |
| } |
| return channel.position(0); |
| } |
| } |