Index: trunk/test/unit/org/openstreetmap/josm/TestUtils.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/TestUtils.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/TestUtils.java	(revision 14052)
@@ -3,4 +3,6 @@
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
@@ -9,4 +11,5 @@
 import java.awt.Graphics2D;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -23,4 +26,6 @@
 import java.util.Comparator;
 import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
 import java.util.stream.Stream;
 
@@ -34,8 +39,10 @@
 import org.openstreetmap.josm.data.osm.RelationMember;
 import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.gui.MainApplication;
 import org.openstreetmap.josm.gui.progress.AbstractProgressMonitor;
 import org.openstreetmap.josm.gui.progress.CancelHandler;
 import org.openstreetmap.josm.gui.progress.ProgressMonitor;
 import org.openstreetmap.josm.gui.progress.ProgressTaskId;
+import org.openstreetmap.josm.gui.util.GuiHelper;
 import org.openstreetmap.josm.io.Compression;
 import org.openstreetmap.josm.testutils.FakeGraphics;
@@ -45,4 +52,6 @@
 import com.github.tomakehurst.wiremock.WireMockServer;
 import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+
+import com.google.common.io.ByteStreams;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -429,3 +438,56 @@
         return getHTTPDate(Instant.ofEpochMilli(time));
     }
+
+    /**
+     * Throws AssertionError if contents of both files are not equal
+     * @param fileA File A
+     * @param fileB File B
+     */
+    public static void assertFileContentsEqual(final File fileA, final File fileB) {
+        assertTrue(fileA.exists());
+        assertTrue(fileA.canRead());
+        assertTrue(fileB.exists());
+        assertTrue(fileB.canRead());
+        try {
+            try (
+                FileInputStream streamA = new FileInputStream(fileA);
+                FileInputStream streamB = new FileInputStream(fileB);
+            ) {
+                assertArrayEquals(
+                    ByteStreams.toByteArray(streamA),
+                    ByteStreams.toByteArray(streamB)
+                );
+            }
+        } catch (IOException e) {
+            fail(e.toString());
+        }
+    }
+
+    /**
+     * Waits until any asynchronous operations launched by the test on the EDT or worker threads have
+     * (almost certainly) completed.
+     */
+    public static void syncEDTAndWorkerThreads() {
+        boolean workerQueueEmpty = false;
+        while (!workerQueueEmpty) {
+            try {
+                // once our own task(s) have made it to the front of their respective queue(s),
+                // they're both executing at the same time and we know there aren't any outstanding
+                // worker tasks, then presumably the only way there could be incomplete operations
+                // is if the EDT had launched a deferred task to run on itself or perhaps set up a
+                // swing timer - neither are particularly common patterns in JOSM (?)
+                //
+                // there shouldn't be a risk of creating a deadlock in doing this as there shouldn't
+                // (...couldn't?) be EDT operations waiting on the results of a worker task.
+                workerQueueEmpty = MainApplication.worker.submit(
+                    () -> GuiHelper.runInEDTAndWaitAndReturn(
+                        () -> ((ThreadPoolExecutor) MainApplication.worker).getQueue().isEmpty()
+                    )
+                ).get();
+            } catch (InterruptedException | ExecutionException e) {
+                // inconclusive - retry...
+                workerQueueEmpty = false;
+            }
+        }
+    }
 }
Index: trunk/test/unit/org/openstreetmap/josm/actions/ExitActionTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/actions/ExitActionTest.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/actions/ExitActionTest.java	(revision 14052)
@@ -1,9 +1,18 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.actions;
+
+import static org.junit.Assert.assertTrue;
 
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.contrib.java.lang.system.ExpectedSystemExit;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.progress.swing.ProgressMonitorExecutor;
 import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.tools.ImageProvider;
+
+import mockit.Invocation;
+import mockit.Mock;
+import mockit.MockUp;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -33,6 +42,43 @@
     public void testActionPerformed() {
         exit.expectSystemExitWithStatus(0);
+
+        boolean[] workerShutdownCalled = {false};
+        boolean[] workerShutdownNowCalled = {false};
+        boolean[] imageProviderShutdownCalled = {false};
+
+        // critically we don't proceed into the actual implementation in any of these mock methods -
+        // that would be quite annoying for tests following this one which were expecting to use any
+        // of these
+        new MockUp<ProgressMonitorExecutor>() {
+            @Mock
+            private void shutdown(Invocation invocation) {
+                if (invocation.getInvokedInstance() == MainApplication.worker) {
+                    workerShutdownCalled[0] = true;
+                }
+            }
+
+            @Mock
+            private void shutdownNow(Invocation invocation) {
+                if (invocation.getInvokedInstance() == MainApplication.worker) {
+                    // regular shutdown should have been called first
+                    assertTrue(workerShutdownCalled[0]);
+                    workerShutdownNowCalled[0] = true;
+                }
+            }
+        };
+        new MockUp<ImageProvider>() {
+            @Mock
+            private void shutdown(Invocation invocation) {
+                imageProviderShutdownCalled[0] = true;
+            }
+        };
+
         // No layer
+
         new ExitAction().actionPerformed(null);
+
+        assertTrue(workerShutdownCalled[0]);
+        assertTrue(workerShutdownNowCalled[0]);
+        assertTrue(imageProviderShutdownCalled[0]);
     }
 }
Index: trunk/test/unit/org/openstreetmap/josm/actions/downloadtasks/PluginDownloadTaskTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/actions/downloadtasks/PluginDownloadTaskTest.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/actions/downloadtasks/PluginDownloadTaskTest.java	(revision 14052)
@@ -56,5 +56,5 @@
     @Test
     public void testUpdatePluginValid() throws Exception {
-        this.pluginPath = "plugin/dummy_plugin.jar";
+        this.pluginPath = "plugin/dummy_plugin.v31772.jar";
         this.mockHttp();
 
@@ -74,5 +74,5 @@
 
         // get PluginInformation from jar file
-        final PluginInformation pluginInformation = new PluginInformation(srcPluginFile);
+        final PluginInformation pluginInformation = new PluginInformation(srcPluginFile, "dummy_plugin");
         // ...and grafting on the downloadlink
         pluginInformation.downloadlink = this.getRemoteFileUrl();
@@ -87,16 +87,6 @@
         // the ".jar.new" file should have been deleted
         assertFalse(pluginFileNew.exists());
-        // the ".jar" file should still exist
-        assertTrue(pluginFile.exists());
-        try (
-            FileInputStream pluginDirPluginStream = new FileInputStream(pluginFile);
-            FileInputStream srcPluginStream = new FileInputStream(srcPluginFile);
-        ) {
-            // and its contents should equal those that were served to the task
-            assertArrayEquals(
-                ByteStreams.toByteArray(pluginDirPluginStream),
-                ByteStreams.toByteArray(srcPluginStream)
-            );
-        }
+        // the ".jar" file should still exist and its contents should equal those that were served to the task
+        TestUtils.assertFileContentsEqual(pluginFile, srcPluginFile);
     }
 
@@ -140,12 +130,10 @@
         }
 
-        // the ".jar.new" file should exist, even though invalid
-        assertTrue(pluginFileNew.exists());
+        // assert that the "corrupt" jar file made it through in tact
+        TestUtils.assertFileContentsEqual(pluginFileNew, srcPluginFile);
         // the ".jar" file should still exist
         assertTrue(pluginFile.exists());
         try (
-            FileInputStream pluginDirPluginNewStream = new FileInputStream(pluginFileNew);
             FileInputStream pluginDirPluginStream = new FileInputStream(pluginFile);
-            FileInputStream srcPluginStream = new FileInputStream(srcPluginFile);
         ) {
             // the ".jar" file's contents should be as before
@@ -154,9 +142,4 @@
                 ByteStreams.toByteArray(pluginDirPluginStream)
             );
-            // just assert that the "corrupt" jar file made it through in tact
-            assertArrayEquals(
-                ByteStreams.toByteArray(pluginDirPluginNewStream),
-                ByteStreams.toByteArray(srcPluginStream)
-            );
         }
     }
Index: trunk/test/unit/org/openstreetmap/josm/gui/dialogs/MinimapDialogTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/gui/dialogs/MinimapDialogTest.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/gui/dialogs/MinimapDialogTest.java	(revision 14052)
@@ -3,4 +3,5 @@
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.openstreetmap.josm.tools.I18n.tr;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -19,4 +20,5 @@
 
 import javax.swing.JMenuItem;
+import javax.swing.JCheckBoxMenuItem;
 import javax.swing.JPopupMenu;
 
@@ -27,4 +29,6 @@
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.DataSource;
+import org.openstreetmap.josm.data.osm.DataSet;
 import org.openstreetmap.josm.data.projection.Projections;
 import org.openstreetmap.josm.gui.MainApplication;
@@ -32,4 +36,5 @@
 import org.openstreetmap.josm.gui.bbox.SlippyMapBBoxChooser;
 import org.openstreetmap.josm.gui.bbox.SourceButton;
+import org.openstreetmap.josm.gui.layer.OsmDataLayer;
 import org.openstreetmap.josm.gui.layer.LayerManagerTest.TestLayer;
 import org.openstreetmap.josm.gui.util.GuiHelper;
@@ -87,5 +92,5 @@
                 break;
             } else {
-                boolean equalText = ((JMenuItem) c).getText() == label;
+                boolean equalText = ((JMenuItem) c).getText().equals(label);
                 boolean isSelected = ((JMenuItem) c).isSelected();
                 assertEquals(equalText, isSelected);
@@ -297,7 +302,7 @@
 
         Map<Integer, String> paletteMap = ImmutableMap.<Integer, String>builder()
-            .put(0xffffffff, "w")
-            .put(0xff000000, "b")
-            .put(0xfff0d1d1, "p")
+            .put(0xffffffff, "w")  // white
+            .put(0xff000000, "b")  // black
+            .put(0xfff0d1d1, "p")  // pink
             .build();
 
@@ -385,3 +390,412 @@
         );
     }
