Subversion Repositories mkgmap

Rev

Rev 4165 | Blame | Compare with Previous | Last modification | View Log | RSS feed

/*
 * Copyright (C) 2017.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 3 or
 * version 2 as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * General Public License for more details.
 */

package main;

import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.StringReader;
import java.io.UnsupportedEncodingException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.BiFunction;

import javax.xml.bind.DatatypeConverter;

import uk.me.parabola.imgfmt.app.Coord;
import uk.me.parabola.mkgmap.main.StyleTester;
import uk.me.parabola.mkgmap.osmstyle.ExpressionArranger;
import uk.me.parabola.mkgmap.osmstyle.eval.AndOp;
import uk.me.parabola.mkgmap.osmstyle.eval.BinaryOp;
import uk.me.parabola.mkgmap.osmstyle.eval.EqualsOp;
import uk.me.parabola.mkgmap.osmstyle.eval.ExistsOp;
import uk.me.parabola.mkgmap.osmstyle.eval.ExpressionReader;
import uk.me.parabola.mkgmap.osmstyle.eval.GTEOp;
import uk.me.parabola.mkgmap.osmstyle.eval.LTOp;
import uk.me.parabola.mkgmap.osmstyle.eval.NodeType;
import uk.me.parabola.mkgmap.osmstyle.eval.NotEqualOp;
import uk.me.parabola.mkgmap.osmstyle.eval.NotExistsOp;
import uk.me.parabola.mkgmap.osmstyle.eval.NotOp;
import uk.me.parabola.mkgmap.osmstyle.eval.Op;
import uk.me.parabola.mkgmap.osmstyle.eval.OrOp;
import uk.me.parabola.mkgmap.osmstyle.eval.RegexOp;
import uk.me.parabola.mkgmap.osmstyle.eval.ValueOp;
import uk.me.parabola.mkgmap.osmstyle.function.FunctionFactory;
import uk.me.parabola.mkgmap.osmstyle.function.GetTagFunction;
import uk.me.parabola.mkgmap.reader.osm.FeatureKind;
import uk.me.parabola.mkgmap.reader.osm.Way;
import uk.me.parabola.mkgmap.scan.SyntaxException;
import uk.me.parabola.mkgmap.scan.TokenScanner;

import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static uk.me.parabola.mkgmap.osmstyle.eval.NodeType.*;

public class RulesTest {
        private static final Random rand = new Random();

        private static final EnumSet<NodeType> INVALID_TOP = EnumSet.of(NOT, NOT_EXISTS, NOT_EQUALS);
        private static final EnumSet<NodeType> USEFUL_NODES;

        private static final String TEST_FILE_NAME = "tmp.test";
        private static final int TEST_TIMEOUT = 500;  // In Milli seconds

        static {
                USEFUL_NODES = EnumSet.allOf(NodeType.class);
                USEFUL_NODES.removeAll(INVALID_TOP);
                USEFUL_NODES.removeAll(EnumSet.of(OR, AND, VALUE));
        }

        private static boolean onlyErrors;
        private static int maxErrors = Integer.MAX_VALUE;
        private static int maxRules = 1000;
        private static long seed = 8799543;
        private static boolean debug;
        private static boolean saveErrors;
        private static boolean stopOnFail;


        private final String[] values = {
                        "1", "2"
        };
        private final String[] names = {
                        "a", "b"
        };

        private int syntaxErrors;

        private final ExecutorService executor = Executors.newSingleThreadExecutor();

        private static ExpressionArranger arranger;

        private final List<Way> testWays = new ArrayList<>();

