diff --git a/java/base/Runner.java b/java/base/Runner.java new file mode 100644 index 0000000..2f06c30 --- /dev/null +++ b/java/base/Runner.java @@ -0,0 +1,281 @@ +package base; + +import java.io.*; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * @author Georgiy Korneev (kgeorgiy@kgeorgiy.info) + */ +@SuppressWarnings("unused") +@FunctionalInterface +public interface Runner { + List run(final TestCounter counter, final List input); + + default List run(final TestCounter counter, final String... input) { + return run(counter, List.of(input)); + } + + default void testEquals( + final TestCounter counter, + final List input, + final List expected + ) { + counter.test(() -> { + final List actual = run(counter, input); + for (int i = 0; i < Math.min(expected.size(), actual.size()); i++) { + final String exp = expected.get(i); + final String act = actual.get(i); + if (!exp.equalsIgnoreCase(act)) { + Asserts.assertEquals("Line " + (i + 1), exp, act); + return; + } + } + Asserts.assertEquals( + "Number of lines", + expected.size(), + actual.size() + ); + }); + } + + static Packages packages(final String... packages) { + return new Packages(List.of(packages)); + } + + @FunctionalInterface + interface CommentRunner { + List run( + String comment, + TestCounter counter, + List input + ); + } + + final class Packages { + + private final List packages; + + private Packages(final List packages) { + this.packages = packages; + } + + public Runner std(final String className) { + final CommentRunner main = main(className); + return (counter, input) -> + counter.call("io", () -> { + final ByteArrayOutputStream baos = + new ByteArrayOutputStream(); + try ( + final PrintWriter writer = new PrintWriter( + baos, + false, + StandardCharsets.UTF_8 + ) + ) { + input.forEach(writer::println); + } + + final InputStream oldIn = System.in; + try { + System.setIn( + new ByteArrayInputStream(baos.toByteArray()) + ); + return main.run( + String.format("[%d input lines]", input.size()), + counter, + List.of() + ); + } finally { + System.setIn(oldIn); + } + }); + } + + @SuppressWarnings("ConfusingMainMethod") + public CommentRunner main(final String className) { + final Method method = findMain(className); + + return (comment, counter, input) -> { + counter.format( + "Running test %02d: java %s %s%n", + counter.getTestNo(), + method.getDeclaringClass().getName(), + comment + ); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + @SuppressWarnings("UseOfSystemOutOrSystemErr") + final PrintStream oldOut = System.out; + try { + System.setOut( + new PrintStream(out, false, StandardCharsets.UTF_8) + ); + method.invoke( + null, + new Object[] { input.toArray(String[]::new) } + ); + final BufferedReader reader = new BufferedReader( + new InputStreamReader( + new ByteArrayInputStream(out.toByteArray()), + StandardCharsets.UTF_8 + ) + ); + final List result = new ArrayList<>(); + while (true) { + final String line = reader.readLine(); + if (line == null) { + if (result.isEmpty()) { + result.add(""); + } + return result; + } + result.add(line.trim()); + } + } catch (final InvocationTargetException e) { + final Throwable cause = e.getCause(); + throw Asserts.error( + "main thrown exception %s: %s", + cause.getClass().getSimpleName(), + cause + ); + } catch (final Exception e) { + throw Asserts.error( + "Cannot invoke main: %s: %s", + e.getClass().getSimpleName(), + e.getMessage() + ); + } finally { + System.setOut(oldOut); + } + }; + } + + private Method findMain(final String className) { + try { + final URL url = new File(".").toURI().toURL(); + final List candidates = packages + .stream() + .flatMap(pkg -> { + final String prefix = pkg.isEmpty() ? pkg : pkg + "."; + return Stream.of( + prefix + className, + prefix + "$" + className + ); + }) + .toList(); + + //noinspection ClassLoaderInstantiation,resource,IOResourceOpenedButNotSafelyClosed + final URLClassLoader classLoader = new URLClassLoader( + new URL[] { url } + ); + for (final String candidate : candidates) { + try { + final Class loaded = classLoader.loadClass( + candidate + ); + if (!Modifier.isPublic(loaded.getModifiers())) { + throw Asserts.error( + "Class %s is not public", + candidate + ); + } + final Method main = loaded.getMethod( + "main", + String[].class + ); + if ( + !Modifier.isPublic(main.getModifiers()) || + !Modifier.isStatic(main.getModifiers()) + ) { + throw Asserts.error( + "Method main of class %s should be public and static", + candidate + ); + } + return main; + } catch (final ClassNotFoundException e) { + // Ignore + } catch (final NoSuchMethodException e) { + throw Asserts.error( + "Could not find method main(String[]) in class %s", + candidate, + e + ); + } + } + throw Asserts.error( + "Could not find neither of classes %s", + candidates + ); + } catch (final MalformedURLException e) { + throw Asserts.error("Invalid path", e); + } + } + + private static String getClassName( + final String pkg, + final String className + ) { + return pkg.isEmpty() ? className : pkg + "." + className; + } + + public Runner args(final String className) { + final CommentRunner main = main(className); + // final AtomicReference prev = new AtomicReference<>(""); + return (counter, input) -> { + final int total = + input.stream().mapToInt(String::length).sum() + + input.size() * 3; + final String comment = + total <= 300 + ? input + .stream() + .collect(Collectors.joining("\" \"", "\"", "\"")) + : input.size() <= 100 + ? String.format( + "[%d arguments, sizes: %s]", + input.size(), + input + .stream() + .mapToInt(String::length) + .mapToObj(String::valueOf) + .collect(Collectors.joining(", ")) + ) + : String.format( + "[%d arguments, total size: %d]", + input.size(), + total + ); + // assert comment.length() <= 5 || !prev.get().equals(comment) : "Duplicate tests " + comment; + // prev.set(comment); + return main.run(comment, counter, input); + }; + } + + public Runner files(final String className) { + final Runner args = args(className); + return (counter, input) -> + counter.call("io", () -> { + final Path inf = counter.getFile("in"); + final Path ouf = counter.getFile("out"); + Files.write(inf, input); + args.run(counter, List.of(inf.toString(), ouf.toString())); + final List output = Files.readAllLines(ouf); + Files.delete(inf); + Files.delete(ouf); + return output; + }); + } + } +} diff --git a/java/base/Selector.java b/java/base/Selector.java new file mode 100644 index 0000000..049607f --- /dev/null +++ b/java/base/Selector.java @@ -0,0 +1,202 @@ +package base; + +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * @author Georgiy Korneev (kgeorgiy@kgeorgiy.info) + */ +public final class Selector { + + private final Class owner; + private final List modes; + private final Set variantNames = new LinkedHashSet<>(); + private final Map> variants = + new LinkedHashMap<>(); + + public Selector(final Class owner, final String... modes) { + this.owner = owner; + this.modes = List.of(modes); + } + + public Selector variant( + final String name, + final Consumer operations + ) { + Asserts.assertTrue( + "Duplicate variant " + name, + variants.put(name.toLowerCase(), operations) == null + ); + variantNames.add(name); + return this; + } + + private static void check( + final boolean condition, + final String format, + final Object... args + ) { + if (!condition) { + throw new IllegalArgumentException(String.format(format, args)); + } + } + + @SuppressWarnings("UseOfSystemOutOrSystemErr") + public void main(final String... args) { + try { + final String mode; + if (modes.isEmpty()) { + check( + args.length >= 1, + "At least one argument expected, found %s", + args.length + ); + mode = ""; + } else { + check( + args.length >= 2, + "At least two arguments expected, found %s", + args.length + ); + mode = args[0]; + } + + final List vars = Arrays.stream(args) + .skip(modes.isEmpty() ? 0 : 1) + .flatMap(arg -> Arrays.stream(arg.split("[ +]+"))) + .toList(); + + test(mode, vars); + } catch (final IllegalArgumentException e) { + System.err.println("ERROR: " + e.getMessage()); + if (modes.isEmpty()) { + System.err.println("Usage: " + owner.getName() + " VARIANT..."); + } else { + System.err.println( + "Usage: " + owner.getName() + " MODE VARIANT..." + ); + System.err.println("Modes: " + String.join(", ", modes)); + } + System.err.println("Variants: " + String.join(", ", variantNames)); + System.exit(1); + } + } + + public void test(final String mode, List vars) { + final int modeNo = modes.isEmpty() ? -1 : modes.indexOf(mode); + check(modes.isEmpty() || modeNo >= 0, "Unknown mode '%s'", mode); + if (variantNames.contains("Base") && !vars.contains("Base")) { + vars = new ArrayList<>(vars); + vars.add(0, "Base"); + } + + vars.forEach(variant -> + check( + variants.containsKey(variant.toLowerCase()), + "Unknown variant '%s'", + variant + ) + ); + + final Map properties = modes.isEmpty() + ? Map.of("variant", String.join("+", vars)) + : Map.of("variant", String.join("+", vars), "mode", mode); + final TestCounter counter = new TestCounter(owner, modeNo, properties); + counter.printHead(); + vars.forEach(variant -> + counter.scope("Testing " + variant, () -> + variants.get(variant.toLowerCase()).accept(counter) + ) + ); + counter.printStatus(); + } + + public static Composite composite( + final Class owner, + final Function factory, + final String... modes + ) { + return new Composite<>( + owner, + factory, + (tester, counter) -> tester.test(), + modes + ); + } + + public static Composite composite( + final Class owner, + final Function factory, + final BiConsumer tester, + final String... modes + ) { + return new Composite<>(owner, factory, tester, modes); + } + + public List getModes() { + return modes.isEmpty() ? List.of("~") : modes; + } + + public List getVariants() { + return List.copyOf(variants.keySet()); + } + + /** + * @author Georgiy Korneev (kgeorgiy@kgeorgiy.info) + */ + public static final class Composite { + + private final Selector selector; + private final Function factory; + private final BiConsumer tester; + private List> base; + + private Composite( + final Class owner, + final Function factory, + final BiConsumer tester, + final String... modes + ) { + selector = new Selector(owner, modes); + this.factory = factory; + this.tester = tester; + } + + @SafeVarargs + public final Composite variant( + final String name, + final Consumer... parts + ) { + if ("Base".equalsIgnoreCase(name)) { + base = List.of(parts); + return v(name.toLowerCase()); + } else { + return v(name, parts); + } + } + + @SafeVarargs + private Composite v( + final String name, + final Consumer... parts + ) { + selector.variant(name, counter -> { + final V variant = factory.apply(counter); + for (final Consumer part : base) { + part.accept(variant); + } + for (final Consumer part : parts) { + part.accept(variant); + } + tester.accept(variant, counter); + }); + return this; + } + + public Selector selector() { + return selector; + } + } +} diff --git a/java/base/TestCounter.java b/java/base/TestCounter.java new file mode 100644 index 0000000..01ada07 --- /dev/null +++ b/java/base/TestCounter.java @@ -0,0 +1,236 @@ +package base; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * @author Georgiy Korneev (kgeorgiy@kgeorgiy.info) + */ +public class TestCounter extends Log { + + public static final int DENOMINATOR = Integer.getInteger( + "base.denominator", + 1 + ); + public static final int DENOMINATOR2 = (int) Math.round( + Math.sqrt(DENOMINATOR) + ); + + private static final String JAR_EXT = ".jar"; + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat( + "dd.MM.yyyy HH:mm:ss" + ); + + private final Class owner; + private final int mode; + private final Map properties; + private final ExtendedRandom random; + + private final long start = System.currentTimeMillis(); + private int passed; + + public TestCounter( + final Class owner, + final int mode, + final Map properties + ) { + Locale.setDefault(Locale.US); + Asserts.checkAssert(getClass()); + + this.owner = owner; + this.mode = mode; + this.properties = properties; + random = new ExtendedRandom(owner); + } + + public int mode() { + return mode; + } + + public int getTestNo() { + return passed + 1; + } + + public void test(final Runnable action) { + testV(() -> { + action.run(); + return null; + }); + } + + public void testForEach( + final Iterable items, + final Consumer action + ) { + for (final T item : items) { + test(() -> action.accept(item)); + } + } + + public T testV(final Supplier action) { + return silentScope("Test " + getTestNo(), () -> { + final T result = action.get(); + passed++; + return result; + }); + } + + private String getLine() { + return getIndent() == 0 ? "=" : "-"; + } + + public void printHead() { + println("=== " + getTitle()); + } + + public void printStatus() { + format("%s%n%s%n", getLine().repeat(30), getTitle()); + format( + "%d tests passed in %dms%n", + passed, + System.currentTimeMillis() - start + ); + println("Version: " + getVersion(owner)); + println(""); + } + + private String getTitle() { + return String.format( + "%s %s", + owner.getSimpleName(), + properties.isEmpty() ? "" : properties + ); + } + + private static String getVersion(final Class clazz) { + try { + final ClassLoader cl = clazz.getClassLoader(); + final URL url = cl.getResource( + clazz.getName().replace('.', '/') + ".class" + ); + if (url == null) { + return "(no manifest)"; + } + + final String path = url.getPath(); + final int index = path.indexOf(JAR_EXT); + if (index == -1) { + return DATE_FORMAT.format( + new Date(new File(path).lastModified()) + ); + } + + final String jarPath = path.substring(0, index + JAR_EXT.length()); + try ( + final JarFile jarFile = new JarFile(new File(new URI(jarPath))) + ) { + final JarEntry entry = jarFile.getJarEntry( + "META-INF/MANIFEST.MF" + ); + return DATE_FORMAT.format(new Date(entry.getTime())); + } + } catch (final IOException | URISyntaxException e) { + return "error: " + e; + } + } + + public T call(final String message, final SupplierE supplier) { + return get(supplier).either( + e -> fail(e, "%s", message), + Function.identity() + ); + } + + public void shouldFail( + final String message, + @SuppressWarnings("TypeMayBeWeakened") final RunnableE action + ) { + test(() -> get(action).either(e -> null, v -> fail("%s", message))); + } + + public T fail(final String format, final Object... args) { + return fail(Asserts.error(format, args)); + } + + public T fail(final Throwable throwable) { + return fail( + throwable, + "%s: %s", + throwable.getClass().getSimpleName(), + throwable.getMessage() + ); + } + + public T fail( + final Throwable throwable, + final String format, + final Object... args + ) { + final String message = String.format(format, args); + println("ERROR: " + message); + throw throwable instanceof Error + ? (Error) throwable + : new AssertionError(throwable); + } + + public void checkTrue( + final boolean condition, + final String message, + final Object... args + ) { + if (!condition) { + fail(message, args); + } + } + + public static Either get(final SupplierE supplier) { + return supplier.get(); + } + + public Path getFile(final String suffix) { + return Paths.get(String.format("test%d.%s", getTestNo(), suffix)); + } + + public ExtendedRandom random() { + return random; + } + + @FunctionalInterface + public interface SupplierE extends Supplier> { + T getE() throws Exception; + + @Override + default Either get() { + try { + return Either.right(getE()); + } catch (final Exception e) { + return Either.left(e); + } + } + } + + @FunctionalInterface + public interface RunnableE extends SupplierE { + void run() throws Exception; + + @Override + default Void getE() throws Exception { + run(); + return null; + } + } +} diff --git a/java/base/Tester.java b/java/base/Tester.java new file mode 100644 index 0000000..e911baf --- /dev/null +++ b/java/base/Tester.java @@ -0,0 +1,21 @@ +package base; + +/** + * @author Georgiy Korneev (kgeorgiy@kgeorgiy.info) + */ +public abstract class Tester extends BaseChecker { + + protected Tester(final TestCounter counter) { + super(counter); + } + + public abstract void test(); + + public void run(final Class test, final String... args) { + System.out.println( + "=== Testing " + test.getSimpleName() + " " + String.join(" ", args) + ); + test(); + counter.printStatus(); + } +} diff --git a/java/base/Unit.java b/java/base/Unit.java new file mode 100644 index 0000000..8b90c8f --- /dev/null +++ b/java/base/Unit.java @@ -0,0 +1,16 @@ +package base; + +/** + * @author Georgiy Korneev (kgeorgiy@kgeorgiy.info) + */ +public final class Unit { + + public static final Unit INSTANCE = new Unit(); + + private Unit() {} + + @Override + public String toString() { + return "unit"; + } +}