+
+    protected JCheckBoxMenuItem getShowDownloadedAreaMenuItem() {
+        JPopupMenu menu = this.sourceButton.getPopupMenu();
+        boolean afterSeparator = false;
+        for (Component c: menu.getComponents()) {
+            if (JPopupMenu.Separator.class.isInstance(c)) {
+                assertFalse("More than one separator before target item", afterSeparator);
+                afterSeparator = true;
+            } else if (((JMenuItem) c).getText().equals(tr("Show downloaded area"))) {
+                assertTrue("Separator not found before target item", afterSeparator);
+                assertTrue("Target item doesn't appear to be a JCheckBoxMenuItem", JCheckBoxMenuItem.class.isInstance(c));
+                return (JCheckBoxMenuItem) c;
+            }
+        }
+        fail("'Show downloaded area' menu item not found");
+        return null;
+    }
+
+    /**
+     * test downloaded area is shown shaded
+     * @throws Exception if any error occurs
+     */
+    @Test
+    public void testShowDownloadedArea() throws Exception {
+        Main.pref.put("slippy_map_chooser.mapstyle", "Green Tiles");
+        Main.pref.putBoolean("slippy_map_chooser.show_downloaded_area", false);
+
+        DataSet dataSet = new DataSet();
+        dataSet.addDataSource(new DataSource(new Bounds(51.725, -0.0209, 51.746, 0.0162), "Somewhere"));
+
+        OsmDataLayer dataLayer = new OsmDataLayer(
+            dataSet,
+            "Test Layer 123",
+            null
+        );
+        MainApplication.getLayerManager().addLayer(dataLayer);
+        MainApplication.getLayerManager().setActiveLayer(dataLayer);
+
+        MapView mapView = MainApplication.getMap().mapView;
+        GuiHelper.runInEDTAndWaitWithException(() -> {
+            mapView.setVisible(true);
+            mapView.addNotify();
+            mapView.doLayout();
+            mapView.setBounds(0, 0, 500, 500);
+        });
+
+        this.setUpMiniMap();
+
+        // assert "show downloaded areas" checkbox is unchecked
+        assertFalse(this.getShowDownloadedAreaMenuItem().isSelected());
+
+        // we won't end up with exactly this viewport as it doesn't *precisely* match the aspect ratio
+        mapView.zoomTo(new Bounds(51.732, -0.0269, 51.753, 0.0102));
+
+        // 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(0xff00ff00, "g")  // green
+            .put(0xff000000, "b")  // black
+            .put(0xff8ad16b, "v")  // viewport marker inner (pink+green mix)
+            .put(0xff00df00, "d")  // (shaded green)
+            .put(0xff8ac46b, "q")  // (shaded pink+green mix)
+            .build();
+
+        // assert downloaded areas are not drawn
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^g+bv+bg+$",
+            true
+        );
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^g+bv+bg+$",
+            true
+        );
+
+        // enable "show downloaded areas"
+        GuiHelper.runInEDTAndWaitWithException(() -> this.getShowDownloadedAreaMenuItem().doClick());
+        assertTrue(this.getShowDownloadedAreaMenuItem().isSelected());
+
+        // assert downloaded areas are drawn
+        this.paintSlippyMap();
+
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^d+bq+v+bg+d+$",
+            true
+        );
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^d+bq+v+bg+d+$",
+            true
+        );
+
+        // also assert the leftmost column doesn't (yet) have any downloaded area marks (i.e. fully shaded)
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            0,
+            paletteMap,
+            "^d+$",
+            true
+        );
+
+        // add another downloaded area, going off the left of the widget
+        dataSet.addDataSource(new DataSource(new Bounds(51.745, -1., 51.765, 0.0162), "Somewhere else"));
+        // and redraw
+        this.paintSlippyMap();
+
+        // the middle row should be as before
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^d+bq+v+bg+d+$",
+            true
+        );
+        // the middle column should have its unshaded region extended beyond the viewport marker
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^d+g+bv+bg+d+$",
+            true
+        );
+        // but the leftmost column should now have an unshaded mark
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            0,
+            paletteMap,
+            "^d+g+d+$",
+            true
+        );
+        // and the rightmost column should be untouched
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()-1,
+            paletteMap,
+            "^d+$",
+            true
+        );
+
+        // and now if we pan to the left (in EastNorth units)
+        mapView.zoomTo(mapView.getCenter().add(-5000., 0.));
+        // and redraw
+        this.paintSlippyMap();
+
+        // the middle row should have its unshaded region outside the viewport marker
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^d+bq+bd+g+d*$",
+            true
+        );
+        // the middle column should have a shaded region inside the viewport marker
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^d+g+bv+q+bd+$",
+            true
+        );
+    }
+
+    /**
+     * test display of downloaded area follows active layer switching
+     * @throws Exception if any error occurs
+     */
+    @Test
+    public void testShowDownloadedAreaLayerSwitching() throws Exception {
+        Main.pref.put("slippy_map_chooser.mapstyle", "Green Tiles");
+        Main.pref.putBoolean("slippy_map_chooser.show_downloaded_area", true);
+
+        DataSet dataSetA = new DataSet();
+        // dataSetA has a long thin horizontal downloaded area (extending off the left & right of the map)
+        dataSetA.addDataSource(new DataSource(new Bounds(-18., -61.02, -15., -60.98), "Elsewhere"));
+
+        OsmDataLayer dataLayerA = new OsmDataLayer(
+            dataSetA,
+            "Test Layer A",
+            null
+        );
+        MainApplication.getLayerManager().addLayer(dataLayerA);
+
+        DataSet dataSetB = new DataSet();
+        // dataSetB has a long thin vertical downloaded area (extending off the top & bottom of the map)
+        dataSetB.addDataSource(new DataSource(new Bounds(-16.38, -62., -16.34, -60.), "Nowhere"));
+
+        OsmDataLayer dataLayerB = new OsmDataLayer(
+            dataSetB,
+            "Test Layer B",
+            null
+        );
+        MainApplication.getLayerManager().addLayer(dataLayerB);
+
+        MainApplication.getLayerManager().setActiveLayer(dataLayerB);
+
+        MapView mapView = MainApplication.getMap().mapView;
+        GuiHelper.runInEDTAndWaitWithException(() -> {
+            mapView.setVisible(true);
+            mapView.addNotify();
+            mapView.doLayout();
+            mapView.setBounds(0, 0, 400, 400);
+        });
+
+        this.setUpMiniMap();
+
+        // assert "show downloaded areas" checkbox is checked
+        assertTrue(this.getShowDownloadedAreaMenuItem().isSelected());
+
+        // again, we won't end up with exactly this viewport as it doesn't *precisely* match the aspect ratio
+        mapView.zoomTo(new Bounds(-16.423, -61.076, -16.299, -60.932));
+
+        // 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(0xff00ff00, "g")  // green
+            .put(0xff000000, "b")  // black
+            .put(0xff8ad16b, "v")  // viewport marker inner (pink+green mix)
+            .put(0xff00df00, "d")  // (shaded green)
+            .put(0xff8ac46b, "q")  // (shaded pink+green mix)
+            .build();
+
+        // the middle row should be entirely unshaded
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^g+bv+bg+$",
+            true
+        );
+        // the middle column should have an unshaded band within the viewport marker
+        Matcher centerMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^(d+bq+)(v+)(q+bd+)$",
+            true
+        );
+        // the leftmost and rightmost columns should have an unshaded band
+        Matcher leftMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            0,
+            paletteMap,
+            "^(d+)(g+)(d+)$",
+            true
+        );
+        Matcher rightMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()-1,
+            paletteMap,
+            "^(d+)(g+)(d+)$",
+            true
+        );
+        // the three columns should have the unshaded band in the same place
+        assertEquals(centerMatcher.group(1).length(), leftMatcher.group(1).length());
+        assertEquals(centerMatcher.group(1).length(), rightMatcher.group(1).length());
+        assertEquals(centerMatcher.group(2).length(), leftMatcher.group(2).length());
+        assertEquals(centerMatcher.group(2).length(), rightMatcher.group(2).length());
+
+        // switch active layer
+        MainApplication.getLayerManager().setActiveLayer(dataLayerA);
+        this.paintSlippyMap();
+
+        // the middle column should be entirely unshaded
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^g+bv+bg+$",
+            true
+        );
+        // the middle row should have an unshaded band within the viewport marker
+        centerMatcher = ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^(d+bq+)(v+)(q+bd+)$",
+            true
+        );
+        // the topmost and bottommost rows should have an unshaded band
+        Matcher topMatcher = ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            0,
+            paletteMap,
+            "^(d+)(g+)(d+)$",
+            true
+        );
+        Matcher BottomMatcher = ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()-1,
+            paletteMap,
+            "^(d+)(g+)(d+)$",
+            true
+        );
+        // the three rows should have the unshaded band in the same place
+        assertEquals(centerMatcher.group(1).length(), topMatcher.group(1).length());
+        assertEquals(centerMatcher.group(1).length(), BottomMatcher.group(1).length());
+        assertEquals(centerMatcher.group(2).length(), topMatcher.group(2).length());
+        assertEquals(centerMatcher.group(2).length(), BottomMatcher.group(2).length());
+
+        // deleting dataLayerA should hopefully switch our active layer back to dataLayerB
+        MainApplication.getLayerManager().removeLayer(dataLayerA);
+        this.paintSlippyMap();
+
+        // now we're really just repeating the same assertions we made originally when dataLayerB was active
+        // the middle row should be entirely unshaded
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^g+bv+bg+$",
+            true
+        );
+        // the middle column should have an unshaded band within the viewport marker
+        centerMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^(d+bq+)(v+)(q+bd+)$",
+            true
+        );
+        // the leftmost and rightmost columns should have an unshaded band
+        leftMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            0,
+            paletteMap,
+            "^(d+)(g+)(d+)$",
+            true
+        );
+        rightMatcher = ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()-1,
+            paletteMap,
+            "^(d+)(g+)(d+)$",
+            true
+        );
+        // the three columns should have the unshaded band in the same place
+        assertEquals(centerMatcher.group(1).length(), leftMatcher.group(1).length());
+        assertEquals(centerMatcher.group(1).length(), rightMatcher.group(1).length());
+        assertEquals(centerMatcher.group(2).length(), leftMatcher.group(2).length());
+        assertEquals(centerMatcher.group(2).length(), rightMatcher.group(2).length());
+
+        // but now if we expand its downloaded area to cover most of the southern hemisphere...
+        dataSetB.addDataSource(new DataSource(new Bounds(-75., -100., 0., 100.), "Everywhere"));
+        this.paintSlippyMap();
+
+        // we should see it all as unshaded.
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            0,
+            paletteMap,
+            "^g+$",
+            true
+        );
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()/2,
+            paletteMap,
+            "^g+bv+bg+$",
+            true
+        );
+        ImagePatternMatching.rowMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getHeight()-1,
+            paletteMap,
+            "^g+$",
+            true
+        );
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            0,
+            paletteMap,
+            "^g+$",
+            true
+        );
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()/2,
+            paletteMap,
+            "^g+bv+bg+$",
+            true
+        );
+        ImagePatternMatching.columnMatch(
+            paintedSlippyMap,
+            paintedSlippyMap.getWidth()-1,
+            paletteMap,
+            "^g+$",
+            true
+        );
+    }
 }
Index: trunk/test/unit/org/openstreetmap/josm/gui/io/AsynchronousUploadPrimitivesTaskTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/gui/io/AsynchronousUploadPrimitivesTaskTest.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/gui/io/AsynchronousUploadPrimitivesTaskTest.java	(revision 14052)
@@ -3,4 +3,6 @@
 
 import java.util.Optional;
+
+import javax.swing.JOptionPane;
 
 import org.junit.After;
@@ -18,4 +20,7 @@
 import org.openstreetmap.josm.io.UploadStrategySpecification;
 import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.testutils.mockers.JOptionPaneSimpleMocker;
+
+import com.google.common.collect.ImmutableMap;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -37,8 +42,15 @@
     @Rule
     @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
-    public JOSMTestRules test = new JOSMTestRules();
+    public JOSMTestRules test = new JOSMTestRules().assertionsInEDT();
 
+    /**
+     * Bootstrap.
+     */
     @Before
     public void bootStrap() {
+        new JOptionPaneSimpleMocker(ImmutableMap.of(
+            "A background upload is already in progress. Kindly wait for it to finish before uploading new changes", JOptionPane.OK_OPTION
+        ));
+
         DataSet dataSet = new DataSet();
         Node node1 = new Node();
@@ -60,4 +72,7 @@
     }
 
+    /**
+     * Tear down.
+     */
     @After
     public void tearDown() {
@@ -70,4 +85,7 @@
     }
 
