Subject: [PATCH] #11153: improve readability of validator warnings
---
Index: src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerFixCommand.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerFixCommand.java b/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerFixCommand.java
--- a/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerFixCommand.java	(revision 18634)
+++ b/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerFixCommand.java	(date 1673906820529)
@@ -54,7 +54,7 @@
         } else {
             return null;
         }
-        return MapCSSTagCheckerRule.insertArguments(matchingSelector, s, p);
+        return MapCSSTagCheckerRule.insertArguments(matchingSelector, s, p, false);
     }
 
     /**
@@ -113,8 +113,8 @@
             @Override
             public Command createCommand(OsmPrimitive p, Selector matchingSelector) {
                 return new ChangePropertyKeyCommand(p,
-                        MapCSSTagCheckerRule.insertArguments(matchingSelector, oldKey, p),
-                        MapCSSTagCheckerRule.insertArguments(matchingSelector, newKey, p));
+                        MapCSSTagCheckerRule.insertArguments(matchingSelector, oldKey, p, false),
+                        MapCSSTagCheckerRule.insertArguments(matchingSelector, newKey, p, false));
             }
 
             @Override
Index: src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java b/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java
--- a/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java	(revision 18634)
+++ b/src/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerRule.java	(date 1673907076891)
@@ -262,22 +262,43 @@
      * @param matchingSelector matching selector
      * @param s                any string
      * @param p                OSM primitive
+     * @param linkify          {@code true} to insert links to wiki pages
      * @return string with arguments inserted
      */
