From 0548bfe6e284c3ca8db13d54d31a1cfe8fcc73ba Mon Sep 17 00:00:00 2001 From: Heiko Klare Date: Thu, 14 May 2026 09:16:06 +0200 Subject: [PATCH] Add tests for find/replace UI history store and overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history feature of the find/replace UI lacks test coverage in two distinct areas: HistoryStore (unit tests): HistoryStoreTest covers the storage class in isolation — ordering (newest-first), deduplication, size-limit enforcement, remove semantics, persistence across instances sharing the same IDialogSettings, and independence of separate sections. Unit tests are necessary here because the class carries non-trivial logic (e.g. the in-place deduplication loop in write()) that should be verified without spinning up the Eclipse workbench, and because failures in this layer would otherwise only surface as hard-to-diagnose UI test failures. Find/replace UI (integration tests): five new tests in FindReplaceUITest verify that history is correctly populated after search, replace-all, and single replace, and that entries are ordered newest-first with duplicates suppressed. Running them in the common superclass ensures both the overlay and the dialog are covered. IFindReplaceUIAccess gains selectFindHistoryEntry(int) to abstract over the two different navigation mechanisms (ARROW_DOWN in the overlay text field, combo selection in the dialog). Two additional tests remain in FindReplaceOverlayTest to cover the overlay-specific forward and backward search buttons, which have no dialog counterpart. Fixes https://github.com/eclipse-platform/eclipse.platform.ui/issues/2100 --- .../META-INF/MANIFEST.MF | 7 +- .../findandreplace/FindReplaceUITest.java | 74 +++++ .../findandreplace/HistoryStoreTest.java | 255 ++++++++++++++++++ .../findandreplace/IFindReplaceUIAccess.java | 2 + .../overlay/FindReplaceOverlayTest.java | 31 +++ .../findandreplace/overlay/OverlayAccess.java | 11 + .../texteditor/tests/DialogAccess.java | 5 + .../tests/WorkbenchTextEditorTestSuite.java | 2 + 8 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/HistoryStoreTest.java diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/META-INF/MANIFEST.MF b/tests/org.eclipse.ui.workbench.texteditor.tests/META-INF/MANIFEST.MF index c39e7b8fbdd..8fc2605ffed 100644 --- a/tests/org.eclipse.ui.workbench.texteditor.tests/META-INF/MANIFEST.MF +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/META-INF/MANIFEST.MF @@ -22,9 +22,10 @@ Require-Bundle: Bundle-RequiredExecutionEnvironment: JavaSE-21 Eclipse-BundleShape: dir Automatic-Module-Name: org.eclipse.ui.workbench.texteditor.tests -Import-Package: org.mockito, - org.mockito.stubbing;version="5.5.0", - org.junit.jupiter.api;version="[5.14.0,6.0.0)", +Import-Package: org.junit.jupiter.api;version="[5.14.0,6.0.0)", org.junit.jupiter.api.function;version="[5.14.0,6.0.0)", org.junit.platform.suite.api;version="[1.14.0,2.0.0)", + org.mockito, + org.mockito.invocation;version="[5.23.0,6.0.0)", + org.mockito.stubbing;version="5.5.0", org.opentest4j;version="[1.3.0,2.0.0)" diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/FindReplaceUITest.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/FindReplaceUITest.java index c253586ccc3..efca3df0db4 100644 --- a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/FindReplaceUITest.java +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/FindReplaceUITest.java @@ -376,6 +376,80 @@ public void testReplaceIfSelectedOnStart() { assertThat(fTextViewer.getDocument().get(), is("abaaefg")); } + @Test + public void testSearchTermStoredInHistoryAfterSearch() { + // Performing a search must persist the search term so that the user can + // navigate back to it via history in a subsequent session. + initializeTextViewerWithFindReplaceUI("foo bar foo"); + dialog.setFindText("foo"); + dialog.simulateKeyboardInteractionInFindInputField(SWT.CR, false); + + dialog.selectFindHistoryEntry(0); + assertEquals("foo", dialog.getFindText()); + } + + @Test + public void testSearchHistoryContainsAllRecentTermsNewestFirst() { + // Multiple searches must all appear in history ordered newest-first, so that + // index 0 always yields the most recently used term. + initializeTextViewerWithFindReplaceUI("foo bar baz foo bar baz"); + dialog.setFindText("foo"); + dialog.simulateKeyboardInteractionInFindInputField(SWT.CR, false); + dialog.setFindText("bar"); + dialog.simulateKeyboardInteractionInFindInputField(SWT.CR, false); + + dialog.selectFindHistoryEntry(0); + assertEquals("bar", dialog.getFindText()); + + dialog.selectFindHistoryEntry(1); + assertEquals("foo", dialog.getFindText()); + } + + @Test + public void testSearchHistoryDeduplicatesRepeatedSearchTerms() { + // Searching for the same term twice must not create a duplicate entry in + // history. If it did, index 1 would show "foo" again instead of "bar". + initializeTextViewerWithFindReplaceUI("foo bar foo bar"); + dialog.setFindText("bar"); + dialog.simulateKeyboardInteractionInFindInputField(SWT.CR, false); + dialog.setFindText("foo"); + dialog.simulateKeyboardInteractionInFindInputField(SWT.CR, false); + // Search "foo" a second time — must not insert a second "foo" entry. + dialog.setFindText("foo"); + dialog.simulateKeyboardInteractionInFindInputField(SWT.CR, false); + + dialog.selectFindHistoryEntry(0); + assertEquals("foo", dialog.getFindText()); + // A duplicate "foo" would appear here instead of "bar". + dialog.selectFindHistoryEntry(1); + assertEquals("bar", dialog.getFindText()); + } + + @Test + public void testSearchTermStoredInHistoryAfterReplaceAll() { + // A replace-all operation must persist the search term to history so that + // it is available for future searches. + initializeTextViewerWithFindReplaceUI("foo foo foo"); + dialog.setFindText("foo"); + dialog.setReplaceText("bar"); + dialog.performReplaceAll(); + + dialog.selectFindHistoryEntry(0); + assertEquals("foo", dialog.getFindText()); + } + + @Test + public void testSearchTermStoredInHistoryAfterSingleReplace() { + // A single replace operation must also persist the search term to history. + initializeTextViewerWithFindReplaceUI("foo bar"); + dialog.setFindText("foo"); + dialog.setReplaceText("baz"); + dialog.performReplace(); + + dialog.selectFindHistoryEntry(0); + assertEquals("foo", dialog.getFindText()); + } + protected AccessType getDialog() { return dialog; } diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/HistoryStoreTest.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/HistoryStoreTest.java new file mode 100644 index 00000000000..ddc5cdd285d --- /dev/null +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/HistoryStoreTest.java @@ -0,0 +1,255 @@ +/******************************************************************************* + * Copyright (c) 2025 Vector Informatik GmbH and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Vector Informatik GmbH - initial API and implementation + *******************************************************************************/ +package org.eclipse.ui.internal.findandreplace; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.eclipse.jface.dialogs.IDialogSettings; + +public class HistoryStoreTest { + + /** + * Returns a minimal {@link IDialogSettings} stub that stores and retrieves + * {@code String[]} values in memory, keyed by section name. All other + * {@code IDialogSettings} methods are left as Mockito no-ops / default returns. + */ + private IDialogSettings createInMemoryDialogSettings() { + Map store = new HashMap<>(); + IDialogSettings settings = mock(IDialogSettings.class); + when(settings.getArray(anyString())).thenAnswer(inv -> store.get(inv.getArgument(0))); + doAnswer(inv -> { + store.put(inv.getArgument(0), inv.getArgument(1)); + return null; + }).when(settings).put(anyString(), any(String[].class)); + return settings; + } + + @Test + public void testConstructorThrowsOnNullSectionName() { + assertThrows(IllegalStateException.class, + () -> new HistoryStore(createInMemoryDialogSettings(), null, 5)); + } + + @Test + public void testNewStoreIsEmpty() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + assertTrue(store.isEmpty()); + assertEquals(0, store.size()); + } + + @Test + public void testAddSingleItem() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + + store.add("item"); + + assertFalse(store.isEmpty()); + assertEquals(1, store.size()); + assertEquals("item", store.get(0)); + } + + @Test + public void testAddNullIsIgnored() { + // Null items must not be stored; history stays empty. + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + + store.add(null); + + assertTrue(store.isEmpty()); + } + + @Test + public void testAddEmptyStringIsIgnored() { + // Empty strings must not be stored; an empty search term is not useful history. + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + + store.add(""); + + assertTrue(store.isEmpty()); + } + + @Test + public void testMostRecentlyAddedItemIsFirst() { + // The most recently added item should appear at index 0 so that history + // navigation reaches recent searches first. + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + + store.add("first"); + store.add("second"); + store.add("third"); + + assertEquals("third", store.get(0)); + assertEquals("second", store.get(1)); + assertEquals("first", store.get(2)); + assertEquals(3, store.size()); + } + + @Test + public void testAddingExistingItemMovesItToFront() { + // Re-adding a term already in history should move it to index 0 without + // creating a duplicate. This mirrors how every search toolbar works: the most + // recently used term comes first. + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("first"); + store.add("second"); + + store.add("first"); + + assertEquals(2, store.size()); + assertEquals("first", store.get(0)); + assertEquals("second", store.get(1)); + } + + @Test + public void testAddingItemAlreadyAtFrontKeepsItThere() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("item"); + + store.add("item"); + + assertEquals(1, store.size()); + assertEquals("item", store.get(0)); + } + + @Test + public void testHistorySizeLimitDropsOldestEntries() { + // Once the capacity is reached, the oldest (highest-index) entry must be + // dropped to make room for the new one. + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 3); + store.add("first"); + store.add("second"); + store.add("third"); + + store.add("fourth"); + + assertEquals(3, store.size()); + assertEquals("fourth", store.get(0)); + assertEquals("third", store.get(1)); + assertEquals("second", store.get(2)); + } + + @Test + public void testRemoveExistingItem() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("first"); + store.add("second"); + + store.remove("first"); + + assertEquals(1, store.size()); + assertEquals("second", store.get(0)); + } + + @Test + public void testRemoveItemAtFront() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("first"); + store.add("second"); + + store.remove("second"); + + assertEquals(1, store.size()); + assertEquals("first", store.get(0)); + } + + @Test + public void testRemoveNonExistentItemIsNoOp() { + // Removing a term that was never stored must not alter the history. + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("item"); + + store.remove("nonexistent"); + + assertEquals(1, store.size()); + assertEquals("item", store.get(0)); + } + + @Test + public void testIndexOfReturnsCorrectPositions() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("first"); + store.add("second"); + store.add("third"); + + assertEquals(0, store.indexOf("third")); + assertEquals(1, store.indexOf("second")); + assertEquals(2, store.indexOf("first")); + } + + @Test + public void testIndexOfReturnsMinusOneForAbsentItem() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("item"); + + assertEquals(-1, store.indexOf("nonexistent")); + } + + @Test + public void testGetIterableReturnsItemsNewestFirst() { + HistoryStore store = new HistoryStore(createInMemoryDialogSettings(), "section", 5); + store.add("first"); + store.add("second"); + + List items = new ArrayList<>(); + for (String item : store.get()) { + items.add(item); + } + + assertEquals(List.of("second", "first"), items); + } + + @Test + public void testHistoryPersistedAcrossInstances() { + // Two HistoryStore instances pointing to the same IDialogSettings section must + // share the same data, modelling persistence across workbench sessions. + IDialogSettings sharedSettings = createInMemoryDialogSettings(); + HistoryStore store1 = new HistoryStore(sharedSettings, "section", 5); + store1.add("first"); + store1.add("second"); + + HistoryStore store2 = new HistoryStore(sharedSettings, "section", 5); + + assertEquals(2, store2.size()); + assertEquals("second", store2.get(0)); + assertEquals("first", store2.get(1)); + } + + @Test + public void testDistinctSectionsAreIndependent() { + // Two stores sharing the same IDialogSettings but using different section names + // must not interfere with each other (models separate find/replace histories). + IDialogSettings sharedSettings = createInMemoryDialogSettings(); + HistoryStore findHistory = new HistoryStore(sharedSettings, "findhistory", 5); + HistoryStore replaceHistory = new HistoryStore(sharedSettings, "replacehistory", 5); + findHistory.add("findterm"); + + assertTrue(replaceHistory.isEmpty()); + } + +} diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/IFindReplaceUIAccess.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/IFindReplaceUIAccess.java index 57a4edd6f04..55d60f7f685 100644 --- a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/IFindReplaceUIAccess.java +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/IFindReplaceUIAccess.java @@ -49,6 +49,8 @@ public interface IFindReplaceUIAccess { void performReplaceAndFind(); + void selectFindHistoryEntry(int index); + void assertInitialConfiguration(); void assertUnselected(SearchOptions option); diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/FindReplaceOverlayTest.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/FindReplaceOverlayTest.java index ee073791589..e74eb92d3b4 100644 --- a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/FindReplaceOverlayTest.java +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/FindReplaceOverlayTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; +import org.eclipse.swt.SWT; import org.eclipse.swt.graphics.Point; import org.eclipse.core.runtime.preferences.IEclipsePreferences; @@ -184,4 +185,34 @@ public void testDisableOverlayViaPreference() { } } + @Test + public void testSearchTermStoredInHistoryAfterSearchForward() { + // After a forward search, the term must be retrievable from history so that + // the user can navigate back to it in a subsequent session. + initializeTextViewerWithFindReplaceUI("foo bar foo"); + OverlayAccess dialog= getDialog(); + dialog.setFindText("foo"); + dialog.pressSearch(true); + + // Down-arrow navigates to the most recently stored entry (index 0). + dialog.setFindText(""); + dialog.simulateKeyboardInteractionInFindInputField(SWT.ARROW_DOWN, false); + + assertEquals("foo", dialog.getFindText()); + } + + @Test + public void testSearchTermStoredInHistoryAfterSearchBackward() { + // Backward search must persist the term to history just like forward search. + initializeTextViewerWithFindReplaceUI("foo bar foo"); + OverlayAccess dialog= getDialog(); + dialog.setFindText("foo"); + dialog.pressSearch(false); + + dialog.setFindText(""); + dialog.simulateKeyboardInteractionInFindInputField(SWT.ARROW_DOWN, false); + + assertEquals("foo", dialog.getFindText()); + } + } diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/OverlayAccess.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/OverlayAccess.java index ae07a9643ec..16de0671b77 100644 --- a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/OverlayAccess.java +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/OverlayAccess.java @@ -216,6 +216,17 @@ private Predicate isOptionSelected() { }; } + @Override + public void selectFindHistoryEntry(int index) { + // Clear the field directly (no SWT.Modify, so no incremental search fires), + // then step down once per index position. From an empty field ARROW_DOWN + // always lands on index 0 (newest); each additional press moves one step older. + find.setText(""); + for (int i = 0; i <= index; i++) { + simulateKeyboardInteractionInFindInputField(SWT.ARROW_DOWN, false); + } + } + public void pressSearch(boolean forward) { if (forward) { searchForward.notifyListeners(SWT.Selection, null); diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/DialogAccess.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/DialogAccess.java index 29247c3c0f1..9d5a10dddb0 100644 --- a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/DialogAccess.java +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/DialogAccess.java @@ -230,6 +230,11 @@ public void performReplace() { } } + @Override + public void selectFindHistoryEntry(int index) { + findCombo.select(index); + } + public void performFindNext() { if (findNextButton.getEnabled()) { findNextButton.notifyListeners(SWT.Selection, null); diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java index 30ba98d200c..d2ad80884ed 100644 --- a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java +++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/workbench/texteditor/tests/WorkbenchTextEditorTestSuite.java @@ -17,6 +17,7 @@ import org.junit.platform.suite.api.SelectClasses; import org.eclipse.ui.internal.findandreplace.FindReplaceLogicTest; +import org.eclipse.ui.internal.findandreplace.HistoryStoreTest; import org.eclipse.ui.internal.findandreplace.overlay.FindReplaceOverlayTest; import org.eclipse.ui.workbench.texteditor.tests.minimap.MinimapPageTest; @@ -48,6 +49,7 @@ FindReplaceDialogTest.class, FindReplaceOverlayTest.class, FindReplaceLogicTest.class, + HistoryStoreTest.class, }) public class WorkbenchTextEditorTestSuite { // see @SelectClasses