+    /**
+     * Test single upload instance.
+     */
     @Test
     public void testSingleUploadInstance() {
Index: trunk/test/unit/org/openstreetmap/josm/gui/preferences/advanced/ExportProfileActionTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/gui/preferences/advanced/ExportProfileActionTest.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/gui/preferences/advanced/ExportProfileActionTest.java	(revision 14052)
@@ -2,8 +2,15 @@
 package org.openstreetmap.josm.gui.preferences.advanced;
 
-import org.junit.BeforeClass;
+import javax.swing.JOptionPane;
+
+import org.junit.Rule;
 import org.junit.Test;
-import org.openstreetmap.josm.JOSMFixture;
 import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.testutils.mockers.JOptionPaneSimpleMocker;
+
+import com.google.common.collect.ImmutableMap;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 
 /**
@@ -11,12 +18,10 @@
  */
 public class ExportProfileActionTest {
-
     /**
-     * Setup test.
+     * Setup tests
      */
-    @BeforeClass
-    public static void setUpBeforeClass() {
-        JOSMFixture.createUnitTestFixture().init();
-    }
+    @Rule
+    @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
+    public JOSMTestRules test = new JOSMTestRules().preferences().assertionsInEDT();
 
     /**
@@ -25,4 +30,7 @@
     @Test
     public void testAction() {
+        new JOptionPaneSimpleMocker(ImmutableMap.of(
+            "All the preferences of this group are default, nothing to save", JOptionPane.OK_OPTION
+        ));
         new ExportProfileAction(Main.pref, "foo", "bar").actionPerformed(null);
         new ExportProfileAction(Main.pref, "expert", "expert").actionPerformed(null);
Index: trunk/test/unit/org/openstreetmap/josm/gui/preferences/advanced/PreferencesTableTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/gui/preferences/advanced/PreferencesTableTest.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/gui/preferences/advanced/PreferencesTableTest.java	(revision 14052)
@@ -9,9 +9,18 @@
 import java.util.Arrays;
 
-import org.junit.BeforeClass;
+import javax.swing.JOptionPane;
+
+import org.junit.Rule;
 import org.junit.Test;
-import org.openstreetmap.josm.JOSMFixture;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.gui.preferences.advanced.PreferencesTable.AllSettingsTableModel;
 import org.openstreetmap.josm.spi.preferences.StringSetting;
-import org.openstreetmap.josm.gui.preferences.advanced.PreferencesTable.AllSettingsTableModel;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.testutils.mockers.ExtendedDialogMocker;
+import org.openstreetmap.josm.testutils.mockers.JOptionPaneSimpleMocker;
+
+import com.google.common.collect.ImmutableMap;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 
 /**
@@ -19,12 +28,10 @@
  */
 public class PreferencesTableTest {
-
     /**
-     * Setup test.
+     * Setup tests
      */
-    @BeforeClass
-    public static void setUpBeforeClass() {
-        JOSMFixture.createUnitTestFixture().init();
-    }
+    @Rule
+    @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
+    public JOSMTestRules test = new JOSMTestRules().preferences().assertionsInEDT();
 
     private static PrefEntry newPrefEntry(String value) {
@@ -43,4 +50,18 @@
     @Test
     public void testPreferencesTable() {
+        new JOptionPaneSimpleMocker(ImmutableMap.of(
+            "Please select the row to edit.", JOptionPane.OK_OPTION,
+            "Please select the row to delete.", JOptionPane.OK_OPTION
+        ));
+        new ExtendedDialogMocker() {
+            @Override
+            protected int getMockResult(final ExtendedDialog instance) {
+                if (instance.getTitle().equals("Add setting")) {
+                    return 1 + this.getButtonPositionFromLabel(instance, "Cancel");
+                } else {
+                    return super.getMockResult(instance);
+                }
+            }
+        };
         PreferencesTable t = newTable();
         t.fireDataChanged();
Index: trunk/test/unit/org/openstreetmap/josm/gui/preferences/plugin/PluginPreferenceHighLevelTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/gui/preferences/plugin/PluginPreferenceHighLevelTest.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/gui/preferences/plugin/PluginPreferenceHighLevelTest.java	(revision 14052)
@@ -0,0 +1,836 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.plugin;
+
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.awt.Component;
+import java.awt.Window;
+import java.io.File;
+import java.nio.file.Files;
+import java.util.Collection;
+
+import javax.swing.JOptionPane;
+
+import org.awaitility.Awaitility;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.plugins.PluginHandler;
+import org.openstreetmap.josm.plugins.PluginProxy;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.testutils.PluginServer;
+import org.openstreetmap.josm.testutils.mockers.HelpAwareOptionPaneMocker;
+import org.openstreetmap.josm.testutils.mockers.JOptionPaneSimpleMocker;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit.WireMockRule;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+/**
+ * Higher level tests of {@link PluginPreference} class.
+ */
+public class PluginPreferenceHighLevelTest {
+    /**
+     * Setup test.
+     */
+    @Rule
+    @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
+    public JOSMTestRules test = new JOSMTestRules().assumeRevision(
+        "Revision: 10000\n"
+    ).preferences().main().assertionsInEDT().platform();
+
+    /**
+     * Plugin server mock.
+     */
+    @Rule
+    public WireMockRule pluginServerRule = new WireMockRule(
+        options().dynamicPort().usingFilesUnderDirectory(TestUtils.getTestDataRoot())
+    );
+
+    /**
+     * Setup test.
+     * @throws ReflectiveOperationException never
+     */
+    @Before
+    public void setUp() throws ReflectiveOperationException {
+        if (!java.awt.GraphicsEnvironment.isHeadless()) {
+            originalMainParent = Main.parent;
+            Main.parent = new Window(null);
+        }
+
+        // some other tests actually go ahead and load plugins (notably at time of writing,
+        // MainApplicationTest$testUpdateAndLoadPlugins), which really isn't a reversible operation.
+        // it is, however, possible to pretend to our tests temporarily that they *aren't* loaded by
+        // setting the PluginHandler#pluginList to empty for the duration of this test. ideally these
+        // other tests wouldn't be so badly behaved or would at least do this from a separate batch
+        // but this works for now
+        @SuppressWarnings("unchecked")
+        final Collection<PluginProxy> pluginList = (Collection<PluginProxy>) TestUtils.getPrivateStaticField(
+            PluginHandler.class,
+            "pluginList"
+        );
+        this.originalPluginList = ImmutableList.copyOf(pluginList);
+        pluginList.clear();
+
+        Config.getPref().putInt("pluginmanager.version", 999);
+        Config.getPref().put("pluginmanager.lastupdate", "999");
+        Config.getPref().putList("pluginmanager.sites",
+            ImmutableList.of(String.format("http://localhost:%s/plugins", this.pluginServerRule.port()))
+        );
+
+        this.referenceDummyJarOld = new File(TestUtils.getTestDataRoot(), "__files/plugin/dummy_plugin.v31701.jar");
+        this.referenceDummyJarNew = new File(TestUtils.getTestDataRoot(), "__files/plugin/dummy_plugin.v31772.jar");
+        this.referenceBazJarOld = new File(TestUtils.getTestDataRoot(), "__files/plugin/baz_plugin.v6.jar");
+        this.referenceBazJarNew = new File(TestUtils.getTestDataRoot(), "__files/plugin/baz_plugin.v7.jar");
+        this.pluginDir = Main.pref.getPluginsDirectory();
+        this.targetDummyJar = new File(this.pluginDir, "dummy_plugin.jar");
+        this.targetDummyJarNew = new File(this.pluginDir, "dummy_plugin.jar.new");
+        this.targetBazJar = new File(this.pluginDir, "baz_plugin.jar");
+        this.targetBazJarNew = new File(this.pluginDir, "baz_plugin.jar.new");
+        this.pluginDir.mkdirs();
+    }
+
+    /**
+     * Tear down.
+     * @throws ReflectiveOperationException never
+     */
+    @After
+    public void tearDown() throws ReflectiveOperationException {
+        // restore actual PluginHandler#pluginList
+        @SuppressWarnings("unchecked")
+        final Collection<PluginProxy> pluginList = (Collection<PluginProxy>) TestUtils.getPrivateStaticField(
+            PluginHandler.class,
+            "pluginList"
+        );
+        pluginList.clear();
+        pluginList.addAll(this.originalPluginList);
+
+        if (!java.awt.GraphicsEnvironment.isHeadless()) {
+            Main.parent = originalMainParent;
+        }
+    }
+
+    private Collection<PluginProxy> originalPluginList;
+
+    private Component originalMainParent;
+
+    private File pluginDir;
+    private File referenceDummyJarOld;
+    private File referenceDummyJarNew;
+    private File referenceBazJarOld;
+    private File referenceBazJarNew;
+    private File targetDummyJar;
+    private File targetDummyJarNew;
+    private File targetBazJar;
+    private File targetBazJarNew;
+
+    /**
+     * Tests choosing a new plugin to install without upgrading an already-installed plugin
+     * @throws Exception never
+     */
+    @Test
+    public void testInstallWithoutUpdate() throws Exception {
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarNew),
+            new PluginServer.RemotePlugin(this.referenceBazJarOld),
+            new PluginServer.RemotePlugin(null, ImmutableMap.of("Plugin-Version", "2"), "irrelevant_plugin")
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of("dummy_plugin"));
+
+        final HelpAwareOptionPaneMocker haMocker = new HelpAwareOptionPaneMocker(
+            ImmutableMap.<String, Object>of(
+                "<html>The following plugin has been downloaded <strong>successfully</strong>:"
+                + "<ul><li>baz_plugin (6)</li></ul>"
+                + "You have to restart JOSM for some settings to take effect."
+                + "<br/><br/>Would you like to restart now?</html>",
+                "Cancel"
+            )
+        );
+
+        Files.copy(this.referenceDummyJarOld.toPath(), this.targetDummyJar.toPath());
+
+        final PreferenceTabbedPane tabbedPane = new PreferenceTabbedPane();
+
+        tabbedPane.buildGui();
+        // PluginPreference is already added to PreferenceTabbedPane by default
+        tabbedPane.selectTabByPref(PluginPreference.class);
+
+        GuiHelper.runInEDTAndWait(
+            () -> ((javax.swing.JButton) TestUtils.getComponentByName(tabbedPane, "downloadListButton")).doClick()
+        );
+
+        Awaitility.await().atMost(2000, MILLISECONDS).until(() -> Config.getPref().getInt("pluginmanager.version", 999) != 999);
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        WireMock.resetAllRequests();
+
+        final PluginPreferencesModel model = (PluginPreferencesModel) TestUtils.getPrivateField(
+            tabbedPane.getPluginPreference(),
+            "model"
+        );
+
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        // questionably correct
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+        assertEquals(model.getDisplayedPlugins(), model.getAvailablePlugins());
+
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin", "irrelevant_plugin"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("dummy_plugin"),
+            model.getSelectedPlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("(null)", "31701", "(null)"),
+            model.getAvailablePlugins().stream().map(
+                (pi) -> pi.localversion == null ? "(null)" : pi.localversion
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("6", "31772", "2"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.version).collect(ImmutableList.toImmutableList())
+        );
+
+        // now we're going to choose to install baz_plugin
+        model.setPluginSelected("baz_plugin", true);
+
+        assertEquals(
+            ImmutableList.of("baz_plugin"),
+            model.getNewlyActivatedPlugins().stream().map(
+                (pi) -> pi.getName()
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        assertEquals(
+            ImmutableList.of("baz_plugin"),
+            model.getPluginsScheduledForUpdateOrDownload().stream().map(
+                (pi) -> pi.getName()
+            ).collect(ImmutableList.toImmutableList())
+        );
+
+        tabbedPane.savePreferences();
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        assertEquals(1, haMocker.getInvocationLog().size());
+        Object[] invocationLogEntry = haMocker.getInvocationLog().get(0);
+        assertEquals(2, (int) invocationLogEntry[0]);
+        assertEquals("Restart", invocationLogEntry[2]);
+
+        // dummy_plugin jar shouldn't have been updated
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarOld, this.targetDummyJar);
+        // baz_plugin jar should have been installed
+        TestUtils.assertFileContentsEqual(this.referenceBazJarOld, this.targetBazJar);
+
+        // neither of these .jar.new files should have been left hanging round
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        // the advertized version of dummy_plugin shouldn't have been fetched
+        this.pluginServerRule.verify(0, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/dummy_plugin.v31772.jar")));
+        // but the advertized version of baz_plugin *should* have
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/baz_plugin.v6.jar")));
+
+        // pluginmanager.version has been set to the current version
+        // questionably correct
+        assertEquals(10000, Config.getPref().getInt("pluginmanager.version", 111));
+        // however pluginmanager.lastupdate hasn't been updated
+        // questionably correct
+        assertEquals("999", Config.getPref().get("pluginmanager.lastupdate", "111"));
+
+        // baz_plugin should have been added to the plugins list
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            Config.getPref().getList("plugins", null).stream().sorted().collect(ImmutableList.toImmutableList())
+        );
+    }
+
+    /**
+     * Tests a plugin being disabled without applying available upgrades
+     * @throws Exception never
+     */
+    @Test
+    public void testDisablePluginWithUpdatesAvailable() throws Exception {
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarNew),
+            new PluginServer.RemotePlugin(this.referenceBazJarNew),
+            new PluginServer.RemotePlugin(null, null, "irrelevant_plugin")
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of("baz_plugin", "dummy_plugin"));
+
+        final HelpAwareOptionPaneMocker haMocker = new HelpAwareOptionPaneMocker(
+            ImmutableMap.<String, Object>of(
+                "<html>You have to restart JOSM for some settings to take effect."
+                + "<br/><br/>Would you like to restart now?</html>",
+                "Cancel"
+            )
+        );
+
+        Files.copy(this.referenceDummyJarOld.toPath(), this.targetDummyJar.toPath());
+        Files.copy(this.referenceBazJarOld.toPath(), this.targetBazJar.toPath());
+
+        final PreferenceTabbedPane tabbedPane = new PreferenceTabbedPane();
+
+        tabbedPane.buildGui();
+        // PluginPreference is already added to PreferenceTabbedPane by default
+        tabbedPane.selectTabByPref(PluginPreference.class);
+
+        GuiHelper.runInEDTAndWait(
+            () -> ((javax.swing.JButton) TestUtils.getComponentByName(tabbedPane, "downloadListButton")).doClick()
+        );
+
+        Awaitility.await().atMost(2000, MILLISECONDS).until(() -> Config.getPref().getInt("pluginmanager.version", 999) != 999);
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        WireMock.resetAllRequests();
+
+        final PluginPreferencesModel model = (PluginPreferencesModel) TestUtils.getPrivateField(
+            tabbedPane.getPluginPreference(),
+            "model"
+        );
+
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        // questionably correct
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+        assertEquals(model.getDisplayedPlugins(), model.getAvailablePlugins());
+
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin", "irrelevant_plugin"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            model.getSelectedPlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("6", "31701", "(null)"),
+            model.getAvailablePlugins().stream().map(
+                (pi) -> pi.localversion == null ? "(null)" : pi.localversion
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("7", "31772", "(null)"),
+            model.getAvailablePlugins().stream().map(
+                (pi) -> pi.version == null ? "(null)" : pi.version
+            ).collect(ImmutableList.toImmutableList())
+        );
+
+        // now we're going to choose to disable baz_plugin
+        model.setPluginSelected("baz_plugin", false);
+
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertEquals(
+            ImmutableList.of("baz_plugin"),
+            model.getNewlyDeactivatedPlugins().stream().map(
+                (pi) -> pi.getName()
+            ).collect(ImmutableList.toImmutableList())
+        );
+        // questionably correct
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+
+        tabbedPane.savePreferences();
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        assertEquals(1, haMocker.getInvocationLog().size());
+        Object[] invocationLogEntry = haMocker.getInvocationLog().get(0);
+        assertEquals(2, (int) invocationLogEntry[0]);
+        assertEquals("Restart", invocationLogEntry[2]);
+
+        // dummy_plugin jar shouldn't have been updated
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarOld, this.targetDummyJar);
+        // baz_plugin jar shouldn't have been deleted
+        TestUtils.assertFileContentsEqual(this.referenceBazJarOld, this.targetBazJar);
+
+        // neither of these .jar.new files have a reason to be here
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        // neither of the new jars have been fetched
+        this.pluginServerRule.verify(0, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/dummy_plugin.v31772.jar")));
+        this.pluginServerRule.verify(0, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/baz_plugin.v6.jar")));
+
+        // pluginmanager.version has been set to the current version
+        // questionably correct
+        assertEquals(10000, Config.getPref().getInt("pluginmanager.version", 111));
+        // however pluginmanager.lastupdate hasn't been updated
+        // questionably correct
+        assertEquals("999", Config.getPref().get("pluginmanager.lastupdate", "111"));
+
+        // baz_plugin should have been removed from the installed plugins list
+        assertEquals(
+            ImmutableList.of("dummy_plugin"),
+            Config.getPref().getList("plugins", null).stream().sorted().collect(ImmutableList.toImmutableList())
+        );
+    }
+
+    /**
+     * Demonstrates behaviour exhibited when attempting to update a single plugin when multiple updates
+     * are available by deselecting it before clicking the update button then reselecting it.
+     *
+     * This is probably NOT desirable and should be fixed, however this test documents the behaviour.
+     * @throws Exception never
+     */
+    @Test
+    public void testUpdateOnlySelectedPlugin() throws Exception {
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarNew),
+            new PluginServer.RemotePlugin(this.referenceBazJarNew)
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of("baz_plugin", "dummy_plugin"));
+
+        final HelpAwareOptionPaneMocker haMocker = new HelpAwareOptionPaneMocker();
+        final JOptionPaneSimpleMocker jopsMocker = new JOptionPaneSimpleMocker();
+
+        Files.copy(this.referenceDummyJarOld.toPath(), this.targetDummyJar.toPath());
+        Files.copy(this.referenceBazJarOld.toPath(), this.targetBazJar.toPath());
+
+        final PreferenceTabbedPane tabbedPane = new PreferenceTabbedPane();
+
+        tabbedPane.buildGui();
+        // PluginPreference is already added to PreferenceTabbedPane by default
+        tabbedPane.selectTabByPref(PluginPreference.class);
+
+        GuiHelper.runInEDTAndWait(
+            () -> ((javax.swing.JButton) TestUtils.getComponentByName(tabbedPane, "downloadListButton")).doClick()
+        );
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        WireMock.resetAllRequests();
+
+        final PluginPreferencesModel model = (PluginPreferencesModel) TestUtils.getPrivateField(
+            tabbedPane.getPluginPreference(),
+            "model"
+        );
+
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        // questionably correct
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+        assertEquals(model.getDisplayedPlugins(), model.getAvailablePlugins());
+
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            model.getSelectedPlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("6", "31701"),
+            model.getAvailablePlugins().stream().map(
+                (pi) -> pi.localversion == null ? "(null)" : pi.localversion
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("7", "31772"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.version).collect(ImmutableList.toImmutableList())
+        );
+
+        // now we're going to choose not to update baz_plugin
+        model.setPluginSelected("baz_plugin", false);
+
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertEquals(
+            ImmutableList.of("baz_plugin"),
+            model.getNewlyDeactivatedPlugins().stream().map(
+                pi -> pi.getName()
+            ).collect(ImmutableList.toImmutableList())
+        );
+        // questionably correct
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+
+        // prepare haMocker to handle this message
+        haMocker.getMockResultMap().put(
+            "<html>The following plugin has been downloaded <strong>successfully</strong>:"
+            + "<ul><li>dummy_plugin (31772)</li></ul>Please restart JOSM to activate the "
+            + "downloaded plugins.</html>",
+            "OK"
+        );
+
+        GuiHelper.runInEDTAndWait(
+            () -> ((javax.swing.JButton) TestUtils.getComponentByName(tabbedPane, "updatePluginsButton")).doClick()
+        );
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        assertTrue(jopsMocker.getInvocationLog().isEmpty());
+        assertEquals(1, haMocker.getInvocationLog().size());
+        Object[] invocationLogEntry = haMocker.getInvocationLog().get(0);
+        assertEquals(0, (int) invocationLogEntry[0]);
+        assertEquals("Update plugins", invocationLogEntry[2]);
+
+        // dummy_plugin jar should have been updated
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarNew, this.targetDummyJar);
+        // but baz_plugin jar shouldn't have been
+        TestUtils.assertFileContentsEqual(this.referenceBazJarOld, this.targetBazJar);
+
+        // any .jar.new files should have been removed
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        // the plugin list was rechecked
+        // questionably necessary
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        // dummy_plugin has been fetched
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/dummy_plugin.v31772.jar")));
+        // baz_plugin has not
+        this.pluginServerRule.verify(0, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/baz_plugin.v7.jar")));
+        WireMock.resetAllRequests();
+
+        // pluginmanager.version has been set to the current version
+        // questionably correct
+        assertEquals(10000, Config.getPref().getInt("pluginmanager.version", 111));
+        // however pluginmanager.lastupdate hasn't been updated
+        // questionably correct
+        assertEquals("999", Config.getPref().get("pluginmanager.lastupdate", "111"));
+
+        // plugins list shouldn't have been altered, we haven't hit save yet
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            Config.getPref().getList("plugins", null).stream().sorted().collect(ImmutableList.toImmutableList())
+        );
+
+        // the model's selection state should be largely as before
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertEquals(
+            ImmutableList.of("baz_plugin"),
+            model.getNewlyDeactivatedPlugins().stream().map(
+                (pi) -> pi.getName()
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+
+        // but now we re-select baz_plugin so that it isn't removed/disabled
+        model.setPluginSelected("baz_plugin", true);
+
+        // this has caused baz_plugin to be interpreted as a plugin "for download"
+        // questionably correct
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        assertEquals(
+            ImmutableList.of("baz_plugin"),
+            model.getPluginsScheduledForUpdateOrDownload().stream().map(
+                (pi) -> pi.getName()
+            ).collect(ImmutableList.toImmutableList())
+        );
+
+        // prepare jopsMocker to handle this message
+        jopsMocker.getMockResultMap().put(
+            "<html>The following plugin has been downloaded <strong>successfully</strong>:"
+            + "<ul><li>baz_plugin (7)</li></ul></html>",
+            JOptionPane.OK_OPTION
+        );
+
+        tabbedPane.savePreferences();
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        // from previous haMocker invocation
+        assertEquals(1, haMocker.getInvocationLog().size());
+        // we've been alerted that (the new version of) baz_plugin was installed
+        // questionably correct
+        assertEquals(1, jopsMocker.getInvocationLog().size());
+        invocationLogEntry = jopsMocker.getInvocationLog().get(0);
+        assertEquals(JOptionPane.OK_OPTION, (int) invocationLogEntry[0]);
+        assertEquals("Warning", invocationLogEntry[2]);
+
+        // dummy_plugin jar is still the updated version
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarNew, this.targetDummyJar);
+        // but now the baz_plugin jar has been too
+        // questionably correct
+        TestUtils.assertFileContentsEqual(this.referenceBazJarNew, this.targetBazJar);
+
+        // all .jar.new files have been deleted
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        // dummy_plugin was not fetched
+        this.pluginServerRule.verify(0, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/dummy_plugin.v31772.jar")));
+        // baz_plugin however was fetched
+        // questionably correct
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/baz_plugin.v7.jar")));
+
+        assertEquals(10000, Config.getPref().getInt("pluginmanager.version", 111));
+        // questionably correct
+        assertEquals("999", Config.getPref().get("pluginmanager.lastupdate", "111"));
+    }
+
+    /**
+     * Tests the effect of requesting a "plugin update" when everything is up to date
+     * @throws Exception never
+     */
+    @Test
+    public void testUpdateWithNoAvailableUpdates() throws Exception {
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarOld),
+            new PluginServer.RemotePlugin(this.referenceBazJarOld),
+            new PluginServer.RemotePlugin(null, ImmutableMap.of("Plugin-Version", "123"), "irrelevant_plugin")
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of("baz_plugin", "dummy_plugin"));
+
+        final HelpAwareOptionPaneMocker haMocker = new HelpAwareOptionPaneMocker(
+            ImmutableMap.<String, Object>of(
+                "All installed plugins are up to date. JOSM does not have to download newer versions.",
+                "OK"
+            )
+        );
+        final JOptionPaneSimpleMocker jopsMocker = new JOptionPaneSimpleMocker();
+
+        Files.copy(this.referenceDummyJarOld.toPath(), this.targetDummyJar.toPath());
+        Files.copy(this.referenceBazJarOld.toPath(), this.targetBazJar.toPath());
+
+        final PreferenceTabbedPane tabbedPane = new PreferenceTabbedPane();
+
+        tabbedPane.buildGui();
+        // PluginPreference is already added to PreferenceTabbedPane by default
+        tabbedPane.selectTabByPref(PluginPreference.class);
+
+        GuiHelper.runInEDTAndWait(
+            () -> ((javax.swing.JButton) TestUtils.getComponentByName(tabbedPane, "downloadListButton")).doClick()
+        );
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        WireMock.resetAllRequests();
+
+        final PluginPreferencesModel model = (PluginPreferencesModel) TestUtils.getPrivateField(
+            tabbedPane.getPluginPreference(),
+            "model"
+        );
+
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+        assertEquals(model.getDisplayedPlugins(), model.getAvailablePlugins());
+
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin", "irrelevant_plugin"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            model.getSelectedPlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("6", "31701", "(null)"),
+            model.getAvailablePlugins().stream().map(
+                (pi) -> pi.localversion == null ? "(null)" : pi.localversion
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("6", "31701", "123"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.version).collect(ImmutableList.toImmutableList())
+        );
+
+        GuiHelper.runInEDTAndWait(
+            () -> ((javax.swing.JButton) TestUtils.getComponentByName(tabbedPane, "updatePluginsButton")).doClick()
+        );
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        assertTrue(jopsMocker.getInvocationLog().isEmpty());
+        assertEquals(1, haMocker.getInvocationLog().size());
+        Object[] invocationLogEntry = haMocker.getInvocationLog().get(0);
+        assertEquals(0, (int) invocationLogEntry[0]);
+        assertEquals("Plugins up to date", invocationLogEntry[2]);
+
+        // neither jar should have changed
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarOld, this.targetDummyJar);
+        TestUtils.assertFileContentsEqual(this.referenceBazJarOld, this.targetBazJar);
+
+        // no reason for any .jar.new files
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        // the plugin list was rechecked
+        // questionably necessary
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        // that should have been the only request to our PluginServer
+        assertEquals(1, this.pluginServerRule.getAllServeEvents().size());
+        WireMock.resetAllRequests();
+
+        // pluginmanager.version has been set to the current version
+        assertEquals(10000, Config.getPref().getInt("pluginmanager.version", 111));
+        // pluginmanager.lastupdate hasn't been updated
+        // questionably correct
+        assertEquals("999", Config.getPref().get("pluginmanager.lastupdate", "111"));
+
+        // plugins list shouldn't have been altered
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            Config.getPref().getList("plugins", null).stream().sorted().collect(ImmutableList.toImmutableList())
+        );
+
+        // the model's selection state should be largely as before
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+
+        tabbedPane.savePreferences();
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        assertTrue(jopsMocker.getInvocationLog().isEmpty());
+        assertEquals(1, haMocker.getInvocationLog().size());
+
+        // both jars are still the original version
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarOld, this.targetDummyJar);
+        TestUtils.assertFileContentsEqual(this.referenceBazJarOld, this.targetBazJar);
+
+        // no reason for any .jar.new files
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        // none of PluginServer's URLs should have been touched
+        assertEquals(0, this.pluginServerRule.getAllServeEvents().size());
+
+        // pluginmanager.version has been set to the current version
+        assertEquals(10000, Config.getPref().getInt("pluginmanager.version", 111));
+        // pluginmanager.lastupdate hasn't been updated
+        // questionably correct
+        assertEquals("999", Config.getPref().get("pluginmanager.lastupdate", "111"));
+    }
+
+    /**
+     * Tests installing a single plugin which is marked as "Canloadatruntime"
+     * @throws Exception never
+     */
+    @Test
+    public void testInstallWithoutRestartRequired() throws Exception {
+        final boolean[] loadPluginsCalled = new boolean[] {false};
+        final mockit.MockUp<PluginHandler> pluginHandlerMocker = new mockit.MockUp<PluginHandler>() {
+            @mockit.Mock
+            private void loadPlugins(
+                final Component parent,
+                final Collection<org.openstreetmap.josm.plugins.PluginInformation> plugins,
+                final org.openstreetmap.josm.gui.progress.ProgressMonitor monitor
+            ) {
+                assertEquals(1, plugins.size());
+                assertEquals("dummy_plugin", plugins.iterator().next().name);
+                assertEquals("31772", plugins.iterator().next().localversion);
+                loadPluginsCalled[0] = true;
+            }
+        };
+
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarNew),
+            new PluginServer.RemotePlugin(this.referenceBazJarNew)
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of());
+
+        final HelpAwareOptionPaneMocker haMocker = new HelpAwareOptionPaneMocker();
+        final JOptionPaneSimpleMocker jopsMocker = new JOptionPaneSimpleMocker(ImmutableMap.<String, Object>of(
+            "<html>The following plugin has been downloaded <strong>successfully</strong>:"
+            + "<ul><li>dummy_plugin (31772)</li></ul></html>",
+            JOptionPane.OK_OPTION
+        ));
+
+        final PreferenceTabbedPane tabbedPane = new PreferenceTabbedPane();
+
+        tabbedPane.buildGui();
+        // PluginPreference is already added to PreferenceTabbedPane by default
+        tabbedPane.selectTabByPref(PluginPreference.class);
+
+        GuiHelper.runInEDTAndWait(
+            () -> ((javax.swing.JButton) TestUtils.getComponentByName(tabbedPane, "downloadListButton")).doClick()
+        );
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        WireMock.resetAllRequests();
+
+        final PluginPreferencesModel model = (PluginPreferencesModel) TestUtils.getPrivateField(
+            tabbedPane.getPluginPreference(),
+            "model"
+        );
+
+        assertTrue(model.getNewlyActivatedPlugins().isEmpty());
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+        assertTrue(model.getPluginsScheduledForUpdateOrDownload().isEmpty());
+        assertEquals(model.getDisplayedPlugins(), model.getAvailablePlugins());
+
+        assertEquals(
+            ImmutableList.of("baz_plugin", "dummy_plugin"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.getName()).collect(ImmutableList.toImmutableList())
+        );
+        assertTrue(model.getSelectedPlugins().isEmpty());
+        assertEquals(
+            ImmutableList.of("(null)", "(null)"),
+            model.getAvailablePlugins().stream().map(
+                (pi) -> pi.localversion == null ? "(null)" : pi.localversion
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertEquals(
+            ImmutableList.of("7", "31772"),
+            model.getAvailablePlugins().stream().map((pi) -> pi.version).collect(ImmutableList.toImmutableList())
+        );
+
+        // now we select dummy_plugin
+        model.setPluginSelected("dummy_plugin", true);
+
+        // model should now reflect this
+        assertEquals(
+            ImmutableList.of("dummy_plugin"),
+            model.getNewlyActivatedPlugins().stream().map(
+                pi -> pi.getName()
+            ).collect(ImmutableList.toImmutableList())
+        );
+        assertTrue(model.getNewlyDeactivatedPlugins().isEmpty());
+
+        tabbedPane.savePreferences();
+
+        TestUtils.syncEDTAndWorkerThreads();
+
+        assertEquals(1, jopsMocker.getInvocationLog().size());
+        org.openstreetmap.josm.tools.Logging.error(jopsMocker.getInvocationLog().get(0)[0].toString());
+        Object[] invocationLogEntry = jopsMocker.getInvocationLog().get(0);
+        assertEquals(JOptionPane.OK_OPTION, (int) invocationLogEntry[0]);
+        assertEquals("Warning", invocationLogEntry[2]);
+
+        assertTrue(haMocker.getInvocationLog().isEmpty());
+
+        // any .jar.new files should have been deleted
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        // dummy_plugin was fetched
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/dummy_plugin.v31772.jar")));
+
+        // loadPlugins(...) was called (with expected parameters)
+        assertTrue(loadPluginsCalled[0]);
+
+        // pluginmanager.version has been set to the current version
+        assertEquals(10000, Config.getPref().getInt("pluginmanager.version", 111));
+        // pluginmanager.lastupdate hasn't been updated
+        // questionably correct
+        assertEquals("999", Config.getPref().get("pluginmanager.lastupdate", "111"));
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/gui/preferences/plugin/PluginPreferenceTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/gui/preferences/plugin/PluginPreferenceTest.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/gui/preferences/plugin/PluginPreferenceTest.java	(revision 14052)
@@ -10,7 +10,6 @@
 import java.util.Collections;
 
-import org.junit.BeforeClass;
+import org.junit.Rule;
 import org.junit.Test;
-import org.openstreetmap.josm.JOSMFixture;
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.gui.preferences.PreferencesTestUtils;
@@ -19,4 +18,10 @@
 import org.openstreetmap.josm.plugins.PluginException;
 import org.openstreetmap.josm.plugins.PluginInformation;
+import org.openstreetmap.josm.testutils.mockers.HelpAwareOptionPaneMocker;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import com.google.common.collect.ImmutableMap;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 
 /**
@@ -24,12 +29,10 @@
  */
 public class PluginPreferenceTest {
-
     /**
      * Setup test.
      */
-    @BeforeClass
-    public static void setUpBeforeClass() {
-        JOSMFixture.createUnitTestFixture().init();
-    }
+    @Rule
+    @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
+    public JOSMTestRules test = new JOSMTestRules().preferences().assertionsInEDT().platform();
 
     /**
@@ -48,5 +51,5 @@
     public static PluginInformation getDummyPluginInformation() throws PluginException {
         return new PluginInformation(
-                new File(TestUtils.getTestDataRoot() + "plugin/dummy_plugin.jar"), "dummy_plugin");
+                new File(TestUtils.getTestDataRoot() + "__files/plugin/dummy_plugin.v31772.jar"), "dummy_plugin");
     }
 
@@ -89,4 +92,10 @@
     @Test
     public void testNotifyDownloadResults() {
+        new HelpAwareOptionPaneMocker(ImmutableMap.<String, Object>builder()
+            .put("<html></html>", "OK")  // (buildDownloadSummary() output was empty)
+            .put("<html>Please restart JOSM to activate the downloaded plugins.</html>", "OK")
+            .build()
+        );
+
         PluginDownloadTask task = new PluginDownloadTask(NullProgressMonitor.INSTANCE, Collections.<PluginInformation>emptyList(), "");
         PluginPreference.notifyDownloadResults(null, task, false);
Index: trunk/test/unit/org/openstreetmap/josm/plugins/PluginHandlerJOSMTooOldTest.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/plugins/PluginHandlerJOSMTooOldTest.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/plugins/PluginHandlerJOSMTooOldTest.java	(revision 14052)
@@ -0,0 +1,281 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins;
+
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.testutils.PluginServer;
+import org.openstreetmap.josm.testutils.mockers.ExtendedDialogMocker;
+import org.openstreetmap.josm.testutils.mockers.HelpAwareOptionPaneMocker;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit.WireMockRule;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+/**
+ * Test parts of {@link PluginHandler} class when the reported JOSM version is too old for the plugin.
+ */
+public class PluginHandlerJOSMTooOldTest {
+    /**
+     * Setup test.
+     */
+    @Rule
+    @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
+    public JOSMTestRules test = new JOSMTestRules().preferences().main().assumeRevision(
+        "Revision: 6000\n"
+    );
+
+    /**
+     * Plugin server mock.
+     */
+    @Rule
+    public WireMockRule pluginServerRule = new WireMockRule(
+        options().dynamicPort().usingFilesUnderDirectory(TestUtils.getTestDataRoot())
+    );
+
+    /**
+     * Setup test.
+     */
+    @Before
+    public void setUp() {
+        Config.getPref().putInt("pluginmanager.version", 999);
+        Config.getPref().put("pluginmanager.lastupdate", "999");
+        Config.getPref().putList("pluginmanager.sites",
+            ImmutableList.of(String.format("http://localhost:%s/plugins", this.pluginServerRule.port()))
+        );
+
+        this.referenceDummyJarOld = new File(TestUtils.getTestDataRoot(), "__files/plugin/dummy_plugin.v31701.jar");
+        this.referenceDummyJarNew = new File(TestUtils.getTestDataRoot(), "__files/plugin/dummy_plugin.v31772.jar");
+        this.referenceBazJarOld = new File(TestUtils.getTestDataRoot(), "__files/plugin/baz_plugin.v6.jar");
+        this.referenceBazJarNew = new File(TestUtils.getTestDataRoot(), "__files/plugin/baz_plugin.v7.jar");
+        this.pluginDir = Main.pref.getPluginsDirectory();
+        this.targetDummyJar = new File(this.pluginDir, "dummy_plugin.jar");
+        this.targetDummyJarNew = new File(this.pluginDir, "dummy_plugin.jar.new");
+        this.targetBazJar = new File(this.pluginDir, "baz_plugin.jar");
+        this.targetBazJarNew = new File(this.pluginDir, "baz_plugin.jar.new");
+        this.pluginDir.mkdirs();
+    }
+
+    private File pluginDir;
+    private File referenceDummyJarOld;
+    private File referenceDummyJarNew;
+    private File referenceBazJarOld;
+    private File referenceBazJarNew;
+    private File targetDummyJar;
+    private File targetDummyJarNew;
+    private File targetBazJar;
+    private File targetBazJarNew;
+
+    private final String bazPluginVersionReqString = "JOSM version 8,001 required for plugin baz_plugin.";
+    private final String dummyPluginVersionReqString = "JOSM version 7,001 required for plugin dummy_plugin.";
+    private final String dummyPluginFailedString = "<html>Updating the following plugin has failed:<ul><li>dummy_plugin</li></ul>"
+        + "Please open the Preference Dialog after JOSM has started and try to update it manually.</html>";
+
+    /**
+     * test update of plugins when those plugins turn out to require a higher JOSM version, but the
+     * user chooses to update them anyway.
+     * @throws IOException never
+     */
+    @Test
+    public void testUpdatePluginsDownloadBoth() throws IOException {
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarNew),
+            new PluginServer.RemotePlugin(this.referenceBazJarNew)
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of("dummy_plugin", "baz_plugin"));
+
+        final ExtendedDialogMocker edMocker = new ExtendedDialogMocker(ImmutableMap.<String, Object>builder()
+            .put(this.bazPluginVersionReqString, "Download Plugin")
+            .put(this.dummyPluginVersionReqString, "Download Plugin")
+            .build()
+        );
+
+        Files.copy(this.referenceDummyJarOld.toPath(), this.targetDummyJar.toPath());
+        Files.copy(this.referenceBazJarOld.toPath(), this.targetBazJar.toPath());
+
+        final List<PluginInformation> updatedPlugins = PluginHandler.updatePlugins(
+            Main.parent,
+            null,
+            null,
+            false
+        ).stream().sorted((a, b) -> a.name.compareTo(b.name)).collect(ImmutableList.toImmutableList());
+
+        assertEquals(
+            ImmutableList.of(
+                this.dummyPluginVersionReqString,
+                this.bazPluginVersionReqString
+            ),
+            edMocker.getInvocationLog().stream().map(
+                invocationEntry -> invocationEntry[1]
+            ).sorted().collect(ImmutableList.toImmutableList())
+        );
+
+        assertEquals(2, updatedPlugins.size());
+
+        assertEquals(updatedPlugins.get(0).name, "baz_plugin");
+        assertEquals("7", updatedPlugins.get(0).localversion);
+
+        assertEquals(updatedPlugins.get(1).name, "dummy_plugin");
+        assertEquals("31772", updatedPlugins.get(1).localversion);
+
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarNew, this.targetDummyJar);
+        TestUtils.assertFileContentsEqual(this.referenceBazJarNew, this.targetBazJar);
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/dummy_plugin.v31772.jar")));
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/baz_plugin.v7.jar")));
+
+        assertEquals(Config.getPref().getInt("pluginmanager.version", 111), 6000);
+        // not mocking the time so just check it's not its original value
+        assertNotEquals(Config.getPref().get("pluginmanager.lastupdate", "999"), "999");
+    }
+
+    /**
+     * test update of plugins when those plugins turn out to require a higher JOSM version, but the
+     * user chooses to update one and skip the other.
+     * @throws IOException never
+     */
+    @Test
+    public void testUpdatePluginsSkipOne() throws IOException {
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarNew),
+            new PluginServer.RemotePlugin(this.referenceBazJarNew)
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of("dummy_plugin", "baz_plugin"));
+
+        final ExtendedDialogMocker edMocker = new ExtendedDialogMocker(ImmutableMap.<String, Object>builder()
+            .put(this.bazPluginVersionReqString, "Download Plugin")
+            .put(this.dummyPluginVersionReqString, "Skip Download")
+            .build()
+        );
+        final HelpAwareOptionPaneMocker haMocker = new HelpAwareOptionPaneMocker(ImmutableMap.<String, Object>builder()
+            .put(this.dummyPluginFailedString, "OK")
+            .build()
+        );
+
+        Files.copy(this.referenceDummyJarOld.toPath(), this.targetDummyJar.toPath());
+        Files.copy(this.referenceBazJarOld.toPath(), this.targetBazJar.toPath());
+
+        final List<PluginInformation> updatedPlugins = PluginHandler.updatePlugins(
+            Main.parent,
+            null,
+            null,
+            false
+        ).stream().sorted((a, b) -> a.name.compareTo(b.name)).collect(ImmutableList.toImmutableList());
+
+        assertEquals(
+            ImmutableList.of(
+                this.dummyPluginVersionReqString,
+                this.bazPluginVersionReqString
+            ),
+            edMocker.getInvocationLog().stream().map(
+                invocationEntry -> invocationEntry[1]
+            ).sorted().collect(ImmutableList.toImmutableList())
+        );
+
+        assertEquals(
+            ImmutableList.of(
+                this.dummyPluginFailedString
+            ),
+            haMocker.getInvocationLog().stream().map(
+                invocationEntry -> invocationEntry[1]
+            ).sorted().collect(ImmutableList.toImmutableList())
+        );
+
+        assertEquals(2, updatedPlugins.size());
+
+        assertEquals(updatedPlugins.get(0).name, "baz_plugin");
+        assertEquals("7", updatedPlugins.get(0).localversion);
+
+        assertEquals(updatedPlugins.get(1).name, "dummy_plugin");
+        assertEquals("31701", updatedPlugins.get(1).localversion);
+
+        assertFalse(targetDummyJarNew.exists());
+        assertFalse(targetBazJarNew.exists());
+
+        TestUtils.assertFileContentsEqual(this.referenceDummyJarOld, this.targetDummyJar);
+        TestUtils.assertFileContentsEqual(this.referenceBazJarNew, this.targetBazJar);
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        this.pluginServerRule.verify(0, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/dummy_plugin.v31772.jar")));
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/baz_plugin.v7.jar")));
+
+        // shouldn't have been updated
+        assertEquals(Config.getPref().getInt("pluginmanager.version", 111), 999);
+        assertEquals(Config.getPref().get("pluginmanager.lastupdate", "999"), "999");
+    }
+
+    /**
+     * When the plugin list suggests that the jar file at the provided URL *doesn't* require a newer JOSM
+     * but in fact the plugin served *does*, it is installed anyway.
+     *
+     * This is probably NOT desirable and should be fixed, however this test documents the behaviour.
+     * @throws IOException never
+     */
+    @Test
+    public void testUpdatePluginsUnexpectedlyJOSMTooOld() throws IOException {
+        final PluginServer pluginServer = new PluginServer(
+            new PluginServer.RemotePlugin(this.referenceDummyJarNew),
+            new PluginServer.RemotePlugin(this.referenceBazJarNew, ImmutableMap.of(
+                "Plugin-Mainversion", "5500"
+            ))
+        );
+        pluginServer.applyToWireMockServer(this.pluginServerRule);
+        Config.getPref().putList("plugins", ImmutableList.of("baz_plugin"));
+
+        // setting up blank ExtendedDialogMocker which would raise an exception if any attempt to show
+        // and ExtendedDialog were made
+        new ExtendedDialogMocker();
+
+        Files.copy(this.referenceBazJarOld.toPath(), this.targetBazJar.toPath());
+
+        final List<PluginInformation> updatedPlugins = ImmutableList.copyOf(PluginHandler.updatePlugins(
+            Main.parent,
+            null,
+            null,
+            false
+        ));
+
+        // questionably correct
+        assertEquals(1, updatedPlugins.size());
+
+        // questionably correct
+        assertEquals(updatedPlugins.get(0).name, "baz_plugin");
+        assertEquals("7", updatedPlugins.get(0).localversion);
+
+        assertFalse(targetBazJarNew.exists());
+
+        // questionably correct
+        TestUtils.assertFileContentsEqual(this.referenceBazJarNew, this.targetBazJar);
+
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugins")));
+        // questionably correct
+        this.pluginServerRule.verify(1, WireMock.getRequestedFor(WireMock.urlEqualTo("/plugin/baz_plugin.v7.jar")));
+
+        // should have been updated
+        assertEquals(Config.getPref().getInt("pluginmanager.version", 111), 6000);
+        assertNotEquals(Config.getPref().get("pluginmanager.lastupdate", "999"), "999");
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/JOSMTestRules.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/JOSMTestRules.java	(revision 14049)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/JOSMTestRules.java	(revision 14052)
@@ -3,4 +3,6 @@
 
 import java.awt.Color;
+import java.awt.Window;
+import java.awt.event.WindowEvent;
 import java.io.ByteArrayInputStream;
 import java.io.File;
@@ -8,5 +10,8 @@
 import java.security.GeneralSecurityException;
 import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Map;
 import java.util.TimeZone;
+import java.util.logging.Handler;
 
 import org.junit.rules.TemporaryFolder;
@@ -37,4 +42,8 @@
 import org.openstreetmap.josm.io.OsmTransferCanceledException;
 import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.spi.preferences.Setting;
+import org.openstreetmap.josm.testutils.mockers.EDTAssertionMocker;
+import org.openstreetmap.josm.testutils.mockers.WindowlessMapViewStateMocker;
+import org.openstreetmap.josm.testutils.mockers.WindowlessNavigatableComponentMocker;
 import org.openstreetmap.josm.tools.I18n;
 import org.openstreetmap.josm.tools.JosmRuntimeException;
@@ -44,4 +53,6 @@
 import org.openstreetmap.josm.tools.Territories;
 import org.openstreetmap.josm.tools.date.DateUtils;
+
+import org.awaitility.Awaitility;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -64,4 +75,7 @@
     private String assumeRevisionString;
     private Version originalVersion;
+    private Runnable mapViewStateMockingRunnable;
+    private Runnable navigableComponentMockingRunnable;
+    private Runnable edtAssertionMockingRunnable;
     private boolean platform;
     private boolean useProjection;
@@ -256,4 +270,23 @@
         territories();
         rlTraffic = true;
+        return this;
+    }
+
+    /**
+     * Re-raise AssertionErrors thrown in the EDT where they would have normally been swallowed.
+     * @return this instance, for easy chaining
+     */
+    public JOSMTestRules assertionsInEDT() {
+        return this.assertionsInEDT(EDTAssertionMocker::new);
+    }
+
+    /**
+     * Re-raise AssertionErrors thrown in the EDT where they would have normally been swallowed.
+     * @param edtAssertionMockingRunnable Runnable for initializing this functionality
+     *
+     * @return this instance, for easy chaining
+     */
+    public JOSMTestRules assertionsInEDT(final Runnable edtAssertionMockingRunnable) {
+        this.edtAssertionMockingRunnable = edtAssertionMockingRunnable;
         return this;
     }
