From 9d73b77030440debfbe6b30c3e81334e7db50c05 Mon Sep 17 00:00:00 2001
From: Robert Scott <code@humanleg.org.uk>
Date: Sat, 25 Nov 2017 23:55:46 +0000
Subject: [PATCH v3 3/3] add ImagePatternMatching testutil, example use in
 MinimapDialogTest

---
 .../josm/gui/dialogs/MinimapDialogTest.java        | 130 +++++++++
 .../josm/testutils/ImagePatternMatching.java       | 316 +++++++++++++++++++++
 2 files changed, 446 insertions(+)
 create mode 100644 test/unit/org/openstreetmap/josm/testutils/ImagePatternMatching.java

diff --git a/test/unit/org/openstreetmap/josm/gui/dialogs/MinimapDialogTest.java b/test/unit/org/openstreetmap/josm/gui/dialogs/MinimapDialogTest.java
index b4ac23820..50197ac79 100644
--- a/test/unit/org/openstreetmap/josm/gui/dialogs/MinimapDialogTest.java
+++ b/test/unit/org/openstreetmap/josm/gui/dialogs/MinimapDialogTest.java
@@ -11,26 +11,38 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import java.awt.Color;
 import java.awt.Component;
 import java.awt.Graphics2D;
+import java.awt.event.ComponentEvent;
 import java.awt.image.BufferedImage;
 
 import javax.swing.JMenuItem;
 import javax.swing.JPopupMenu;
 
+import java.util.Arrays;
+import java.util.Map;
 import java.util.concurrent.Callable;
+import java.util.regex.Matcher;
 
 import org.junit.Rule;
 import org.junit.Test;
 import org.openstreetmap.josm.Main;
 import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.projection.Projections;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
 import org.openstreetmap.josm.gui.bbox.SlippyMapBBoxChooser;
 import org.openstreetmap.josm.gui.bbox.SourceButton;
+import org.openstreetmap.josm.gui.layer.LayerManagerTest.TestLayer;
 import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.testutils.ImagePatternMatching;
 import org.openstreetmap.josm.testutils.JOSMTestRules;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 
 import org.awaitility.Awaitility;
 
+import com.google.common.collect.ImmutableMap;
+
 /**
  * Unit tests of {@link MinimapDialog} class.
  */
@@ -251,4 +263,122 @@ public class MinimapDialogTest {
 
         assertEquals(0xffffffff, paintedSlippyMap.getRGB(0, 0));
     }
+
+    /**
+     * test viewport marker rectangle matches the mapView's aspect ratio
+     * @throws Exception if any error occurs
+     */
+    @Test
+    public void testViewportAspectRatio() throws Exception {
+        // Add a test layer to the layer manager to get the MapFrame & MapView
+        MainApplication.getLayerManager().addLayer(new TestLayer());
+
+        Main.pref.put("slippy_map_chooser.mapstyle", "White Tiles");
+        // ensure projection matches JMapViewer's
+        Main.setProjection(Projections.getProjectionByCode("EPSG:3857"));
+
+        MapView mapView = MainApplication.getMap().mapView;
+        GuiHelper.runInEDTAndWaitWithException(() -> {
+            mapView.setVisible(true);
+            mapView.addNotify();
+            mapView.doLayout();
+            // ensure we have a square mapView viewport
+            mapView.setBounds(0, 0, 350, 350);
+        });
+
+        this.setUpMiniMap();
+
+        // attempt to set viewport to cover a non-square area
+        mapView.zoomTo(new Bounds(26.27, -18.23, 26.275, -18.229));
+
+        // an initial paint operation is required to trigger the tile fetches
+        this.paintSlippyMap();
+
+        Awaitility.await().atMost(1000, MILLISECONDS).until(this.slippyMapTasksFinished);
+
+        this.paintSlippyMap();
+
+        Map<Integer, String> paletteMap = ImmutableMap.<Integer, String>builder()
+            .put(0xffffffff, "w")
+            .put(0xff000000, "b")
+            .put(0xfff0d1d1, "p")
+            .build();
+
+        Matcher rowMatcher = ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^(w+)b(p+)b(w+)$",
+            true
+        );
+
+        // (within a tolerance for numerical error) the number of pixels on the left of the viewport marker
+        // should equal the number on the right
+        assertTrue(
+            "Viewport marker not horizontally centered",
+            Math.abs(rowMatcher.group(1).length() - rowMatcher.group(3).length()) < 4
+        );
+
+        Matcher colMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^(w+)b(p+)b(w+)$",
+            true
+        );
+
+        // (within a tolerance for numerical error) the number of pixels on the top of the viewport marker
+        // should equal the number on the bottom
+        assertTrue(
+            "Viewport marker not vertically centered",
+            Math.abs(colMatcher.group(1).length() - colMatcher.group(3).length()) < 4
+        );
+
+        // (within a tolerance for numerical error) the viewport marker should be square
+        assertTrue(
+            "Viewport marker not square",
+            Math.abs(colMatcher.group(2).length() - rowMatcher.group(2).length()) < 4
+        );
+
+        // now change the mapView size
+        GuiHelper.runInEDTAndWaitWithException(() -> {
+            mapView.setBounds(0, 0, 150, 300);
+            Arrays.stream(mapView.getComponentListeners()).forEach(
+                cl -> cl.componentResized(new ComponentEvent(mapView, ComponentEvent.COMPONENT_RESIZED))
+            );
+        });
+        // minimap doesn't (yet?) listen for component resize events to update its viewport marker, so
+        // trigger a zoom change
+        mapView.zoomTo(mapView.getCenter());
+        this.paintSlippyMap();
+
+        rowMatcher = ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^(w+)b(p+)b(w+)$",
+            true
+        );
+        assertTrue(
+            "Viewport marker not horizontally centered",
+            Math.abs(rowMatcher.group(1).length() - rowMatcher.group(3).length()) < 4
+        );
+
+        colMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^(w+)b(p+)b(w+)$",
+            true
+        );
+        assertTrue(
+            "Viewport marker not vertically centered",
+            Math.abs(colMatcher.group(1).length() - colMatcher.group(3).length()) < 4
+        );
+
+        assertTrue(
+            "Viewport marker not 2:1 aspect ratio",
+            Math.abs(colMatcher.group(2).length() - (rowMatcher.group(2).length()*2.0)) < 5
+        );
+    }
 }