        private static void processOptions(String... args) {
                for (int i = 0, argsLength = args.length; i < argsLength; i++) {
                        String s = args[i];
                        if (s.startsWith("--err")) {
                                onlyErrors = true;
                        } else if (s.startsWith("--save")) {
                                saveErrors = true;
                        } else if (s.startsWith("--max-e")) {
                                maxErrors = Integer.parseInt(args[++i]);
                        } else if (s.startsWith("--max-r")) {
                                maxRules = Integer.parseInt(args[++i]);
                        } else if (s.startsWith("--arr")) {
                                arranger = new ExpressionArranger();
                        } else if (s.startsWith("--stop")) {
                                stopOnFail = true;
                        } else if (s.startsWith("--use-len")) {
                                System.out.println("The --use-length option is no longer needed");
                        } else if (s.startsWith("--seed")) {
                                seed = Long.parseLong(args[++i]);
                        } else if (s.startsWith("--rand")) {
                                seed = System.currentTimeMillis();
                        } else if (s.startsWith("--debug")) {
                                debug = true;
                        } else {
                                System.out.println("Usage:\n" +
                                                "--arrange-test use the ExpressionArranger instead of style tester\n" +
                                                "--max-errors   stop after this many errors\n" +
                                                "--max-rules    run this number of random rules\n" +
                                                "--errors       only show errors\n" +
                                                "--stop-on-fail stop on test failures\n" +
                                                "--seed N       set the random number seed to reproduce a previous run\n" +
                                                "--rand         set the random seed to the current time, so a different set of rules" +
                                                "               are generated.\n" +
                                                "--save-errors  save errors as style tester files\n" +
                                                "\n" +
                                                "Any error displayed is a bug in mkgmap.\n")
                                ;
                                System.exit(2);
                        }

                }
        }

        public static void main(String... args) {
                processOptions(args);
                rand.setSeed(seed);

                RulesTest srt = new RulesTest();
                srt.run();
        }

        private void run() {
                initTestWays();

                int errors = 0;

                int count;
                for (count = 0; count < maxRules && errors < maxErrors; ) {

                        Op expr = generateExpr(true);

                        boolean invalid = checkEmpty(expr);
                        if (!invalid) {
                                count++;
                                String rule = expr + String.format(" {name 'n%d'} [0x2]", count);
                                boolean ok = testRule(rule, count);
                                if (!ok)
                                        errors++;
                        }
                }

                executor.shutdownNow();
                System.out.printf("Tests: %s, errors=%d (%d non-syntax failures), passed=%d. ",
                                count, errors, errors-syntaxErrors, count-errors);
                System.out.printf("Seed was %d\n", seed);
        }

        private void initTestWays() {
                for (int a = 0; a < 3; a++) {
                        for (int b = 0; b < 3; b++) {

                                Way w = makeWay();
                                if (a > 0)
                                        w.addTag("a", String.valueOf(a));
                                if (b > 0)
                                        w.addTag("b", String.valueOf(b));
                                testWays.add(w);
                        }
                }
        }

        /**
         * Test the rule by writing a style-tester file and letting it run it.
         */

        private boolean testRule(String rule, int id) {
                if (arranger != null) {
                        return testCradle(rule, id, this::runArrangeTest);
                } else {
                        return testCradle(rule, id, this::runStyleTester);
                }
        }

        private boolean runStyleTester(String rule, int id) {
                OutputStream buf = new ByteArrayOutputStream();
                StyleTester.setOut(new PrintStream(buf));

                try (BufferedWriter fw = new BufferedWriter(new FileWriter(TEST_FILE_NAME))) {

                        fw.write(String.format("#\n# Rule: %s\n# Id: %d\n# Seed: %d\n# Date: %s\n#\n",
                                        rule, id, seed, SimpleDateFormat.getDateInstance().format(new Date())));

                        writeWayDefs(fw);
                        fw.write("<<<lines>>>\n");
                        fw.write(rule);
                        fw.newLine();

                } catch (IOException e) {
                        e.printStackTrace();
                        System.exit(2);
                }

                StyleTester.runSimpleTest(TEST_FILE_NAME);

                String output = buf.toString();
                if (output.contains("ERROR")) {
                        System.out.println("ERROR: FAILED test: " + rule);
                        System.out.println(output);

                        // On error save the test file.
                        saveFile(TEST_FILE_NAME, rule, id);
                        checkStopOnFail();
                        return false;
                } else {
                        if (!onlyErrors)
                                System.out.println("OK: " + rule);

                        // remove since ok
                        new File(TEST_FILE_NAME).delete();
                        return true;
                }
        }