@@ -297,6 +330,28 @@
      */
     public JOSMTestRules main() {
+        return this.main(
+            WindowlessMapViewStateMocker::new,
+            WindowlessNavigatableComponentMocker::new
+        );
+    }
+
+    /**
+     * Use the {@link Main#main}, {@code Main.contentPanePrivate}, {@code Main.mainPanel},
+     *         global variables in this test.
+     * @param mapViewStateMockingRunnable Runnable to use for mocking out any required parts of
+     *        {@link org.openstreetmap.josm.gui.MapViewState}, null to skip.
+     * @param navigableComponentMockingRunnable Runnable to use for mocking out any required parts
+     *        of {@link org.openstreetmap.josm.gui.NavigatableComponent}, null to skip.
+     *
+     * @return this instance, for easy chaining
+     */
+    public JOSMTestRules main(
+        final Runnable mapViewStateMockingRunnable,
+        final Runnable navigableComponentMockingRunnable
+    ) {
         platform();
-        main = true;
+        this.main = true;
+        this.mapViewStateMockingRunnable = mapViewStateMockingRunnable;
+        this.navigableComponentMockingRunnable = navigableComponentMockingRunnable;
         return this;
     }
@@ -344,7 +399,4 @@
      */
     protected void before() throws InitializationError, ReflectiveOperationException {
-        // Tests are running headless by default.
-        System.setProperty("java.awt.headless", "true");
-
         cleanUpFromJosmFixture();
 
@@ -355,10 +407,30 @@
         }
 