-    static String insertArguments(Selector matchingSelector, String s, OsmPrimitive p) {
+    static String insertArguments(Selector matchingSelector, String s, OsmPrimitive p, boolean linkify) {
         if (s != null && matchingSelector instanceof Selector.ChildOrParentSelector) {
-            return insertArguments(((Selector.ChildOrParentSelector) matchingSelector).right, s, p);
+            return insertArguments(((Selector.ChildOrParentSelector) matchingSelector).right, s, p, linkify);
         } else if (s == null || !(matchingSelector instanceof Selector.GeneralSelector)) {
             return s;
         }
-        final Matcher m = Pattern.compile("\\{(\\d+)\\.(key|value|tag)\\}").matcher(s);
+        final Matcher m = Pattern.compile("\\{(\\S+?)\\.(key|value|tag)}", Pattern.UNICODE_CHARACTER_CLASS).matcher(s);
+        final Matcher number = Pattern.compile("^\\d+$").matcher(s);
         final StringBuffer sb = new StringBuffer();
         while (m.find()) {
-            final String argument = determineArgument((Selector.GeneralSelector) matchingSelector,
-                    Integer.parseInt(m.group(1)), m.group(2), p);
+            final String argument;
+            final String tag;
+            if (number.reset(m.group(1)).matches()) {
+                argument = determineArgument((Selector.GeneralSelector) matchingSelector,
+                        Integer.parseInt(m.group(1)), m.group(2), p);
+                tag = determineArgument((Selector.GeneralSelector) matchingSelector,
+                        Integer.parseInt(m.group(1)), "tag", p);
+            } else {
+                argument = m.group(1);
+                tag = argument;
+            }
             try {
                 // Perform replacement with null-safe + regex-safe handling
-                m.appendReplacement(sb, String.valueOf(argument).replace("^(", "").replace(")$", ""));
+                final String replacement = String.valueOf(argument).replace("^(", "").replace(")$", "");
+                final String replacementTag = String.valueOf(tag).replace("^(", "").replace(")$", "");
+                final String[] splitTag = replacementTag.split("=", 2);
+                if (linkify) {
+                    final String url = Test.getWikiLink(splitTag[0], !"key".equals(m.group(2)) && splitTag.length == 2 ? splitTag[1] : null);
+                    m.appendReplacement(sb, "<a href=\"" + url + "\">"
+                            + replacement
+                            + "</a>");
+                } else {
+                    m.appendReplacement(sb, replacement);
+                }
             } catch (IndexOutOfBoundsException | IllegalArgumentException e) {
                 Logging.log(Logging.LEVEL_ERROR, tr("Unable to replace argument {0} in {1}: {2}", argument, sb, e.getMessage()), e);
             }
@@ -307,7 +328,7 @@
                 cmds.add(new DeleteCommand(p));
             }
             return cmds.isEmpty() ? null
-                    : new SequenceCommand(tr("Fix of {0}", getDescriptionForMatchingSelector(p, matchingSelector)), cmds);
+                    : new SequenceCommand(tr("Fix of {0}", getDescriptionForMatchingSelector(p, matchingSelector, false)), cmds);
         } catch (IllegalArgumentException e) {
             Logging.error(e);
             return null;
@@ -356,10 +377,11 @@
      *
      * @param matchingSelector matching selector
      * @param p                OSM primitive
+     * @param linkify          {@code true} to get a description with links to tag/key wiki pages
      * @return a description (possibly with alternative suggestions)
      */
-    String getDescriptionForMatchingSelector(OsmPrimitive p, Selector matchingSelector) {
-        return insertArguments(matchingSelector, getDescription(p), p);
+    String getDescriptionForMatchingSelector(OsmPrimitive p, Selector matchingSelector, boolean linkify) {
+        return insertArguments(matchingSelector, getDescription(p), p, linkify);
     }
 
     Severity getSeverity() {
@@ -384,7 +406,7 @@
         List<TestError> res = new ArrayList<>();
         if (matchingSelector != null && !errors.isEmpty()) {
             final Command fix = fixPrimitive(p);
-            final String description = getDescriptionForMatchingSelector(p, matchingSelector);
+            final String description = getDescriptionForMatchingSelector(p, matchingSelector, true);
             final String description1 = group == null ? description : group;
             final String description2 = group == null ? null : description;
             final String selector = matchingSelector.toString();
Index: src/org/openstreetmap/josm/data/validation/Test.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/validation/Test.java b/src/org/openstreetmap/josm/data/validation/Test.java
--- a/src/org/openstreetmap/josm/data/validation/Test.java	(revision 18634)
+++ b/src/org/openstreetmap/josm/data/validation/Test.java	(date 1673547219899)
@@ -11,6 +11,8 @@
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 import javax.swing.JCheckBox;
 import javax.swing.JPanel;
 
@@ -26,6 +28,7 @@
 import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper;
 import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
 import org.openstreetmap.josm.gui.progress.ProgressMonitor;
+import org.openstreetmap.josm.spi.preferences.Config;
 import org.openstreetmap.josm.tools.GBC;
 import org.openstreetmap.josm.tools.Logging;
 import org.openstreetmap.josm.tools.Stopwatch;
@@ -351,7 +354,7 @@
      * @param p The primitive to be tested
      * @return True if building key is set and different from no,entrance
      */
-    protected static final boolean isBuilding(OsmPrimitive p) {
+    protected static boolean isBuilding(OsmPrimitive p) {
         return p.hasTagDifferent("building", "no", "entrance");
     }
 
@@ -360,10 +363,26 @@
      * @param p The primitive to be tested
      * @return True if landuse key is equal to residential
      */
-    protected static final boolean isResidentialArea(OsmPrimitive p) {
+    protected static boolean isResidentialArea(OsmPrimitive p) {
         return p.hasTag("landuse", "residential");
     }
 
+    /**
+     * Get the wiki link for a specified key and value
+     * @param key The key
+     * @param value The value
+     * @return The wiki link
+     * @since xxx
+     */
+    @Nonnull
+    public static String getWikiLink(@Nonnull String key, @Nullable String value) {
+        final String wiki = Config.getUrls().getOSMWiki() + "/wiki/";
+        if (value == null) {
+            return wiki + "Key:" + key;
+        }
+        return wiki + "Tag:" + key + "=" + value;
+    }
+
     /**
      * Free resources.
      */
Index: src/org/openstreetmap/josm/data/validation/TestError.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/validation/TestError.java b/src/org/openstreetmap/josm/data/validation/TestError.java
--- a/src/org/openstreetmap/josm/data/validation/TestError.java	(revision 18634)
+++ b/src/org/openstreetmap/josm/data/validation/TestError.java	(date 1673906700645)
@@ -12,6 +12,7 @@
 import java.util.Locale;
 import java.util.TreeSet;
 import java.util.function.Supplier;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -33,6 +34,8 @@
  * @since 3669
  */
 public class TestError implements Comparable<TestError> {
+    /** The pattern for html tags */
+    private static final Pattern HTML_LINK_PATTERN = Pattern.compile("<a href=\".*?\">|</a>");
     /** is this error on the ignore list */
     private boolean ignored;
     /** Severity */
@@ -350,11 +353,11 @@
     public String getIgnoreSubGroup() {
         if (code == 3000) {
             // see #19053
-            return "3000_" + (description == null ? message : description);
+            return "3000_" + removeHtmlTags(getDescription() == null ? getMessage() : getDescription());
         }
         String ignorestring = getIgnoreGroup();
         if (descriptionEn != null) {
-            ignorestring += '_' + descriptionEn;
+            ignorestring += '_' + removeHtmlTags(descriptionEn);
         }
         return ignorestring;
     }
@@ -367,7 +370,7 @@
     public String getIgnoreGroup() {
         if (code == 3000) {
             // see #19053
-            return "3000_" + getMessage();
+            return "3000_" + removeHtmlTags(getMessage());
         }
         return Integer.toString(code);
     }
@@ -573,4 +576,12 @@
         return "TestError [tester=" + tester + ", code=" + code + ", message=" + message + ']';
     }
 
+    /**
+     * Remove html tags
+     * @param string The string to remove tags from (specifically the anchor tag)
+     * @return The stripped string
+     */
+    private static String removeHtmlTags(String string) {
+        return HTML_LINK_PATTERN.matcher(string).replaceAll("");
+    }
 }
Index: src/org/openstreetmap/josm/gui/dialogs/validator/ValidatorTreeRenderer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/dialogs/validator/ValidatorTreeRenderer.java b/src/org/openstreetmap/josm/gui/dialogs/validator/ValidatorTreeRenderer.java
--- a/src/org/openstreetmap/josm/gui/dialogs/validator/ValidatorTreeRenderer.java	(revision 18634)
+++ b/src/org/openstreetmap/josm/gui/dialogs/validator/ValidatorTreeRenderer.java	(date 1673368470256)
@@ -3,39 +3,45 @@
 
 import java.awt.Component;
 
+import javax.swing.JLabel;
 import javax.swing.JTree;
 import javax.swing.tree.DefaultMutableTreeNode;
-import javax.swing.tree.DefaultTreeCellRenderer;
 
 import org.openstreetmap.josm.data.validation.Severity;
 import org.openstreetmap.josm.data.validation.TestError;
 import org.openstreetmap.josm.data.validation.util.MultipleNameVisitor;
+import org.openstreetmap.josm.gui.widgets.HtmlTreeCellRenderer;
 import org.openstreetmap.josm.tools.ImageProvider;
 
 /**
  * Tree renderer for displaying errors
  * @author frsantos
  */
-public class ValidatorTreeRenderer extends DefaultTreeCellRenderer {
+public class ValidatorTreeRenderer extends HtmlTreeCellRenderer {
+
+    private static final long serialVersionUID = 4750085115702320153L;
 
     @Override
     public Component getTreeCellRendererComponent(JTree tree, Object value,
             boolean selected, boolean expanded, boolean leaf, int row,
             boolean hasFocus) {
-        super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus);
+        final Component component = super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus);
 
-        DefaultMutableTreeNode node = (DefaultMutableTreeNode) value;
-        Object nodeInfo = node.getUserObject();
+        final DefaultMutableTreeNode node = (DefaultMutableTreeNode) value;
+        final Object nodeInfo = node.getUserObject();
 
-        if (nodeInfo instanceof Severity) {
-            Severity s = (Severity) nodeInfo;
-            setIcon(ImageProvider.get("data", s.getIcon()));
-        } else if (nodeInfo instanceof TestError) {
-            TestError error = (TestError) nodeInfo;
-            MultipleNameVisitor v = error.getNameVisitor();
-            setText(v.getText());
-            setIcon(v.getIcon());
+        if (component instanceof JLabel) {
+            final JLabel label = (JLabel) component;
+            if (nodeInfo instanceof Severity) {
+                final Severity s = (Severity) nodeInfo;
+                label.setIcon(ImageProvider.get("data", s.getIcon()));
+            } else if (nodeInfo instanceof TestError) {
+                final TestError error = (TestError) nodeInfo;
+                MultipleNameVisitor v = error.getNameVisitor();
+                label.setText(v.getText());
+                label.setIcon(v.getIcon());
+            }
         }
-        return this;
+        return component;
     }
 }
Index: src/org/openstreetmap/josm/gui/widgets/HtmlTreeCellRenderer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/widgets/HtmlTreeCellRenderer.java b/src/org/openstreetmap/josm/gui/widgets/HtmlTreeCellRenderer.java
new file mode 100644
--- /dev/null	(date 1673900867295)
+++ b/src/org/openstreetmap/josm/gui/widgets/HtmlTreeCellRenderer.java	(date 1673900867295)
@@ -0,0 +1,260 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.widgets;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Cursor;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.event.MouseEvent;
+import java.text.MessageFormat;
+import java.util.Arrays;
+
+import javax.annotation.Nullable;
+import javax.swing.JLabel;
+import javax.swing.JTree;
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.MouseInputListener;
+import javax.swing.text.html.HTMLDocument;
+import javax.swing.text.html.StyleSheet;
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.DefaultTreeCellRenderer;
+import javax.swing.tree.TreePath;
+
+import org.openstreetmap.josm.tools.ColorHelper;
+import org.openstreetmap.josm.tools.OpenBrowser;
+
+/**
+ * A {@link javax.swing.tree.TreeCellRenderer} for cells that may contain hyperlinks.
+ * @since xxx
+ */
+public class HtmlTreeCellRenderer extends DefaultTreeCellRenderer {
+    /**
+     * This only exists since the JTree cannot pass events to subcomponents.
+     */
+    private static class JTreeMouseInputListener implements MouseInputListener {
+        @Override
+        public void mouseClicked(MouseEvent mouseEvent) {
+            dispatchEvent(mouseEvent);
+        }
+
+        @Override
+        public void mouseMoved(MouseEvent mouseEvent) {
+            dispatchEvent(mouseEvent);
+        }
+
+        @Override
+        public void mousePressed(MouseEvent mouseEvent) {
+            // Do nothing -- this can cause issues with the popup menu
+        }
+
+        @Override
+        public void mouseReleased(MouseEvent mouseEvent) {
+            // Do nothing -- this can cause issues with the popup menu
+        }
+
+        @Override
+        public void mouseEntered(MouseEvent mouseEvent) {
+            dispatchEvent(mouseEvent);
+        }
+
+        @Override
+        public void mouseExited(MouseEvent mouseEvent) {
+            dispatchEvent(mouseEvent);
+        }
+
+        @Override
+        public void mouseDragged(MouseEvent mouseEvent) {
+            dispatchEvent(mouseEvent);
+        }
+
+        /**
+         * Dispatch mouse events for HTML-like cells
+         * @param mouseEvent The event to dispatch
+         */
+        private static void dispatchEvent(MouseEvent mouseEvent) {
+            if (mouseEvent.getComponent() instanceof JTree) {
+                final Point p = mouseEvent.getPoint();
+                final JTree tree = (JTree) mouseEvent.getComponent();
+                final Component component = getComponentAt(tree, p); // p is translated here
+                if (component != null) {
+                    component.dispatchEvent(new MouseEvent(component, // Use the component we found to fire the event
+                            mouseEvent.getID(), mouseEvent.getWhen(), mouseEvent.getModifiers(),
+                            p.x, p.y, mouseEvent.getClickCount(), mouseEvent.isPopupTrigger(),
+                            mouseEvent.getButton()));
+                    component.setBounds(0, 0, 0, 0);
+                }
+            }
+        }
+
+        /**
+         * Get a rendered component for HTML
+         * @param tree The tree to get the component from
+         * @param p The point to get the component at (will be modified)
+         * @return The component
+         */
+        @Nullable
+        private static Component getComponentAt(JTree tree, Point p) {
+            final TreePath path = tree.getPathForLocation(p.x, p.y);
+            if (path != null && tree.getCellRenderer() instanceof HtmlTreeCellRenderer) {
+                final HtmlTreeCellRenderer renderer = (HtmlTreeCellRenderer) tree.getCellRenderer();
+                final JosmEditorPane panel = renderer.panel;
+                renderer.panel.setText(null); // Reset text in panel to avoid having links in sub-elements.
+                final int row = tree.getRowForPath(path);
+                final Object last = path.getLastPathComponent();
+                // We need to get the right text into the panel, so call the renderer
+                final Component component = renderer.getTreeCellRendererComponent(tree, last,
+                        tree.isRowSelected(row), tree.isExpanded(row),
+                        tree.getModel().isLeaf(last), row, true);
+                final Rectangle bounds = tree.getPathBounds(path);
+                if (bounds != null) {
+                    // If we don't translate the point, we are attempting to get the link in the wrong frame of reference
+                    // The reference x/y are 0. This is set in BasicTextUI#getVisibleEditorRect, so we must translate the point here.
+                    final int width = (component instanceof JLabel)
+                            ? ((JLabel) component).getIcon().getIconWidth() + ((JLabel) component).getIconTextGap()
+                            : 0;
+                    // This translation puts the origin point at the upper-left of the JLabel, but moves it to the right of the icon to the text
+                    // Yes, it is - width, not + width. If you change this, make certain that clicking links still works!
+                    p.translate(-bounds.x - width, -bounds.y);
+                    // Just make certain that we are consistent for other objects.
+                    bounds.x = 0;
+                    bounds.y = 0;
+                    // Set the correct width
+                    bounds.width -= width;
+                    // Set the bounds from the JTree component (needed for proper layout)
+                    panel.setBounds(bounds);
+                    return panel;
+                }
+            }
+            return null;
+        }
+    }
+
+    private static final long serialVersionUID = -4842755204541968238L;
+    /**
+     * A reusable panel to avoid new objects where possible.
+     * It isn't worth it to make a new object for each row, since it doesn't receive mouse events (tested on Java 8).
+     */
+    private final JosmEditorPane panel = new JosmEditorPane();
+    /** JTree does not send mouse events to subcomponents */
+    private final JTreeMouseInputListener mouseTreeListener = new JTreeMouseInputListener();
+    /** The tree used to set cursors when entering/leaving a hyperlink */
+    private JTree tree;
+
+    /**
+     * Create a new renderer
+     */
+    public HtmlTreeCellRenderer() {
+        panel.addHyperlinkListener(e -> {
+            if (e.getURL() != null) {
+                if (HyperlinkEvent.EventType.ACTIVATED.equals(e.getEventType())) {
+                    OpenBrowser.displayUrl(e.getURL().toString());
+                } else if (this.tree != null && HyperlinkEvent.EventType.ENTERED.equals(e.getEventType())) {
+                    this.tree.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+                } else if (this.tree != null && HyperlinkEvent.EventType.EXITED.equals(e.getEventType())) {
+                    this.tree.setCursor(Cursor.getDefaultCursor());
+                }
+            }
+        });
+        JosmEditorPane.makeJLabelLike(panel, false);
+        final Color defaultLink = ColorHelper.html2color(JosmEditorPane.getLinkColor());
+        final Color lighterLink = defaultLink.brighter().brighter();
+        final Color darkerLink = defaultLink.darker().darker();
+        final String divRule = "{0} '{'background-color:{1}; color:{2};'}'";
+        final String aRule = "{0} a '{'text-decoration: underline; color:{1}'}'";
+        StyleSheet ss = ((HTMLDocument) panel.getDocument()).getStyleSheet();
+        ss.addRule(MessageFormat.format(divRule, ".selected", ColorHelper.color2html(getBackgroundSelectionColor()),
+                ColorHelper.color2html(getTextSelectionColor())));
+        ss.addRule(MessageFormat.format(aRule, ".selected",
+                ColorHelper.color2html(getAppropriateColor(getBackgroundSelectionColor(), defaultLink, lighterLink, darkerLink))));
+        ss.addRule(MessageFormat.format(divRule, ".not-selected", ColorHelper.color2html(getBackgroundNonSelectionColor()),
+                ColorHelper.color2html(getTextNonSelectionColor())));
+        ss.addRule(MessageFormat.format(aRule, ".not-selected",
+                ColorHelper.color2html(getAppropriateColor(getBackgroundNonSelectionColor(), defaultLink, lighterLink, darkerLink))));
+    }
+
+    @Override
+    public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded,
+                                                  boolean leaf, int row, boolean hasFocus) {
+        final Component component = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
+        this.tree = tree;
+        if (value instanceof DefaultMutableTreeNode) {
+            if (Arrays.stream(tree.getMouseListeners()).noneMatch(this.mouseTreeListener::equals)) {
+                tree.addMouseListener(this.mouseTreeListener);
+                tree.addMouseMotionListener(this.mouseTreeListener);
+            }
+            final DefaultMutableTreeNode node = (DefaultMutableTreeNode) value;
+            final Object object = node.getUserObject();
+            if (object instanceof String) {
+                final String text = (String) object;
+                final String modified = "<html><div class=\"" + (sel ? "selected" : "not-selected") + "\">"
+                        + text + "</div></html>";
+                this.panel.setText(modified);
+                this.setText(this.panel.getText()); // This gets the custom CSS into the JLabel
+            }
+        }
+        return component;
+    }
+
+    /**
+     * Convert the rgb value for use in a relative luminance calculation
+     * @param rgb The value to convert
+     * @return The appropriate value to use in the calculation
+     */
+    private static double convertRGB(int rgb) {
+        final double normalized = rgb / 255d;
+        if (normalized <= 0.03928) {
+            return normalized / 12.92;
+        }
+        return Math.pow(((normalized + 0.055)/1.055), 2.4);
+    }
+
+    /**
+     * Calculate the contrast ratio between two luminance values
+     * @param luminanceOne The first luminance value
+     * @param luminanceTwo The second luminance value
+     * @return The contrast ratio. Higher is usually better.
+     */
+    private static double contrastRatio(double luminanceOne, double luminanceTwo) {
+        final double min = Math.min(luminanceOne, luminanceTwo);
+        final double max = Math.max(luminanceOne, luminanceTwo);
+        return (max + 0.05) / (min + 0.05);
+    }
+
+    /**
+     * Calculate the luminance of a color
+     * @param red The red part
+     * @param green The green part
+     * @param blue The blue part
+     * @return The relative luminance (0-1, 0 black, 1 white)
+     */
+    private static double luminance(int red, int green, int blue) {
+        return 0.2126 * convertRGB(red)
+                + 0.7152 * convertRGB(green)
+                + 0.0722 * convertRGB(blue);
+    }
+
+    /**
+     * Get the appropriate color given a background
+     * @param background The background color
+     * @param options The options to choose from
+     * @return The appropriate color
+     */
+    private static Color getAppropriateColor(Color background, Color... options) {
+        if (options.length < 2) {
+            throw new IllegalArgumentException("There must be at least two color options");
+        }
+        // sRGB calculation
+        final double backgroundLuminance = luminance(background.getRed(), background.getGreen(), background.getBlue());
+        double lastContrastRatio = 0; // W3 recommends a contrast ratio of at least 4.5:1. We should be shooting for 7:1.
+        Color lastColor = null;
+        for (Color option : options) {
+            final double contrastRatio = contrastRatio(backgroundLuminance, luminance(option.getRed(), option.getGreen(), option.getBlue()));
+            if (contrastRatio > lastContrastRatio) {
+                lastContrastRatio = contrastRatio;
+                lastColor = option;
+            }
+        }
+        return lastColor;
+    }
+}
Index: test/unit/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/test/unit/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerTest.java b/test/unit/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerTest.java
--- a/test/unit/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerTest.java	(revision 18634)
+++ b/test/unit/org/openstreetmap/josm/data/validation/tests/MapCSSTagCheckerTest.java	(date 1673958262989)
@@ -1,6 +1,7 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.validation.tests;
 
+import static org.junit.jupiter.api.Assertions.assertAll;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -14,10 +15,14 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Stream;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.command.ChangePropertyCommand;
 import org.openstreetmap.josm.command.ChangePropertyKeyCommand;
@@ -105,14 +110,17 @@
         final Collection<TestError> errors = check.getErrorsForPrimitive(n1, check.whichSelectorMatchesPrimitive(n1), new Environment(), null);
         assertEquals(1, errors.size());
         TestError err = errors.iterator().next();
-        assertEquals("deprecated", err.getMessage());
-        assertEquals("natural=marsh is deprecated", err.getDescription());
-        assertEquals(Severity.WARNING, err.getSeverity());
-        assertEquals("Sequence: Fix of natural=marsh is deprecated", check.fixPrimitive(n1).getDescriptionText());
-        assertEquals("{natural=}", ((ChangePropertyCommand) check.fixPrimitive(n1).getChildren().iterator().next()).getTags().toString());
-        assertFalse(check.test(n2));
-        assertEquals("The key is natural and the value is marsh",
-                MapCSSTagCheckerRule.insertArguments(check.rule.selectors.get(0), "The key is {0.key} and the value is {0.value}", null));
+        assertAll(() -> assertEquals("deprecated", err.getMessage()),
+                () -> assertEquals("<a href=\"https://wiki.openstreetmap.org/wiki/Key:natural\">natural</a>=marsh is deprecated",
+                        err.getDescription()),
+                () -> assertEquals(Severity.WARNING, err.getSeverity()),
+                () -> assertEquals("Sequence: Fix of natural=marsh is deprecated", check.fixPrimitive(n1).getDescriptionText()),
+                () -> assertEquals("{natural=}",
+                        ((ChangePropertyCommand) check.fixPrimitive(n1).getChildren().iterator().next()).getTags().toString()),
+                () -> assertFalse(check.test(n2)),
+                () -> assertEquals("The key is natural and the value is marsh",
+                MapCSSTagCheckerRule.insertArguments(check.rule.selectors.get(0),
+                        "The key is {0.key} and the value is {0.value}", null, false)));
     }
 
     /**
@@ -144,9 +152,10 @@
                 "throwWarning: tr(\"has {0} but not {1}\", \"{0.key}\", \"{1.key}\");}");
         final OsmPrimitive p = OsmUtils.createPrimitive("way alt_name=Foo");
         final Collection<TestError> errors = test.getErrorsForPrimitive(p, false);
-        assertEquals(1, errors.size());
-        assertEquals("has alt_name but not name", errors.iterator().next().getMessage());
-        assertEquals("3000_has alt_name but not name", errors.iterator().next().getIgnoreSubGroup());
+        assertAll(() -> assertEquals(1, errors.size()),
+                () -> assertEquals("has <a href=\"https://wiki.openstreetmap.org/wiki/Key:alt_name\">alt_name</a> but not " +
+                        "<a href=\"https://wiki.openstreetmap.org/wiki/Key:name\">name</a>", errors.iterator().next().getMessage()),
+                () -> assertEquals("3000_has alt_name but not name", errors.iterator().next().getIgnoreSubGroup()));
     }
 
     /**
@@ -159,9 +168,10 @@
                 "  throwWarning: tr(\"{0} used with {1}\", \"{0.value}\", \"{1.tag}\");}");
         final OsmPrimitive p = OsmUtils.createPrimitive("way highway=footway foot=no");
         final Collection<TestError> errors = test.getErrorsForPrimitive(p, false);
-        assertEquals(1, errors.size());
-        assertEquals("footway used with foot=no", errors.iterator().next().getMessage());
-        assertEquals("3000_footway used with foot=no", errors.iterator().next().getIgnoreSubGroup());
+        assertAll(() -> assertEquals(1, errors.size()),
+                () -> assertEquals("<a href=\"https://wiki.openstreetmap.org/wiki/Tag:highway=footway\">footway</a> used with " +
+                        "<a href=\"https://wiki.openstreetmap.org/wiki/Tag:foot=no\">foot=no</a>", errors.iterator().next().getMessage()),
+                () -> assertEquals("3000_footway used with foot=no", errors.iterator().next().getIgnoreSubGroup()));
     }
 
     /**
@@ -207,19 +217,23 @@
         assertTrue(c.getErrorsForPrimitive(node, false).isEmpty());
     }
 
+    static Stream<Arguments> testAssertions() {
+        MapCSSTagChecker c = new MapCSSTagChecker();
+        return ValidatorPrefHelper.INSTANCE.getDefault().stream()
+                .map(entry -> Arguments.of(entry, c));
+    }
+
     /**
      * Unit test for all {@link TagTest} assertions.
      * @throws Exception if an error occurs
      */
-    @Test
-    void testAssertions() throws Exception {
-        MapCSSTagChecker c = new MapCSSTagChecker();
+    @ParameterizedTest
+    @MethodSource
+    void testAssertions(ExtendedSourceEntry entry, MapCSSTagChecker c) throws Exception {
         Set<String> assertionErrors = new LinkedHashSet<>();
 
         // initialize
-        for (ExtendedSourceEntry entry : ValidatorPrefHelper.INSTANCE.getDefault()) {
-            c.addMapCSS(entry.url, assertionErrors::add);
-        }
+        c.addMapCSS(entry.url, assertionErrors::add);
 
         for (String msg : assertionErrors) {
             Logging.error(msg);
Index: test/unit/org/openstreetmap/josm/gui/tagging/presets/TaggingPresetValidationTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/test/unit/org/openstreetmap/josm/gui/tagging/presets/TaggingPresetValidationTest.java b/test/unit/org/openstreetmap/josm/gui/tagging/presets/TaggingPresetValidationTest.java
--- a/test/unit/org/openstreetmap/josm/gui/tagging/presets/TaggingPresetValidationTest.java	(revision 18634)
+++ b/test/unit/org/openstreetmap/josm/gui/tagging/presets/TaggingPresetValidationTest.java	(date 1673890919678)
@@ -4,7 +4,7 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
-import java.util.Arrays;
+import java.util.Collections;
 import java.util.Locale;
 
 import javax.swing.JLabel;
@@ -31,7 +31,7 @@
      */
     @RegisterExtension
     @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
-    public JOSMTestRules rule = new JOSMTestRules().projection();
+    static JOSMTestRules rule = new JOSMTestRules().projection();
 
     @BeforeEach
     void setUp() {
@@ -54,10 +54,10 @@
         assertTrue(label.isVisible());
         assertEquals("<html><ul>" +
             "<li>Opening hours syntax (Hours without minutes)</li>" +
-            "<li>unusual value of width: meters is default; only positive values; point is decimal separator; if units, put space then unit</li>" +
-            "<li>unusual value of incline, use x% or x° or up or down instead</li>" +
-            "<li>suspicious tag combination (width on suspicious object)</li>" +
-            "<li>suspicious tag combination (incline on suspicious object)</li></ul>", label.getToolTipText());
+            "<li>unusual value of <a href=\"https://wiki.openstreetmap.org/wiki/Key:width\">width</a>: meters is default; only positive values; point is decimal separator; if units, put space then unit</li>" +
+            "<li>unusual value of <a href=\"https://wiki.openstreetmap.org/wiki/Key:incline\">incline</a>, use x% or x° or up or down instead</li>" +
+            "<li>suspicious tag combination (<a href=\"https://wiki.openstreetmap.org/wiki/Key:width\">width</a> on suspicious object)</li>" +
+            "<li>suspicious tag combination (<a href=\"https://wiki.openstreetmap.org/wiki/Key:incline\">incline</a> on suspicious object)</li></ul>", label.getToolTipText());
         // CHECKSTYLE.ON: LineLength
     }
 
@@ -68,7 +68,7 @@
     void testApplyChangedTags() {
         OsmPrimitive primitive = OsmUtils.createPrimitive("way incline=10m width=1mm opening_hours=\"Mo-Fr 8-10\"");
         new DataSet(primitive);
-        OsmPrimitive clone = TaggingPresetValidation.applyChangedTags(primitive, Arrays.asList(new Tag("incline", "20m")));
+        OsmPrimitive clone = TaggingPresetValidation.applyChangedTags(primitive, Collections.singletonList(new Tag("incline", "20m")));
         assertEquals("20m", clone.get("incline"));
         assertEquals("1mm", clone.get("width"));
     }
