source: josm/trunk/src/org/openstreetmap/josm/gui/dialogs/validator/ValidatorTreePanel.java@ 15972

Last change on this file since 15972 was 15972, checked in by GerdP, 4 years ago

fix #18810: Validator dialog should show the test that produced the message

  • show test also for the grouped error. If multiple tests are involved show "Different tests"
  • for MapCss rules report also the URL/File as stated in the preference dialog, e.g. "resource://data/validator/geometry.mapcss"
  • for Java tests report "Java: " + this.getClass().getName(), e.g. "Java: org.openstreetmap.josm.data.validation.DuplicateWay"
  • Property svn:eol-style set to native
File size: 21.6 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.dialogs.validator;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.event.KeyListener;
7import java.awt.event.MouseEvent;
8import java.util.ArrayList;
9import java.util.Collection;
10import java.util.Enumeration;
11import java.util.HashSet;
12import java.util.List;
13import java.util.Map;
14import java.util.Map.Entry;
15import java.util.Set;
16import java.util.function.Consumer;
17import java.util.function.Predicate;
18
19import javax.swing.JTree;
20import javax.swing.ToolTipManager;
21import javax.swing.tree.DefaultMutableTreeNode;
22import javax.swing.tree.DefaultTreeModel;
23import javax.swing.tree.TreeNode;
24import javax.swing.tree.TreePath;
25import javax.swing.tree.TreeSelectionModel;
26
27import org.openstreetmap.josm.data.osm.OsmPrimitive;
28import org.openstreetmap.josm.data.osm.event.AbstractDatasetChangedEvent;
29import org.openstreetmap.josm.data.osm.event.DataChangedEvent;
30import org.openstreetmap.josm.data.osm.event.DataSetListener;
31import org.openstreetmap.josm.data.osm.event.DatasetEventManager;
32import org.openstreetmap.josm.data.osm.event.NodeMovedEvent;
33import org.openstreetmap.josm.data.osm.event.PrimitivesAddedEvent;
34import org.openstreetmap.josm.data.osm.event.PrimitivesRemovedEvent;
35import org.openstreetmap.josm.data.osm.event.RelationMembersChangedEvent;
36import org.openstreetmap.josm.data.osm.event.TagsChangedEvent;
37import org.openstreetmap.josm.data.osm.event.WayNodesChangedEvent;
38import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper;
39import org.openstreetmap.josm.data.validation.OsmValidator;
40import org.openstreetmap.josm.data.validation.Severity;
41import org.openstreetmap.josm.data.validation.TestError;
42import org.openstreetmap.josm.gui.util.GuiHelper;
43import org.openstreetmap.josm.tools.Destroyable;
44import org.openstreetmap.josm.tools.ListenerList;
45
46/**
47 * A panel that displays the error tree. The selection manager
48 * respects clicks into the selection list. Ctrl-click will remove entries from
49 * the list while single click will make the clicked entry the only selection.
50 *
51 * @author frsantos
52 */
53public class ValidatorTreePanel extends JTree implements Destroyable, DataSetListener {
54
55 private static final class GroupTreeNode extends DefaultMutableTreeNode {
56
57 GroupTreeNode(Object userObject) {
58 super(userObject);
59 }
60
61 @Override
62 public String toString() {
63 return tr("{0} ({1})", super.toString(), getLeafCount());
64 }
65 }
66
67 /**
68 * The validation data.
69 */
70 protected DefaultTreeModel valTreeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
71
72 /** The list of errors shown in the tree, normally identical to field validationErrors in current edit layer*/
73 private transient List<TestError> errors;
74
75 /**
76 * If {@link #filter} is not <code>null</code> only errors are displayed
77 * that refer to one of the primitives in the filter.
78 */
79 private transient Set<? extends OsmPrimitive> filter;
80
81 private final transient ListenerList<Runnable> invalidationListeners = ListenerList.create();
82
83 /** if true, buildTree() does nothing */
84 private boolean resetScheduled;
85
86 /**
87 * Constructor
88 * @param errors The list of errors
89 */
90 public ValidatorTreePanel(List<TestError> errors) {
91 setErrorList(errors);
92 ToolTipManager.sharedInstance().registerComponent(this);
93 this.setModel(valTreeModel);
94 this.setRootVisible(false);
95 this.setShowsRootHandles(true);
96 this.expandRow(0);
97 this.setVisibleRowCount(8);
98 this.setCellRenderer(new ValidatorTreeRenderer());
99 this.getSelectionModel().setSelectionMode(TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION);
100 for (KeyListener keyListener : getKeyListeners()) {
101 // Fix #3596 - Remove default keyListener to avoid conflicts with JOSM commands
102 if ("javax.swing.plaf.basic.BasicTreeUI$Handler".equals(keyListener.getClass().getName())) {
103 removeKeyListener(keyListener);
104 }
105 }
106 DatasetEventManager.getInstance().addDatasetListener(this, DatasetEventManager.FireMode.IN_EDT);
107 }
108
109 @Override
110 public String getToolTipText(MouseEvent e) {
111 String res = null;
112 TreePath path = getPathForLocation(e.getX(), e.getY());
113 if (path != null) {
114 DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
115 Object nodeInfo = node.getUserObject();
116
117 if (nodeInfo instanceof TestError) {
118 TestError error = (TestError) nodeInfo;
119 res = error.getNameVisitor().getText() + "<br>" + error.getMessage();
120 String d = error.getDescription();
121 if (d != null)
122 res += "<br>" + d;
123 res += "<br>" + tr("Test: {0}", getTesterDetails(error));
124 } else {
125 Set<String> tests = new HashSet<>();
126 visitTestErrors(node, err -> tests.add(getTesterDetails(err)), null);
127 String source = (tests.size() == 1) ? tr("Test: {0}", tests.iterator().next()) : tr("Different tests");
128 res = node.toString() + "<br>" + source;
129 }
130 }
131 return "<html>" + res + "</html>";
132 }
133
134 private static String getTesterDetails(TestError e) {
135 return e.getTester().getName() + "<br>" + e.getTester().getSource();
136 }
137
138 /** Constructor */
139 public ValidatorTreePanel() {
140 this(null);
141 }
142
143 @Override
144 public void setVisible(boolean v) {
145 if (v) {
146 buildTree();
147 } else {
148 valTreeModel.setRoot(new DefaultMutableTreeNode());
149 }
150 super.setVisible(v);
151 invalidationListeners.fireEvent(Runnable::run);
152 }
153
154 /**
155 * Builds the errors tree
156 */
157 public void buildTree() {
158 buildTree(true);
159 }
160
161 /**
162 * Builds the errors tree
163 * @param expandAgain if true, try to expand the same rows as before
164 */
165 public void buildTree(boolean expandAgain) {
166 if (resetScheduled)
167 return;
168 final DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode();
169
170 if (errors == null || errors.isEmpty()) {
171 GuiHelper.runInEDTAndWait(() -> valTreeModel.setRoot(rootNode));
172 return;
173 }
174
175 // Remember first selected tree row
176 TreePath selPath = getSelectionPath();
177 int selRow = selPath == null ? -1 : getRowForPath(selPath);
178
179 // Remember the currently expanded rows
180 Set<Object> oldExpandedRows = new HashSet<>();
181 if (expandAgain) {
182 Enumeration<TreePath> expanded = getExpandedDescendants(new TreePath(getRoot()));
183 if (expanded != null) {
184 while (expanded.hasMoreElements()) {
185 TreePath path = expanded.nextElement();
186 DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
187 Object userObject = node.getUserObject();
188 if (userObject instanceof Severity) {
189 oldExpandedRows.add(userObject);
190 } else if (userObject instanceof String) {
191 String msg = removeSize((String) userObject);
192 oldExpandedRows.add(msg);
193 }
194 }
195 }
196 }
197
198 Predicate<TestError> filterToUse = e -> !e.isIgnored();
199 if (!ValidatorPrefHelper.PREF_OTHER.get()) {
200 filterToUse = filterToUse.and(e -> e.getSeverity() != Severity.OTHER);
201 }
202 if (filter != null) {
203 filterToUse = filterToUse.and(e -> e.getPrimitives().stream().anyMatch(filter::contains));
204 }
205 Map<Severity, Map<String, Map<String, List<TestError>>>> errorsBySeverityMessageDescription
206 = OsmValidator.getErrorsBySeverityMessageDescription(errors, filterToUse);
207
208 final List<TreePath> expandedPaths = new ArrayList<>();
209 for (Entry<Severity, Map<String, Map<String, List<TestError>>>> entry: errorsBySeverityMessageDescription.entrySet()) {
210 Severity severity = entry.getKey();
211 Map<String, Map<String, List<TestError>>> errorsByMessageDescription = entry.getValue();
212
213 // Severity node
214 final DefaultMutableTreeNode severityNode = new GroupTreeNode(severity);
215 rootNode.add(severityNode);
216
217 if (oldExpandedRows.contains(severity)) {
218 expandedPaths.add(new TreePath(severityNode.getPath()));
219 }
220
221 final Map<String, List<TestError>> errorsWithEmptyMessageByDescription = errorsByMessageDescription.get("");
222 if (errorsWithEmptyMessageByDescription != null) {
223 errorsWithEmptyMessageByDescription.forEach((description, noDescriptionErrors) -> {
224 final String msg = addSize(description, noDescriptionErrors);
225 final DefaultMutableTreeNode messageNode = new DefaultMutableTreeNode(msg);
226 severityNode.add(messageNode);
227
228 if (oldExpandedRows.contains(description)) {
229 expandedPaths.add(new TreePath(messageNode.getPath()));
230 }
231 // add the matching errors to the current node
232 noDescriptionErrors.stream().map(DefaultMutableTreeNode::new).forEach(messageNode::add);
233 });
234 }
235
236 errorsByMessageDescription.forEach((message, errorsByDescription) -> {
237 if (message.isEmpty()) {
238 return;
239 }
240 // Group node
241 final DefaultMutableTreeNode groupNode;
242 if (errorsByDescription.size() > 1) {
243 groupNode = new GroupTreeNode(message);
244 severityNode.add(groupNode);
245 if (oldExpandedRows.contains(message)) {
246 expandedPaths.add(new TreePath(groupNode.getPath()));
247 }
248 } else {
249 groupNode = null;
250 }
251
252 errorsByDescription.forEach((description, errorsWithDescription) -> {
253 // Message node
254 final String searchMsg;
255 if (groupNode != null) {
256 searchMsg = description;
257 } else if (description == null || description.isEmpty()) {
258 searchMsg = message;
259 } else {
260 searchMsg = message + " - " + description;
261 }
262 final String msg = addSize(searchMsg, errorsWithDescription);
263
264 final DefaultMutableTreeNode messageNode = new DefaultMutableTreeNode(msg);
265 DefaultMutableTreeNode currNode = groupNode != null ? groupNode : severityNode;
266 currNode.add(messageNode);
267 if (oldExpandedRows.contains(searchMsg)) {
268 expandedPaths.add(new TreePath(messageNode.getPath()));
269 }
270
271 // add the matching errors to the current node
272 errorsWithDescription.stream().map(DefaultMutableTreeNode::new).forEach(messageNode::add);
273 });
274 });
275 }
276
277 valTreeModel.setRoot(rootNode);
278 for (TreePath path : expandedPaths) {
279 this.expandPath(path);
280 }
281
282 if (selPath != null) {
283 DefaultMutableTreeNode node = (DefaultMutableTreeNode) selPath.getLastPathComponent();
284 Object userObject = node.getUserObject();
285 if (userObject instanceof TestError && ((TestError) userObject).isIgnored()) {
286 // don't try to find ignored error
287 selPath = null;
288 }
289 }
290 if (selPath != null) {
291 // try to reselect previously selected row. May not work if tree structure changed too much.
292 DefaultMutableTreeNode node = (DefaultMutableTreeNode) selPath.getLastPathComponent();
293 Object searchObject = node.getUserObject();
294 String msg = null;
295 if (searchObject instanceof String) {
296 msg = removeSize((String) searchObject);
297 }
298 String searchString = msg;
299 visitTreeNodes(getRoot(), n -> {
300 boolean found = false;
301 final Object userInfo = n.getUserObject();
302 if (searchObject instanceof TestError && userInfo instanceof TestError) {
303 TestError e1 = (TestError) searchObject;
304 TestError e2 = (TestError) userInfo;
305 found |= e1.getCode() == e2.getCode() && e1.getMessage().equals(e2.getMessage())
306 && e1.getPrimitives().size() == e2.getPrimitives().size()
307 && e1.getPrimitives().containsAll(e2.getPrimitives());
308 } else if (searchObject instanceof String && userInfo instanceof String) {
309 found |= ((String) userInfo).startsWith(searchString);
310 } else if (searchObject instanceof Severity) {
311 found |= searchObject.equals(userInfo);
312 }
313
314 if (found) {
315 TreePath path = new TreePath(n.getPath());
316 setSelectionPath(path);
317 scrollPathToVisible(path);
318 }
319 });
320 }
321 if (selRow >= 0 && selRow < getRowCount() && getSelectionCount() == 0) {
322 // fall back: if we cannot find the previously selected entry, select the row by position
323 setSelectionRow(selRow);
324 scrollRowToVisible(selRow);
325 }
326
327 invalidationListeners.fireEvent(Runnable::run);
328 }
329
330 private static String addSize(String msg, Collection<?> coll) {
331 return msg + " (" + coll.size() + ")";
332 }
333
334 private static String removeSize(String msg) {
335 int index = msg.lastIndexOf(" (");
336 return index > 0 ? msg.substring(0, index) : msg;
337 }
338
339 /**
340 * Add a new invalidation listener
341 * @param listener The listener
342 */
343 public void addInvalidationListener(Runnable listener) {
344 invalidationListeners.addListener(listener);
345 }
346
347 /**
348 * Remove an invalidation listener
349 * @param listener The listener
350 * @since 10880
351 */
352 public void removeInvalidationListener(Runnable listener) {
353 invalidationListeners.removeListener(listener);
354 }
355
356 /**
357 * Sets the errors list used by a data layer
358 * @param errors The error list that is used by a data layer
359 */
360 public final void setErrorList(List<TestError> errors) {
361 if (errors != null && errors == this.errors)
362 return;
363 this.errors = errors != null ? errors : new ArrayList<>();
364 if (isVisible()) {
365 //TODO: If list is changed because another layer was activated it would be good to store/restore
366 // the expanded / selected paths.
367 clearSelection();
368 buildTree(false);
369 }
370 }
371
372 /**
373 * Clears the current error list and adds these errors to it
374 * @param newerrors The validation errors
375 */
376 public void setErrors(List<TestError> newerrors) {
377 errors.clear();
378 for (TestError error : newerrors) {
379 if (!error.isIgnored()) {
380 errors.add(error);
381 }
382 }
383 if (isVisible()) {
384 buildTree();
385 }
386 }
387
388 /**
389 * Returns the errors of the tree
390 * @return the errors of the tree
391 */
392 public List<TestError> getErrors() {
393 return errors;
394 }
395
396 /**
397 * Selects all errors related to the specified {@code primitives}, i.e. where {@link TestError#getPrimitives()}
398 * returns a primitive present in {@code primitives}.
399 * @param primitives collection of primitives
400 */
401 public void selectRelatedErrors(final Collection<OsmPrimitive> primitives) {
402 final List<TreePath> paths = new ArrayList<>();
403 walkAndSelectRelatedErrors(new TreePath(getRoot()), new HashSet<>(primitives)::contains, paths);
404 clearSelection();
405 setSelectionPaths(paths.toArray(new TreePath[0]));
406 // make sure that first path is visible
407 if (!paths.isEmpty()) {
408 scrollPathToVisible(paths.get(0));
409 }
410 }
411
412 private void walkAndSelectRelatedErrors(final TreePath p, final Predicate<OsmPrimitive> isRelevant, final Collection<TreePath> paths) {
413 final int count = getModel().getChildCount(p.getLastPathComponent());
414 for (int i = 0; i < count; i++) {
415 final Object child = getModel().getChild(p.getLastPathComponent(), i);
416 if (getModel().isLeaf(child) && child instanceof DefaultMutableTreeNode
417 && ((DefaultMutableTreeNode) child).getUserObject() instanceof TestError) {
418 final TestError error = (TestError) ((DefaultMutableTreeNode) child).getUserObject();
419 if (error.getPrimitives().stream().anyMatch(isRelevant)) {
420 paths.add(p.pathByAddingChild(child));
421 }
422 } else {
423 walkAndSelectRelatedErrors(p.pathByAddingChild(child), isRelevant, paths);
424 }
425 }
426 }
427
428 /**
429 * Returns the filter list
430 * @return the list of primitives used for filtering
431 */
432 public Set<? extends OsmPrimitive> getFilter() {
433 return filter;
434 }
435
436 /**
437 * Set the filter list to a set of primitives
438 * @param filter the list of primitives used for filtering
439 */
440 public void setFilter(Set<? extends OsmPrimitive> filter) {
441 if (filter != null && filter.isEmpty()) {
442 this.filter = null;
443 } else {
444 this.filter = filter;
445 }
446 if (isVisible()) {
447 buildTree();
448 }
449 }
450
451 /**
452 * Updates the current errors list
453 */
454 public void resetErrors() {
455 resetScheduled = false;
456 filterRemovedPrimitives();
457 setErrors(new ArrayList<>(errors));
458 }
459
460 /**
461 * Expands complete tree
462 */
463 public void expandAll() {
464 visitTreeNodes(getRoot(), x -> expandPath(new TreePath(x.getPath())));
465 }
466
467 /**
468 * Returns the root node model.
469 * @return The root node model
470 */
471 public DefaultMutableTreeNode getRoot() {
472 return (DefaultMutableTreeNode) valTreeModel.getRoot();
473 }
474
475 @Override
476 public void destroy() {
477 DatasetEventManager.getInstance().removeDatasetListener(this);
478 ToolTipManager.sharedInstance().unregisterComponent(this);
479 errors.clear();
480 }
481
482 /**
483 * Visitor call for all tree nodes children of root, in breadth-first order.
484 * @param root Root node
485 * @param visitor Visitor
486 * @since 13940
487 */
488 public static void visitTreeNodes(DefaultMutableTreeNode root, Consumer<DefaultMutableTreeNode> visitor) {
489 @SuppressWarnings("unchecked")
490 Enumeration<TreeNode> errorMessages = root.breadthFirstEnumeration();
491 while (errorMessages.hasMoreElements()) {
492 visitor.accept(((DefaultMutableTreeNode) errorMessages.nextElement()));
493 }
494 }
495
496 /**
497 * Visitor call for all {@link TestError} nodes children of root, in breadth-first order.
498 * @param root Root node
499 * @param visitor Visitor
500 * @since 13940
501 */
502 public static void visitTestErrors(DefaultMutableTreeNode root, Consumer<TestError> visitor) {
503 visitTestErrors(root, visitor, null);
504 }
505
506 /**
507 * Visitor call for all {@link TestError} nodes children of root, in breadth-first order.
508 * @param root Root node
509 * @param visitor Visitor
510 * @param processedNodes Set of already visited nodes (optional)
511 * @since 13940
512 */
513 public static void visitTestErrors(DefaultMutableTreeNode root, Consumer<TestError> visitor,
514 Set<DefaultMutableTreeNode> processedNodes) {
515 visitTreeNodes(root, n -> {
516 if (processedNodes == null || !processedNodes.contains(n)) {
517 if (processedNodes != null) {
518 processedNodes.add(n);
519 }
520 Object o = n.getUserObject();
521 if (o instanceof TestError) {
522 visitor.accept((TestError) o);
523 }
524 }
525 });
526 }
527
528 @Override public void primitivesRemoved(PrimitivesRemovedEvent event) {
529 // Remove purged primitives (fix #8639)
530 if (filterRemovedPrimitives()) {
531 buildTree();
532 }
533 }
534
535 @Override public void primitivesAdded(PrimitivesAddedEvent event) {
536 // Do nothing
537 }
538
539 @Override public void tagsChanged(TagsChangedEvent event) {
540 // Do nothing
541 }
542
543 @Override public void nodeMoved(NodeMovedEvent event) {
544 // Do nothing
545 }
546
547 @Override public void wayNodesChanged(WayNodesChangedEvent event) {
548 // Do nothing
549 }
550
551 @Override public void relationMembersChanged(RelationMembersChangedEvent event) {
552 // Do nothing
553 }
554
555 @Override public void otherDatasetChange(AbstractDatasetChangedEvent event) {
556 // Do nothing
557 }
558
559 @Override public void dataChanged(DataChangedEvent event) {
560 if (filterRemovedPrimitives()) {
561 buildTree();
562 }
563 }
564
565 /**
566 * Can be called to suppress execution of buildTree() while doing multiple updates. Caller must
567 * call resetErrors() to end this state.
568 * @since 14849
569 */
570 public void setResetScheduled() {
571 resetScheduled = true;
572 }
573
574 /**
575 * Remove errors which refer to removed or purged primitives.
576 * @return true if error list was changed
577 */
578 private boolean filterRemovedPrimitives() {
579 return errors.removeIf(
580 error -> error.getPrimitives().stream().anyMatch(p -> p.isDeleted() || p.getDataSet() == null));
581 }
582
583}
Note: See TracBrowser for help on using the repository browser.