+        // Add JOSM home
+        if (josmHome != null) {
+            try {
+                File home = josmHome.newFolder();
+                System.setProperty("josm.home", home.getAbsolutePath());
+                JosmBaseDirectories.getInstance().clearMemos();
+            } catch (IOException e) {
+                throw new InitializationError(e);
+            }
+        }
+
         Config.setPreferencesInstance(Main.pref);
         Config.setBaseDirectoriesProvider(JosmBaseDirectories.getInstance());
         // All tests use the same timezone.
         TimeZone.setDefault(DateUtils.UTC);
+
+        // Force log handers to reacquire reference to (junit's fake) stdout/stderr
+        for (Handler handler : Logging.getLogger().getHandlers()) {
+            if (handler instanceof Logging.ReacquiringConsoleHandler) {
+                handler.flush();
+                ((Logging.ReacquiringConsoleHandler) handler).reacquireOutputStream();
+            }
+        }
         // Set log level to info
         Logging.setLogLevel(Logging.LEVEL_INFO);
+
         // Assume anonymous user
         UserIdentityManager.getInstance().setAnonymous();
@@ -373,16 +445,9 @@
         }
 
-        // Add JOSM home
-        if (josmHome != null) {
-            try {
-                File home = josmHome.newFolder();
-                System.setProperty("josm.home", home.getAbsolutePath());
-            } catch (IOException e) {
-                throw new InitializationError(e);
-            }
-        }
-
         // Add preferences
         if (usePreferences) {
+            @SuppressWarnings("unchecked")
+            final Map<String, Setting<?>> defaultsMap = (Map<String, Setting<?>>) TestUtils.getPrivateField(Main.pref, "defaultsMap");
+            defaultsMap.clear();
             Main.pref.resetToInitialState();
             Main.pref.enableSaveOnPut(false);
@@ -448,4 +513,8 @@
         }
 