        private boolean runArrangeTest(String rule, int id) {
                TokenScanner scanner = new TokenScanner("test.file", new StringReader(rule));
                ExpressionReader er = new ExpressionReader(scanner, FeatureKind.POLYLINE);
                Op op = er.readConditions();

                boolean[] orig = evalWays(op);
                Op result = arranger.arrange(op);

                boolean[] after = evalWays(result);
                boolean ok = Arrays.equals(after, orig);
                if (ok) {
                        if (!onlyErrors)
                                System.out.println("OK: " + rule);
                } else {
                        System.out.println("ERROR: FAILED test: " + rule);
                        System.out.println("  new expr: " + op);

                        for (int i = 0; i < orig.length; i++) {
                                if (orig[i] != after[i]) {
                                        System.out.println("  way " + testWays.get(i) + ", orig=" + orig[i] + ", arranged=" + after[i]);
                                }
                        }
                        checkStopOnFail();
                }
                return ok;
        }

        private boolean[] evalWays(Op op) {
                boolean[] result = new boolean[testWays.size()];
                int count = 0;
                for (Way w : testWays) {
                        result[count++] = op.eval(w);
                }
                return result;
        }

        /**
         * Call a test method in a timeout protected wrapper.
         *
         * @param rule The rule to be tested.
         * @param id The id of the rule in this run.
         * @param call The test method to call.
         * @return True if the test passed, false if it failed, or there was an exception.
         */

        private boolean testCradle(String rule, int id, BiFunction<String, Integer, Boolean> call) {
                Future<Boolean> future = executor.submit(() -> call.apply(rule, id));
                try {
                        Boolean result = future.get(TEST_TIMEOUT, TimeUnit.MILLISECONDS);
                        if (result != null)
                                return result;
                } catch (InterruptedException | TimeoutException e) {
                        future.cancel(true);

                        System.out.println("ERROR: Timeout - took too long: " + rule);
                        System.out.println("  Message: " + e);

                        // On error save the test file.
                        saveFile(TEST_FILE_NAME, rule, id);
                        checkStopOnFail();
                } catch (ExecutionException e) {
                        future.cancel(true);

                        Throwable ne = e.getCause();
                        if (ne instanceof SyntaxException) {
                                System.out.println("ERROR: Syntax: " + rule);
                                syntaxErrors++;
                        } else {
                                System.out.println("ERROR: Failed with exception: " + rule);
                                System.out.println("  exception: " + ne);
                        }
                        checkStopOnFail();
                        System.out.println("  Message: " + ne.getMessage());

                        // On error save the test file.
                        saveFile(TEST_FILE_NAME, rule, id);
                }

                return false;
        }

        private void checkStopOnFail() {
                if (stopOnFail) {
                        System.out.println("Stopping after first failure");
                        executor.shutdownNow();
                        System.exit(1);
                }
        }

        private void saveFile(String fromName, String rule, int id) {
                if (!saveErrors || fromName == null)
                        return;

                String fileName;
                try {
                        MessageDigest md5 = MessageDigest.getInstance("md5");
                        byte[] digest = md5.digest(rule.getBytes("utf-8"));
                        String s = DatatypeConverter.printHexBinary(digest).substring(0, 8);
                        fileName = String.format("t-%s.test", s);
                } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
                        fileName = String.format("t-%06d.test", id);
                }