diff --git a/test/unit/org/openstreetmap/josm/testutils/ImagePatternMatching.java b/test/unit/org/openstreetmap/josm/testutils/ImagePatternMatching.java
new file mode 100644
index 000000000..27536e16a
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/testutils/ImagePatternMatching.java
@@ -0,0 +1,316 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils;
+
+import static org.junit.Assert.fail;
+
+import java.awt.image.BufferedImage;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.IntFunction;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Utilities to aid in making assertions about images using regular expressions.
+ */
+public final class ImagePatternMatching {
+    private ImagePatternMatching() {}
+
+    private static final Map<String, Pattern> patternCache = new HashMap<String, Pattern>();
+
+    private static Matcher imageStripPatternMatchInner(
+        final BufferedImage image,
+        final int columnOrRowIndex,
+        IntFunction<String> paletteMapFn,
+        final Map<Integer, String> paletteMap,
+        Pattern pattern,
+        final String patternString,
+        final boolean isColumn,
+        final boolean assertMatch
+    ) {
+        paletteMapFn = Optional.ofNullable(paletteMapFn)
+            // using "#" as the default "unmapped" character as it can be used in regexes without escaping
+            .orElse(i -> paletteMap.getOrDefault(i, "#"));
+        pattern = Optional.ofNullable(pattern)
+            .orElseGet(() -> patternCache.computeIfAbsent(patternString, k -> Pattern.compile(k)));
+
+        int[] columnOrRow = isColumn
+            ? image.getRGB(columnOrRowIndex, 0, 1, image.getHeight(), null, 0, 1)
+            : image.getRGB(0, columnOrRowIndex, image.getWidth(), 1, null, 0, image.getWidth());
+
+        String stringRepr = Arrays.stream(columnOrRow).mapToObj(paletteMapFn).collect(Collectors.joining());
+        Matcher result = pattern.matcher(stringRepr);
+
+        if (assertMatch && !result.matches()) {
+            System.err.println(String.format("Full strip failing to match pattern %s: %s", pattern, stringRepr));
+            fail(String.format(
+                "%s %d failed to match pattern %s",
+                isColumn ? "Column" : "Row",
+                columnOrRowIndex,
+                pattern
+            ));
+        }
+
+        return result;
+    }
+
+    /**
+     * Attempt to match column {@code colNumber}, once translated to characters according to {@code paletteMap}
+     * against the regular expression described by {@code patternString}.
+     * 
+     * @param image         image to take column from
+     * @param colNumber     image column number for comparison
+     * @param paletteMap    {@link Map} of {@code Integer}s (denoting the color in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters. Colors with no corresponding entry in
+     *                      the map are mapped to {@code #}.
+     * @param patternString string representation of regular expression to match against. These are simply used to
+     *                      construct a {@link Pattern} which is cached in case of re-use.
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher columnMatch(
+        final BufferedImage image,
+        final int colNumber,
+        final Map<Integer, String> paletteMap,
+        final String patternString,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            colNumber,
+            null,
+            paletteMap,
+            null,
+            patternString,
+            true,
+            true
+        );
+    }
+
+    /**
+     * Attempt to match column {@code colNumber}, once translated to characters according to {@code paletteMapFn}
+     * against the regular expression described by {@code patternString}.
+     * 
+     * @param image         image to take column from
+     * @param colNumber     image column number for comparison
+     * @param paletteMapFn  function mapping {@code Integer}s (denoting the color in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters.
+     * @param patternString string representation of regular expression to match against. These are simply used to
+     *                      construct a {@link Pattern} which is cached in case of re-use.
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher columnMatch(
+        final BufferedImage image,
+        final int colNumber,
+        final IntFunction<String> paletteMapFn,
+        final String patternString,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            colNumber,
+            paletteMapFn,
+            null,
+            null,
+            patternString,
+            true,
+            true
+        );
+    }
+
+    /**
+     * Attempt to match column {@code colNumber}, once translated to characters according to {@code paletteMap}
+     * against the regular expression {@code pattern}.
+     * 
+     * @param image         image to take column from
+     * @param colNumber     image column number for comparison
+     * @param paletteMap    {@link Map} of {@code Integer}s (denoting the color in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters. Colors with no corresponding entry in
+     *                      the map are mapped to {@code #}.
+     * @param pattern       regular expression to match against
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher columnMatch(
+        final BufferedImage image,
+        final int colNumber,
+        final Map<Integer, String> paletteMap,
+        final Pattern pattern,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            colNumber,
+            null,
+            paletteMap,
+            pattern,
+            null,
+            true,
+            true
+        );
+    }
+
+    /**
+     * Attempt to match column {@code colNumber}, once translated to characters according to {@code paletteMapFn}
+     * against the regular expression {@code pattern}.
+     * 
+     * @param image         image to take column from
+     * @param colNumber     image column number for comparison
+     * @param paletteMapFn  function mapping {@code Integer}s (denoting the color in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters.
+     * @param pattern       regular expression to match against
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher columnMatch(
+        final BufferedImage image,
+        final int colNumber,
+        final IntFunction<String> paletteMapFn,
+        final Pattern pattern,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            colNumber,
+            paletteMapFn,
+            null,
+            pattern,
+            null,
+            true,
+            true
+        );
+    }
+
+    /**
+     * Attempt to match row {@code rowNumber}, once translated to characters according to {@code paletteMap}
+     * against the regular expression described by {@code patternString}.
+     * 
+     * @param image         image to take row from
+     * @param rowNumber     image row number for comparison
+     * @param paletteMap    {@link Map} of {@code Integer}s (denoting the or in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters. Colors with no corresponding entry in
+     *                      the map are mapped to {@code #}.
+     * @param patternString string representation of regular expression to match against. These are simply used to
+     *                      construct a {@link Pattern} which is cached in case of re-use.
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher rowMatch(
+        final BufferedImage image,
+        final int rowNumber,
+        final Map<Integer, String> paletteMap,
+        final String patternString,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            rowNumber,
+            null,
+            paletteMap,
+            null,
+            patternString,
+            false,
+            true
+        );
+    }
+
+    /**
+     * Attempt to match row {@code rowNumber}, once translated to characters according to {@code paletteMapFn}
+     * against the regular expression described by {@code patternString}.
+     * 
+     * @param image         image to take row from
+     * @param rowNumber     image row number for comparison
+     * @param paletteMapFn  function mapping {@code Integer}s (denoting the color in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters.
+     * @param patternString string representation of regular expression to match against. These are simply used to
+     *                      construct a {@link Pattern} which is cached in case of re-use.
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher rowMatch(
+        final BufferedImage image,
+        final int rowNumber,
+        final IntFunction<String> paletteMapFn,
+        final String patternString,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            rowNumber,
+            paletteMapFn,
+            null,
+            null,
+            patternString,
+            false,
+            true
+        );
+    }
+
+    /**
+     * Attempt to match row {@code rowNumber}, once translated to characters according to {@code paletteMap}
+     * against the regular expression {@code pattern}.
+     * 
+     * @param image         image to take row from
+     * @param rowNumber     image row number for comparison
+     * @param paletteMap    {@link Map} of {@code Integer}s (denoting the color in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters. Colors with no corresponding entry in
+     *                      the map are mapped to {@code #}.
+     * @param pattern       regular expression to match against
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher rowMatch(
+        final BufferedImage image,
+        final int rowNumber,
+        final Map<Integer, String> paletteMap,
+        final Pattern pattern,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            rowNumber,
+            null,
+            paletteMap,
+            pattern,
+            null,
+            false,
+            true
+        );
+    }
+
+    /**
+     * Attempt to match row {@code rowNumber}, once translated to characters according to {@code paletteMapFn}
+     * against the regular expression {@code pattern}.
+     * 
+     * @param image         image to take row from
+     * @param rowNumber     image row number for comparison
+     * @param paletteMapFn  function mapping {@code Integer}s (denoting the color in ARGB format) to {@link String}s. It
+     *                      is advised to only map colors to single characters.
+     * @param pattern       regular expression to match against
+     * @param assertMatch   whether to raise an (informative) {@link AssertionFailedError} if no match is found.
+     * @return {@link Matcher} produced by matching attempt
+     */
+    public static Matcher rowMatch(
+        final BufferedImage image,
+        final int rowNumber,
+        final IntFunction<String> paletteMapFn,
+        final Pattern pattern,
+        final boolean assertMatch
+    ) {
+        return imageStripPatternMatchInner(
+            image,
+            rowNumber,
+            paletteMapFn,
+            null,
+            pattern,
+            null,
+            false,
+            true
+        );
+    }
+}
-- 
2.11.0