+        if (this.edtAssertionMockingRunnable != null) {
+            this.edtAssertionMockingRunnable.run();
+        }
+
         if (commands) {
             // TODO: Implement a more selective version of this once Main is restructured.
@@ -453,4 +522,13 @@
         } else {
             if (main) {
+                // apply mockers to MapViewState and NavigableComponent whether we're headless or not
+                // as we generally don't create the josm main window even in non-headless mode.
+                if (this.mapViewStateMockingRunnable != null) {
+                    this.mapViewStateMockingRunnable.run();
+                }
+                if (this.navigableComponentMockingRunnable != null) {
+                    this.navigableComponentMockingRunnable.run();
+                }
+
                 new MainApplication();
                 JOSMFixture.initContentPane();
@@ -500,9 +578,9 @@
     protected void after() throws ReflectiveOperationException {
         // Sync AWT Thread
-        GuiHelper.runInEDTAndWait(new Runnable() {
-            @Override
-            public void run() {
-            }
-        });
+        GuiHelper.runInEDTAndWait(() -> { });
+        // Sync worker thread
+        final boolean[] queueEmpty = {false};
+        MainApplication.worker.submit(() -> queueEmpty[0] = true);
+        Awaitility.await().forever().until(() -> queueEmpty[0]);
         // Remove all layers
         cleanLayerEnvironment();
@@ -517,4 +595,19 @@
             TestUtils.setPrivateStaticField(Version.class, "instance", this.originalVersion);
         }
+
+        Window[] windows = Window.getWindows();
+        if (windows.length != 0) {
+            Logging.info(
+                "Attempting to close {0} windows left open by tests: {1}",
+                windows.length,
+                Arrays.toString(windows)
+            );
+        }
+        GuiHelper.runInEDTAndWait(() -> {
+            for (Window window : windows) {
+                window.dispatchEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSING));
+                window.dispose();
+            }
+        });
 
         // Parts of JOSM uses weak references - destroy them.
@@ -570,4 +663,5 @@
                     throw exception;
                 } else {
+                    Logging.debug("Thread state at timeout: {0}", Thread.getAllStackTraces());
                     throw new Exception(MessageFormat.format("Test timed out after {0}ms", timeout));
                 }