                try {
                        Files.move(Paths.get(fromName), Paths.get(fileName), REPLACE_EXISTING);
                } catch (IOException e) {
                        e.printStackTrace();
                }
        }

        /**
         * Create ways for all combinations of the used variables and values.
         * @param fw Write to here
         */

        private void writeWayDefs(BufferedWriter fw) {
                int count = 1;
                for (int a = 0; a < 3; a++) {
                        for (int b = 0; b < 3; b++) {

                                try {
                                        fw.write(String.format("WAY %d\n", count));
                                        if (a > 0)
                                                fw.write(String.format("a=%d\n", a));
                                        if (b > 0)
                                                fw.write(String.format("b=%d\n", b));
                                        fw.newLine();
                                } catch (IOException e) {
                                        e.printStackTrace();
                                }
                                count++;
                        }
                }
        }

        /**
         * Check if this rule would match an empty way.
         *
         * Such rules are not allowed.
         *
         * The problem case is length().  The length of the test way is 3.3 and we always make
         * sure that length is only used in expressions with GTE and values of 1 or 2 so they
         * are always true.  However if there is a NOT in front if it it will then be false and
         * so the simple test of of an empty way returning true could miss this case.
         *
         * So we also check with a way of length zero.
         *
         * Then there is still the problem that we do not detect an expression such as (length()>=2 & !(length()>=2))
         *
         * So reject any rule that has length() beneath a not.
         */

        private static boolean checkEmpty(Op expr) {
                Way way = makeWay();

                boolean invalid = expr.eval(way);

                Way way1 = new Way(2);
                way1.addPoint(new Coord(1, 1));
                if (expr.eval(way1))
                        invalid = true;

                // So if there is a length() anywhere beneath a NOT then throw this rule out.
                if (checkNotLength(expr, expr.isType(NOT)))
                        invalid = true;

                return invalid;
        }

        public static boolean checkNotLength(Op expr, boolean hasNot) {
                if (expr == null)
                        return false;

                Op f = expr.getFirst();
                if (hasNot &&  f != null && f.isType(FUNCTION) && Objects.equals(f.toString(), "length()"))
                        return true;

                boolean invalid = false;
                NodeType t = expr.getType();
                if (expr instanceof BinaryOp) {
                        if (checkNotLength(expr.getFirst(), hasNot))
                                invalid = true;

                        if (checkNotLength(expr.getSecond(), hasNot))
                                invalid = true;

                } else if (t == NOT) {
                        if (checkNotLength(expr.getFirst(), true))
                                invalid = true;
                }
                return invalid;
        }

        private static Way makeWay() {
                Way way = new Way(1);
                way.addPoint(new Coord(1, 1));
                way.addPoint(new Coord(2, 2));
                return way;
        }

        /**
         * Generate a random expression.
         */

        private Op generateExpr() {
                return generateExpr(false);
        }

        /**
         * Generate a random expression.
         *
         * Picks a random operation from a hopefully representative list of possibilities.
         * It is called recursively to fill in AND and OR subtrees.
         *
         * @param interesting Only return OR / AND for use as the starting term.  Otherwise
         *   you get a lot of uninteresting a=2 type expressions.
         */

        private Op generateExpr(boolean interesting) {
                int t = rand.nextInt(interesting ? 3: 10);
                switch (t) {
                case 0:
                        return fillOp(new AndOp());
                case 1:
                        return fillOp(new OrOp());
                case 2:
                        return fillOp(new NotOp());
                case 3:
                        return fillNameValue(new LTOp());
                case 4:
                        return fillNameValue(new GTEOp());
                case 5:
                        return fillNameValue(new RegexOp());
                case 6:
                        return fillNameValue(new NotEqualOp());
                case 7:
                        return new ExistsOp().setFirst(genNameOp(EXISTS));
                case 8:
                        return new NotExistsOp().setFirst(genNameOp(NOT_EXISTS));
                default:
                        return fillNameValue(new EqualsOp());
                }
        }

        /**
         * Fill the slots on a container operation eg AND, OR
         */

        private Op fillOp(Op op) {
                op.setFirst(generateExpr());
                if (op instanceof BinaryOp)
                        op.setSecond(generateExpr());
                return op;
        }

        /**
         * Fill with an name and a value.
         *
         * There are a limited number of names and values available.
         */

        private Op fillNameValue(BinaryOp op) {
                op.setFirst(genNameOp(op.getType()));
                op.setSecond(valueOp());
                return op;
        }

        private Op genNameOp(NodeType type) {
                if (type == GTE && rand.nextInt(4) == 0) {
                        return FunctionFactory.createFunction("length");
                }

                String name = names[rand.nextInt(names.length)];
                return new GetTagFunction(name);
        }

        private Op valueOp() {
                return new ValueOp(values[rand.nextInt(values.length)]);
        }
}