Index: trunk/test/unit/org/openstreetmap/josm/testutils/PluginServer.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/PluginServer.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/PluginServer.java	(revision 14052)
@@ -0,0 +1,249 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.jar.JarFile;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.tools.Logging;
+
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import com.google.common.collect.ImmutableList;
+
+import com.github.tomakehurst.wiremock.client.MappingBuilder;
+import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.core.Options;
+import com.github.tomakehurst.wiremock.junit.WireMockRule;
+import com.github.tomakehurst.wiremock.WireMockServer;
+
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+
+public class PluginServer {
+    public static class RemotePlugin {
+        private final File srcJar;
+        private final Map<String, String> attrOverrides;
+        private final String pluginName;
+        private final String pluginURL;
+
+        public RemotePlugin(
+            final File srcJar
+        ) {
+            this(srcJar, null, null, null);
+        }
+
+        public RemotePlugin(
+            final File srcJar,
+            final Map<String, String> attrOverrides
+        ) {
+            this(srcJar, attrOverrides, null, null);
+        }
+
+        public RemotePlugin(
+            final File srcJar,
+            final Map<String, String> attrOverrides,
+            final String pluginName
+        ) {
+            this(srcJar, attrOverrides, pluginName, null);
+        }
+
+        public RemotePlugin(
+            final File srcJar,
+            final Map<String, String> attrOverrides,
+            final String pluginName,
+            final String pluginURL
+        ) {
+            this.srcJar = srcJar;
+            this.attrOverrides = attrOverrides;
+            this.pluginName = pluginName;
+            this.pluginURL = pluginURL;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(this.srcJar, this.attrOverrides, this.pluginName, this.pluginURL, this.getClass());
+        }
+
+        public String getRemotePluginsListManifestSection() {
+            final Map<String, String> attrs = new HashMap<String, String>();
+            JarFile jarFile = null;
+
+            if (srcJar != null) {
+                try {
+                    jarFile = new JarFile(srcJar, false);
+                    jarFile.getManifest().getMainAttributes().entrySet().stream().forEach(
+                        entry -> attrs.put(entry.getKey().toString(), entry.getValue().toString())
+                    );
+                } catch (IOException e) {
+                    Logging.warn(
+                        "Failed to open {0} as a jar file. Using empty initial manifest. Error was: {1}",
+                        srcJar,
+                        e
+                    );
+                } finally {
+                    if (jarFile != null) {
+                        try {
+                            jarFile.close();
+                        } catch (IOException e) {
+                            Logging.warn(
+                                "Somehow failed to close jar file {0}. Error was: {1}",
+                                srcJar,
+                                e
+                            );
+                        }
+                    }
+                }
+            }
+
+            if (this.attrOverrides != null) {
+                attrs.putAll(this.attrOverrides);
+            }
+
+            return attrs.entrySet().stream().filter(entry -> entry.getValue() != null).map(
+                entry -> String.format("\t%s: %s\n", entry.getKey(), entry.getValue())
+            ).collect(Collectors.joining());
+        }
+
+        private String getJarPathBeneathFilesDir() {
+            if (this.srcJar != null) {
+                final Path jarPath = this.srcJar.toPath().toAbsolutePath().normalize();
+                final Path filesRootPath = new File(TestUtils.getTestDataRoot()).toPath().toAbsolutePath().resolve("__files").normalize();
+
+                if (jarPath.startsWith(filesRootPath)) {
+                    return filesRootPath.relativize(jarPath).toString();
+                }
+            }
+            return null;
+        }
+
+        protected String getPluginURLPath() {
+            final String jarPathBeneathFilesDir = this.getJarPathBeneathFilesDir();
+
+            if (jarPathBeneathFilesDir != null) {
+                return "/" + jarPathBeneathFilesDir;
+            }
+
+            return String.format("/%h/%s.jar", this.hashCode(), pluginName != null ? pluginName : Integer.toHexString(this.hashCode()));
+        }
+
+        public String getPluginURL(Integer port) {
+            if (this.pluginURL != null) {
+                return this.pluginURL;
+            } else if (port != null && this.getJarPathBeneathFilesDir() != null) {
+                return String.format("http://localhost:%s%s", port, this.getPluginURLPath());
+            }
+            return "http://example.com" + this.getPluginURLPath();
+        }
+
+        public String getName() {
+            if (this.pluginName != null) {
+                return this.pluginName;
+            } else if (this.srcJar != null) {
+                return this.srcJar.getName().split("\\.", 2)[0];
+            }
+            return Integer.toHexString(this.hashCode());
+        }
+
+        public String getRemotePluginsListSection(Integer port) {
+            return String.format(
+                "%s.jar;%s\n%s",
+                this.getName(),
+                this.getPluginURL(port),
+                this.getRemotePluginsListManifestSection()
+            );
+        }
+
+        public MappingBuilder getMappingBuilder() {
+            final String jarPathBeneathFilesDir = this.getJarPathBeneathFilesDir();
+
+            if (jarPathBeneathFilesDir != null) {
+                return WireMock.get(WireMock.urlMatching(this.getPluginURLPath()));
+            }
+            return null;
+        }
+
+        public ResponseDefinitionBuilder getResponseDefinitionBuilder() {
+            final String jarPathBeneathFilesDir = this.getJarPathBeneathFilesDir();
+
+            if (jarPathBeneathFilesDir != null) {
+                return WireMock.aResponse().withStatus(200).withHeader("Content-Type", "application/java-archive").withBodyFile(
+                    jarPathBeneathFilesDir
+                );
+            }
+            return null;
+        }
+    }
+
+    protected final List<RemotePlugin> pluginList;
+
+    public PluginServer(RemotePlugin... remotePlugins) {
+        this.pluginList = ImmutableList.copyOf(remotePlugins);
+    }
+
+    public void applyToWireMockServer(WireMockServer wireMockServer) {
+        // first add the plugins list
+        wireMockServer.stubFor(
+            WireMock.get(WireMock.urlEqualTo("/plugins")).willReturn(
+                WireMock.aResponse().withStatus(200).withHeader("Content-Type", "text/plain").withBody(
+                    this.pluginList.stream().map(
+                        remotePlugin -> remotePlugin.getRemotePluginsListSection(wireMockServer.port())
+                    ).collect(Collectors.joining())
+                )
+            )
+        );
+
+        // now add each file that we're able to serve
+        for (final RemotePlugin remotePlugin : this.pluginList) {
+            final MappingBuilder mappingBuilder = remotePlugin.getMappingBuilder();
+            final ResponseDefinitionBuilder responseDefinitionBuilder = remotePlugin.getResponseDefinitionBuilder();
+
+            if (mappingBuilder != null && responseDefinitionBuilder != null) {
+                wireMockServer.stubFor(
+                    remotePlugin.getMappingBuilder().willReturn(remotePlugin.getResponseDefinitionBuilder())
+                );
+            }
+        }
+    }
+
+    public PluginServerRule asWireMockRule() {
+        return this.asWireMockRule(
+            options().dynamicPort().usingFilesUnderDirectory(TestUtils.getTestDataRoot()),
+            true
+        );
+    }
+
+    public PluginServerRule asWireMockRule(Options ruleOptions, boolean failOnUnmatchedRequests) {
+        return new PluginServerRule(ruleOptions, failOnUnmatchedRequests);
+    }
+
+    public class PluginServerRule extends WireMockRule {
+        public PluginServerRule(Options ruleOptions, boolean failOnUnmatchedRequests) {
+            super(ruleOptions, failOnUnmatchedRequests);
+        }
+
+        public PluginServer getPluginServer() {
+            return PluginServer.this;
+        }
+
+        @Override
+        public Statement apply(Statement base, Description description) {
+            return super.apply(new Statement() {
+                @Override
+                @SuppressWarnings("unchecked")
+                public void evaluate() throws Throwable {
+                    PluginServer.this.applyToWireMockServer(PluginServerRule.this);
+                    base.evaluate();
+                }
+            }, description);
+        }
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/BaseDialogMockUp.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/BaseDialogMockUp.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/BaseDialogMockUp.java	(revision 14052)
@@ -0,0 +1,51 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import mockit.MockUp;
+
+/**
+ * Abstract class implementing the few common features of the dialog-mockers which are readily factorable.
+ * @param <T> type
+ */
+abstract class BaseDialogMockUp<T> extends MockUp<T> {
+    private final List<Object[]> invocationLog;
+
+    /**
+     * @return an unmodifiable view of the internal invocation log. Each entry is an array of Objects to
+     *     allow for more advanced implementations to be able to express their invocations in their own
+     *     ways. Typically the invocation's "result value" is used as the first element of the array.
+     */
+    public List<Object[]> getInvocationLog() {
+        return this.invocationLog;
+    }
+
+    private final List<Object[]> invocationLogInternal = new ArrayList<>(4);
+
+    /**
+     * @return the actual (writable) invocation log
+     */
+    protected List<Object[]> getInvocationLogInternal() {
+        return this.invocationLogInternal;
+    }
+
+    private final Map<String, Object> mockResultMap;
+
+    /**
+     * @return mapping to {@link Object}s so response button can be specified by String (label) or Integer
+     *     - sorry, no type safety as java doesn't support union types
+     */
+    public Map<String, Object> getMockResultMap() {
+        return this.mockResultMap;
+    }
+
+    BaseDialogMockUp(final Map<String, Object> mockResultMap) {
+        this.mockResultMap = mockResultMap != null ? mockResultMap : new HashMap<>(4);
+        this.invocationLog = Collections.unmodifiableList(this.invocationLogInternal);
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/EDTAssertionMocker.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/EDTAssertionMocker.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/EDTAssertionMocker.java	(revision 14052)
@@ -0,0 +1,24 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import org.openstreetmap.josm.gui.util.GuiHelper;
+
+import mockit.Invocation;
+import mockit.Mock;
+import mockit.MockUp;
+
+/**
+ * MockUp that, when applied, should cause calls to the EDT which would normally swallow generated
+ * AssertionErrors to instead re-raise them.
+ */
+public class EDTAssertionMocker extends MockUp<GuiHelper> {
+    @Mock
+    private static void handleEDTException(final Invocation invocation, final Throwable t) {
+        final Throwable cause = t.getCause();
+        if (cause instanceof AssertionError) {
+            throw (AssertionError) cause;
+        }
+
+        invocation.proceed(t);
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/ExtendedDialogMocker.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/ExtendedDialogMocker.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/ExtendedDialogMocker.java	(revision 14052)
@@ -0,0 +1,166 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import static org.junit.Assert.fail;
+
+import java.awt.Component;
+import java.awt.GraphicsEnvironment;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import java.util.WeakHashMap;
+
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.tools.Logging;
+
+import mockit.Deencapsulation;
+import mockit.Invocation;
+import mockit.Mock;
+
+/**
+ * MockUp for {@link ExtendedDialog} allowing a test to pre-seed uses of {@link ExtendedDialog}
+ * with mock "responses". This works best with {@link ExtendedDialog}s which have their contents set
+ * through {@link ExtendedDialog#setContent(String)} as simple strings. In such a case, responses can
+ * be defined through a mapping from content {@link String}s to button indexes ({@link Integer}s) or
+ * button labels ({@link String}s). Example:
+ *
+ * <pre>
+ *      new ExtendedDialogMocker(ImmutableMap.&lt;String, Object&gt;builder()
+ *          .put("JOSM version 8,001 required for plugin baz_plugin.", "Download Plugin")
+ *          .put("JOSM version 7,001 required for plugin dummy_plugin.", "Cancel")
+ *          .put("Are you sure you want to do foo bar?", ExtendedDialog.DialogClosedOtherwise)
+ *          .build()
+ *      );
+ * </pre>
+ *
+ * Testing examples with more complicated contents would require overriding
+ * {@link #getString(ExtendedDialog)} or even {@link #getMockResult(ExtendedDialog)} with custom logic.
+ * The class is implemented as a number of small methods with the main aim being to allow overriding of
+ * only the parts necessary for a particular case.
+ *
+ * The default {@link #getMockResult(ExtendedDialog)} will raise an
+ * {@link AssertionError} on an {@link ExtendedDialog} activation without a
+ * matching mapping entry or if the named button doesn't exist.
+ *
+ * The public {@link #getMockResultMap()} method returns the modifiable result map to allow for situations
+ * where the desired result might need to be changed mid-test.
+ */
+public class ExtendedDialogMocker extends BaseDialogMockUp<ExtendedDialog> {
+    /**
+     * Because we're unable to add fields to the mocked class, we need to use this external global
+     * mapping to be able to keep a note of the most recently set simple String contents of each
+     * {@link ExtendedDialog} instance - {@link ExtendedDialog} doesn't store this information
+     * itself, instead converting it directly into the embedded {@link Component}.
+     */
+    protected final Map<ExtendedDialog, String> simpleStringContentMemo = new WeakHashMap<>();
+
+    /**
+     * Construct an {@link ExtendedDialogMocker} with an empty {@link #mockResultMap}.
+     */
+    public ExtendedDialogMocker() {
+        this(null);
+    }
+
+    /**
+     * Construct an {@link ExtendedDialogMocker} with the provided {@link #mockResultMap}.
+     * @param mockResultMap mapping of {@link ExtendedDialog} string contents to
+     *      result button label or integer index.
+     */
+    public ExtendedDialogMocker(final Map<String, Object> mockResultMap) {
+        super(mockResultMap);
+        if (GraphicsEnvironment.isHeadless()) {
+            new WindowMocker();
+        }
+    }
+
+    protected int getButtonPositionFromLabel(final ExtendedDialog instance, final String label) {
+        final String[] bTexts = Deencapsulation.getField(instance, "bTexts");
+        final int position = Arrays.asList(bTexts).indexOf(label);
+        if (position == -1) {
+            fail("Unable to find button labeled \"" + label + "\". Instead found: " + Arrays.toString(bTexts));
+        }
+        return position;
+    }
+
+    protected String getString(final ExtendedDialog instance) {
+        return Optional.ofNullable(this.simpleStringContentMemo.get(instance))
+            .orElseGet(() -> instance.toString());
+    }
+
+    protected int getMockResult(final ExtendedDialog instance) {
+        final String stringContent = this.getString(instance);
+        final Object result = this.getMockResultMap().get(stringContent);
+
+        if (result == null) {
+            fail(
+                "Unexpected ExtendedDialog content: " + stringContent
+            );
+        } else if (result instanceof Integer) {
+            return (Integer) result;
+        } else if (result instanceof String) {
+            // buttons are numbered with 1-based indexing
+            return 1 + this.getButtonPositionFromLabel(instance, (String) result);
+        }
+
+        throw new IllegalArgumentException(
+            "ExtendedDialog contents mapped to unsupported type of Object: " + result
+        );
+    }
+
+    protected Object[] getInvocationLogEntry(final ExtendedDialog instance, final int mockResult) {
+        return new Object[] {
+            mockResult,
+            this.getString(instance),
+            instance.getTitle()
+        };
+    }
+
+    @Mock
+    private void setupDialog(final Invocation invocation) {
+        if (!GraphicsEnvironment.isHeadless()) {
+            invocation.proceed();
+        }
+        // else do nothing - WindowMocker-ed Windows doesn't work well enough for some of the
+        // component constructions
+    }
+
+    @Mock
+    private void setVisible(final Invocation invocation, final boolean value) {
+        if (value == true) {
+            try {
+                final ExtendedDialog instance = invocation.getInvokedInstance();
+                final int mockResult = this.getMockResult(instance);
+                // TODO check validity of mockResult?
+                Deencapsulation.setField(instance, "result", mockResult);
+                Logging.info(
+                    "{0} answering {1} to ExtendedDialog with content {2}",
+                    this.getClass().getName(),
+                    mockResult,
+                    this.getString(instance)
+                );
+                this.getInvocationLogInternal().add(this.getInvocationLogEntry(instance, mockResult));
+            } catch (AssertionError e) {
+                // in case this exception gets ignored by the calling thread we want to signify this failure
+                // in the invocation log. it's hard to know what to add to the log in these cases as it's
+                // probably unsafe to call getInvocationLogEntry, so add the exception on its own.
+                this.getInvocationLogInternal().add(new Object[] {e});
+                throw e;
+            }
+        }
+    }
+
+    @Mock
+    private ExtendedDialog setContent(final Invocation invocation, final String message) {
+        final ExtendedDialog retval = invocation.proceed(message);
+        // must set this *after* the regular invocation else that will fall through to
+        // setContent(Component, boolean) which would overwrite it (with null)
+        this.simpleStringContentMemo.put((ExtendedDialog) invocation.getInvokedInstance(), message);
+        return retval;
+    }
+
+    @Mock
+    private ExtendedDialog setContent(final Invocation invocation, final Component content, final boolean placeContentInScrollPane) {
+        this.simpleStringContentMemo.put((ExtendedDialog) invocation.getInvokedInstance(), null);
+        return invocation.proceed(content, placeContentInScrollPane);
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/HelpAwareOptionPaneMocker.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/HelpAwareOptionPaneMocker.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/HelpAwareOptionPaneMocker.java	(revision 14052)
@@ -0,0 +1,199 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import static org.junit.Assert.fail;
+
+import java.awt.Component;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.OptionalInt;
+import java.util.stream.IntStream;
+
+import javax.swing.Icon;
+import javax.swing.JOptionPane;
+
+import org.openstreetmap.josm.gui.HelpAwareOptionPane;
+import org.openstreetmap.josm.tools.Logging;
+
+import mockit.Mock;
+
+/**
+ * MockUp for {@link HelpAwareOptionPane} allowing a test to pre-seed uses of
+ * {@link HelpAwareOptionPane} with mock "responses". This works best with
+ * calls to {@link HelpAwareOptionPane#showOptionDialog(Component, Object, String, int, Icon,
+ * HelpAwareOptionPane.ButtonSpec[], HelpAwareOptionPane.ButtonSpec, String)} which use simple string-based
+ * {@code msg} parameters. In such a case, responses can be defined through a mapping from content
+ * {@link String}s to button indexes ({@link Integer}s) or button labels ({@link String}s). Example:
+ *
+ * <pre>
+ *      new HelpAwareOptionPaneMocker(ImmutableMap.&lt;String, Object&gt;builder()
+ *          .put("Do you want to save foo bar?", 2)
+ *          .put("Please restart JOSM to activate the downloaded plugins.", "OK")
+ *          .build()
+ *      );
+ * </pre>
+ *
+ * Testing examples with more complicated contents would require overriding
+ * {@link #getStringFromMessage(Object)} or even {@link #getMockResultForMessage(Object)} with custom
+ * logic. The class is implemented as a number of small methods with the main aim being to allow overriding
+ * of only the parts necessary for a particular case.
+ *
+ * The default {@link #getMockResultForMessage(Object)} will raise an
+ * {@link AssertionError} on an {@link #showOptionDialog(Component, Object, String,
+ * int, Icon, HelpAwareOptionPane.ButtonSpec[], HelpAwareOptionPane.ButtonSpec, String)}
+ * activation without a matching mapping entry or if the named button doesn't exist.
+ *
+ * The public {@link #getMockResultMap()} method returns the modifiable result map to allow for situations
+ * where the desired result might need to be changed mid-test.
+ */
+public class HelpAwareOptionPaneMocker extends BaseDialogMockUp<HelpAwareOptionPane> {
+    /**
+     * Construct a {@link HelpAwareOptionPaneMocker} with an empty {@link #mockResultMap}.
+     */
+    public HelpAwareOptionPaneMocker() {
+        this(null);
+    }
+
+    /**
+     * Construct an {@link HelpAwareOptionPane} with the provided {@link #mockResultMap}.
+     * @param mockResultMap mapping of {@link HelpAwareOptionPane} {@code msg} string to
+     *      result button label or integer index.
+     */
+    public HelpAwareOptionPaneMocker(
+        final Map<String, Object> mockResultMap
+    ) {
+        super(mockResultMap);
+    }
+
+    protected String getStringFromMessage(final Object message) {
+        return message.toString();
+    }
+
+    protected Object getMockResultForMessage(final Object message) {
+        final String messageString = this.getStringFromMessage(message);
+        if (!this.getMockResultMap().containsKey(messageString)) {
+            fail("Unexpected HelpAwareOptionPane message string: " + messageString);
+        }
+        return this.getMockResultMap().get(messageString);
+    }
+
+    protected int getButtonPositionFromLabel(
+        final HelpAwareOptionPane.ButtonSpec[] options,
+        final String label
+    ) {
+        if (options == null) {
+            if (!label.equals("OK")) {
+                fail(String.format(
+                    "Only valid result for HelpAwareOptionPane with options = null is \"OK\": received %s",
+                    label
+                ));
+            }
+            return JOptionPane.OK_OPTION;
+        } else {
+            final OptionalInt optIndex = IntStream.range(0, options.length)
+                .filter(i -> options[i].text.equals(label))
+                .findFirst();
+            if (!optIndex.isPresent()) {
+                fail(String.format(
+                    "Unable to find button labeled \"%s\". Instead found %s",
+                    label,
+                    Arrays.toString(Arrays.stream(options).map((buttonSpec) -> buttonSpec.text).toArray())
+                ));
+            }
+            // buttons are numbered with 1-based indexing
+            return optIndex.getAsInt() + 1;
+        }
+    }
+
+    protected Object[] getInvocationLogEntry(
+        final Object msg,
+        final String title,
+        final Integer messageType,
+        final Icon icon,
+        final HelpAwareOptionPane.ButtonSpec[] options,
+        final HelpAwareOptionPane.ButtonSpec defaultOption,
+        final String helpTopic,
+        final Integer mockResult
+    ) {
+        return new Object[] {
+            mockResult,
+            this.getStringFromMessage(msg),
+            title
+        };
+    }
+
+    @Mock
+    protected int showOptionDialog(
+        final Component parentComponent,
+        final Object msg,
+        final String title,
+        final int messageType,
+        final Icon icon,
+        final HelpAwareOptionPane.ButtonSpec[] options,
+        final HelpAwareOptionPane.ButtonSpec defaultOption,
+        final String helpTopic
+    ) {
+        try {
+            final Object result = this.getMockResultForMessage(msg);
+
+            if (result == null) {
+                fail(
+                    "Invalid result for HelpAwareOptionPane: null (HelpAwareOptionPane returns"
+                    + "JOptionPane.OK_OPTION for closed windows if that was the intent)"
+                );
+            }
+
+            Integer retval = null;
+            if (result instanceof String) {
+                retval = this.getButtonPositionFromLabel(options, (String) result);
+            } else if (result instanceof Integer) {
+                retval = (Integer) result;
+            } else {
+                throw new IllegalArgumentException(
+                    "HelpAwareOptionPane message mapped to unsupported type of Object: " + result
+                );
+            }
+
+            // check the returned integer for validity
+            if (retval < 0) {
+                fail(String.format(
+                    "Invalid result for HelpAwareOptionPane: %s (HelpAwareOptionPane returns "
+                    + "JOptionPane.OK_OPTION for closed windows if that was the intent)",
+                    retval
+                ));
+            } else if (retval > (options == null ? 0 : options.length)) {  // NOTE 1-based indexing
+                fail(String.format(
+                    "Invalid result for HelpAwareOptionPane: %s (in call with options = %s)",
+                    retval,
+                    options
+                ));
+            }
+
+            Logging.info(
+                "{0} answering {1} to HelpAwareOptionPane with message {2}",
+                this.getClass().getName(),
+                retval,
+                this.getStringFromMessage(msg)
+            );
+
+            this.getInvocationLogInternal().add(this.getInvocationLogEntry(
+                msg,
+                title,
+                messageType,
+                icon,
+                options,
+                defaultOption,
+                helpTopic,
+                retval
+            ));
+
+            return retval;
+        } catch (AssertionError e) {
+            // in case this exception gets ignored by the calling thread we want to signify this failure
+            // in the invocation log. it's hard to know what to add to the log in these cases as it's
+            // probably unsafe to call getInvocationLogEntry, so add the exception on its own.
+            this.getInvocationLogInternal().add(new Object[] {e});
+            throw e;
+        }
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/JOptionPaneSimpleMocker.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/JOptionPaneSimpleMocker.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/JOptionPaneSimpleMocker.java	(revision 14052)
@@ -0,0 +1,332 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import static org.junit.Assert.fail;
+
+import java.awt.Component;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+import javax.swing.Icon;
+import javax.swing.JOptionPane;
+
+import org.openstreetmap.josm.gui.ConditionalOptionPaneUtil.MessagePanel;
+import org.openstreetmap.josm.tools.Logging;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Ints;
+
+import mockit.Invocation;
+import mockit.Mock;
+import mockit.MockUp;
+
+/**
+ * MockUp for {@link JOptionPane} allowing a test to pre-seed uses of {@link JOptionPane}'s
+ * {@code showInputDialog(...)}, {@code showMessageDialog(...)} and {@code showConfirmDialog(...)}
+ * with mock "responses". This works best with calls which use simple string-based {@code message}
+ * parameters. In such a case, responses can be defined through a mapping from content {@link String}s
+ * to button integer codes ({@link Integer}s) in the case of {@code showConfirmDialog(...)} calls or
+ * arbitrary Objects ( but probably {@link String}s) in the case of {@code showInputDialog(...)} calls.
+ * {@code showMessageDialog(...)} calls' contents should be mapped to {@link JOptionPane#OK_OPTION}.
+ * Example:
+ *
+ * <pre>
+ *      new JOptionPaneSimpleMocker(ImmutableMap.of(
+ *          "Number of tags to delete", "17",  // a showInputDialog(...) call
+ *          "Please select the row to edit.", JOptionPane.OK_OPTION,  // a showMessageDialog(...) call
+ *          "Do you want to save foo bar?", JOptionPane.CANCEL_OPTION  // a showConfirmDialog(...) call
+ *      ));
+ * </pre>
+ *
+ * Testing examples with more complicated contents would require overriding
+ * {@link #getStringFromMessage(Object)} or even {@link #getMockResultForMessage(Object)} with custom logic.
+ * The class is implemented as a number of small methods with the main aim being to allow overriding of
+ * only the parts necessary for a particular case.
+ *
+ * The default {@link #getMockResultForMessage(Object)} will raise an
+ * {@link junit.framework.AssertionFailedError} on an activation without a matching mapping entry or if
+ * the mapped result value is invalid for the call.
+ *
+ * The public {@link #getMockResultMap()} method returns the modifiable result map to allow for situations
+ * where the desired result might need to be changed mid-test.
+ *
+ * This class should also work with dialogs shown using
+ * {@link org.openstreetmap.josm.gui.ConditionalOptionPaneUtil}.
+ *
+ * NOTE that this class does NOT handle {@code showOptionDialog(...)} calls or direct {@link JOptionPane}
+ * instantiations. These are probably too flexible to be universally mocked with a "Simple" interface and
+ * are probably best handled with case-specific mockers.
+ */
+public class JOptionPaneSimpleMocker extends BaseDialogMockUp<JOptionPane> {
+    protected static final Map<Integer, int[]> optionTypePermittedResults = ImmutableMap.of(
+        JOptionPane.YES_NO_OPTION, new int[] {
+            JOptionPane.YES_OPTION,
+            JOptionPane.NO_OPTION,
+            JOptionPane.CLOSED_OPTION
+        },
+        JOptionPane.YES_NO_CANCEL_OPTION, new int[] {
+            JOptionPane.YES_OPTION,
+            JOptionPane.NO_OPTION,
+            JOptionPane.CANCEL_OPTION,
+            JOptionPane.CLOSED_OPTION
+        },
+        JOptionPane.OK_CANCEL_OPTION, new int[] {
+            JOptionPane.OK_OPTION,
+            JOptionPane.CANCEL_OPTION,
+            JOptionPane.CLOSED_OPTION
+        }
+    );
+
+    protected final MessagePanelMocker messagePanelMocker;
+
+    /**
+     * Construct a {@link JOptionPaneSimpleMocker} with an empty {@link #mockResultMap}.
+     */
+    public JOptionPaneSimpleMocker() {
+        this(null);
+    }
+
+    /**
+     * Construct an {@link JOptionPaneSimpleMocker} with the provided {@link #mockResultMap} and a
+     * default {@link MessagePanelMocker}.
+     * @param mockResultMap mapping of {@link JOptionPaneSimpleMocker} {@code message} string to
+     *      result Object.
+     */
+    public JOptionPaneSimpleMocker(
+        final Map<String, Object> mockResultMap
+    ) {
+        this(mockResultMap, null);
+    }
+
+    /**
+     * Construct an {@link JOptionPaneSimpleMocker} with the provided {@link #mockResultMap} and the
+     * provided {@link MessagePanelMocker} instance.
+     * @param mockResultMap mapping of {@link JOptionPaneSimpleMocker} {@code message} string to
+     *      result Object.
+     * @param messagePanelMocker {@link MessagePanelMocker} instace to use for {@link org.openstreetmap.josm.gui.ConditionalOptionPaneUtil}
+     *      message-string retrieval.
+     */
+    public JOptionPaneSimpleMocker(
+        final Map<String, Object> mockResultMap,
+        final MessagePanelMocker messagePanelMocker
+    ) {
+        super(mockResultMap);
+        this.messagePanelMocker = messagePanelMocker != null ? messagePanelMocker : new MessagePanelMocker();
+    }
+
+    protected String getStringFromOriginalMessage(final Object originalMessage) {
+        return originalMessage.toString();
+    }
+
+    protected String getStringFromMessage(final Object message) {
+        final Object originalMessage = message instanceof MessagePanel ?
+            this.messagePanelMocker.getOriginalMessage((MessagePanel) message) : message;
+        return this.getStringFromOriginalMessage(originalMessage);
+    }
+
+    protected Object getMockResultForMessage(final Object message) {
+        final String messageString = this.getStringFromMessage(message);
+        if (!this.getMockResultMap().containsKey(messageString)) {
+            fail("Unexpected JOptionPane message string: " + messageString);
+        }
+        return this.getMockResultMap().get(messageString);
+    }
+
+    protected Object[] getInvocationLogEntry(
+        final Object message,
+        final String title,
+        final Integer optionType,
+        final Integer messageType,
+        final Icon icon,
+        final Object[] selectionValues,
+        final Object initialSelectionValue,
+        final Object mockResult
+    ) {
+        return new Object[] {
+            mockResult,
+            this.getStringFromMessage(message),
+            title
+        };
+    }
+
+    @Mock
+    protected Object showInputDialog(
+        final Component parentComponent,
+        final Object message,
+        final String title,
+        final int messageType,
+        final Icon icon,
+        final Object[] selectionValues,
+        final Object initialSelectionValue
+    ) {
+        try {
+            final Object result = this.getMockResultForMessage(message);
+            if (selectionValues == null) {
+                if (!(result instanceof String)) {
+                    fail(String.format(
+                        "Only valid result type for showInputDialog with null selectionValues is String: received %s",
+                        result
+                    ));
+                }
+            } else {
+                if (!Arrays.asList(selectionValues).contains(result)) {
+                    fail(String.format(
+                        "Result for showInputDialog not present in selectionValues: %s",
+                        result
+                    ));
+                }
+            }
+
+            Logging.info(
+                "{0} answering {1} to showInputDialog with message {2}",
+                this.getClass().getName(),
+                result,
+                this.getStringFromMessage(message)
+            );
+
+            this.getInvocationLogInternal().add(this.getInvocationLogEntry(
+                message,
+                title,
+                null,
+                messageType,
+                icon,
+                selectionValues,
+                initialSelectionValue,
+                result
+            ));
+
+            return result;
+        } catch (AssertionError e) {
+            // in case this exception gets ignored by the calling thread we want to signify this failure
+            // in the invocation log. it's hard to know what to add to the log in these cases as it's
+            // probably unsafe to call getInvocationLogEntry, so add the exception on its own.
+            this.getInvocationLogInternal().add(new Object[] {e});
+            throw e;
+        }
+    }
+
+    @Mock
+    protected void showMessageDialog(
+        final Component parentComponent,
+        final Object message,
+        final String title,
+        final int messageType,
+        final Icon icon
+    ) {
+        try {
+            // why look up a "result" for a message dialog which can only have one possible result? it's
+            // a good opportunity to assert its contents
+            final Object result = this.getMockResultForMessage(message);
+            if (!(result instanceof Integer && (int) result == JOptionPane.OK_OPTION)) {
+                fail(String.format(
+                    "Only valid result for showMessageDialog is %d: received %s",
+                    JOptionPane.OK_OPTION,
+                    result
+                ));
+            }
+
+            Logging.info(
+                "{0} answering {1} to showMessageDialog with message {2}",
+                this.getClass().getName(),
+                result,
+                this.getStringFromMessage(message)
+            );
+
+            this.getInvocationLogInternal().add(this.getInvocationLogEntry(
+                message,
+                title,
+                null,
+                messageType,
+                icon,
+                null,
+                null,
+                JOptionPane.OK_OPTION
+            ));
+        } catch (AssertionError e) {
+            // in case this exception gets ignored by the calling thread we want to signify this failure
+            // in the invocation log. it's hard to know what to add to the log in these cases as it's
+            // probably unsafe to call getInvocationLogEntry, so add the exception on its own.
+            this.getInvocationLogInternal().add(new Object[] {e});
+            throw e;
+        }
+    }
+
+    @Mock
+    protected int showConfirmDialog(
+        final Component parentComponent,
+        final Object message,
+        final String title,
+        final int optionType,
+        final int messageType,
+        final Icon icon
+    ) {
+        try {
+            final Object result = this.getMockResultForMessage(message);
+            if (!(result instanceof Integer && Ints.contains(optionTypePermittedResults.get(optionType), (int) result))) {
+                fail(String.format(
+                    "Invalid result for showConfirmDialog with optionType %d: %s",
+                    optionType,
+                    result
+                ));
+            }
+
+            Logging.info(
+                "{0} answering {1} to showConfirmDialog with message {2}",
+                this.getClass().getName(),
+                result,
+                this.getStringFromMessage(message)
+            );
+
+            this.getInvocationLogInternal().add(this.getInvocationLogEntry(
+                message,
+                title,
+                optionType,
+                messageType,
+                icon,
+                null,
+                null,
+                result
+            ));
+
+            return (int) result;
+        } catch (AssertionError e) {
+            // in case this exception gets ignored by the calling thread we want to signify this failure
+            // in the invocation log. it's hard to know what to add to the log in these cases as it's
+            // probably unsafe to call getInvocationLogEntry, so add the exception on its own.
+            this.getInvocationLogInternal().add(new Object[] {e});
+            throw e;
+        }
+    }
+
+    /**
+     * MockUp for {@link MessagePanel} to allow mocking to work with ConditionalOptionPaneUtil dialogs
+     */
+    public static class MessagePanelMocker extends MockUp<MessagePanel> {
+        protected final Map<MessagePanel, Object> originalMessageMemo = new WeakHashMap<>();
+
+        @Mock
+        private void $init(
+            final Invocation invocation,
+            final Object message,
+            final boolean displayImmediateOption
+        ) {
+            this.originalMessageMemo.put(
+                (MessagePanel) invocation.getInvokedInstance(),
+                message
+            );
+            invocation.proceed();
+        }
+
+        /**
+         * Returns the original message.
+         * @param instance message panel
+         * @return the original message
+         */
+        public Object getOriginalMessage(final MessagePanel instance) {
+            return this.originalMessageMemo.get(instance);
+        }
+
+        /* TODO also allow mocking of getNotShowAgain() */
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowMocker.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowMocker.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowMocker.java	(revision 14052)
@@ -0,0 +1,39 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import java.awt.Frame;
+import java.awt.GraphicsConfiguration;
+import java.awt.Window;
+
+import mockit.Invocation;
+import mockit.Mock;
+import mockit.MockUp;
+
+/**
+ * MockUp for a {@link Window} which simply (and naively) makes its constructor(s) a no-op. This has
+ * the advantage of removing the isHeadless check. Though if course it also leaves you with
+ * uninintialized objects, and so of course they don't *necessarily* work properly. But often they
+ * work *just enough* to behave how a test needs them to. Exercise left to the reader to discover
+ * the limits here.
+ */
+public class WindowMocker extends MockUp<Window> {
+    @Mock
+    private void $init(final Invocation invocation) {
+    }
+
+    @Mock
+    private void $init(final Invocation invocation, final Window window) {
+    }
+
+    @Mock
+    private void $init(final Invocation invocation, final Frame frame) {
+    }
+
+    @Mock
+    private void $init(final Invocation invocation, final GraphicsConfiguration gc) {
+    }
+
+    @Mock
+    private void $init(final Invocation invocation, final Window window, final GraphicsConfiguration gc) {
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowlessMapViewStateMocker.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowlessMapViewStateMocker.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowlessMapViewStateMocker.java	(revision 14052)
@@ -0,0 +1,29 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import java.awt.Point;
+
+import javax.swing.JComponent;
+
+import org.openstreetmap.josm.gui.MapViewState;
+
+import mockit.Mock;
+import mockit.MockUp;
+
+/**
+ * MockUp for allowing a {@link MapViewState} to be fully initialized in either headless or
+ * windowless tests
+ */
+public class WindowlessMapViewStateMocker extends MockUp<MapViewState> {
+    @Mock
+    private static Point findTopLeftInWindow(JComponent position) {
+        return new Point();
+    }
+
+    @Mock
+    private static Point findTopLeftOnScreen(JComponent position) {
+        // in our imaginary universe the window is always (10, 10) from the top left of the screen
+        Point topLeftInWindow = findTopLeftInWindow(position);
+        return new Point(topLeftInWindow.x + 10, topLeftInWindow.y + 10);
+    }
+}
Index: trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowlessNavigatableComponentMocker.java
===================================================================
--- trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowlessNavigatableComponentMocker.java	(revision 14052)
+++ trunk/test/unit/org/openstreetmap/josm/testutils/mockers/WindowlessNavigatableComponentMocker.java	(revision 14052)
@@ -0,0 +1,18 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.testutils.mockers;
+
+import org.openstreetmap.josm.gui.NavigatableComponent;
+
+import mockit.Mock;
+import mockit.MockUp;
+
+/**
+ * MockUp for allowing a {@link NavigatableComponent} to be used in either headless or windowless
+ * tests.
+ */
+public class WindowlessNavigatableComponentMocker extends MockUp<NavigatableComponent> {
+    @Mock
+    private boolean isVisibleOnScreen() {
+        return true;
+    }
+}
