From 0ce289951e136229545db9a5b72695a185c0ed75 Mon Sep 17 00:00:00 2001 From: Lars Vogel Date: Tue, 7 Jul 2026 13:48:55 +0200 Subject: [PATCH] Add tests for the unified diff compare editor open path UnifiedDiffOpenTest pins the behavior of opening a compare editor with the unified diff preference enabled: a workspace-file input with shared document adapters opens a plain text editor with unified diff annotations instead of the compare editor, prepareInput runs exactly once, and an input without shared document adapters falls back to the classic compare editor. One test documents that the unified path currently runs prepareInput on the UI thread; its assertion flips when the preparation moves to a background job. Contributes to https://github.com/eclipse-platform/eclipse.platform/issues/2795 --- .../compare/tests/AllCompareTests.java | 1 + .../compare/tests/UnifiedDiffOpenTest.java | 341 ++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/UnifiedDiffOpenTest.java diff --git a/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/AllCompareTests.java b/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/AllCompareTests.java index 3305135b9d3..9b86005845f 100644 --- a/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/AllCompareTests.java +++ b/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/AllCompareTests.java @@ -38,6 +38,7 @@ RangeDifferencerThreeWayDiffTest.class, CompareUIPluginTest.class, CompareOpenEfficiencyTest.class, + UnifiedDiffOpenTest.class, StructureCreatorTest.class, CompareFileRevisionEditorInputTest.class}) public class AllCompareTests { diff --git a/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/UnifiedDiffOpenTest.java b/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/UnifiedDiffOpenTest.java new file mode 100644 index 00000000000..3b04156252d --- /dev/null +++ b/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/UnifiedDiffOpenTest.java @@ -0,0 +1,341 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel 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: + * Eclipse contributors - initial API and implementation + *******************************************************************************/ +package org.eclipse.compare.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import org.eclipse.compare.CompareConfiguration; +import org.eclipse.compare.CompareEditorInput; +import org.eclipse.compare.CompareUI; +import org.eclipse.compare.IEncodedStreamContentAccessor; +import org.eclipse.compare.IResourceProvider; +import org.eclipse.compare.ISharedDocumentAdapter; +import org.eclipse.compare.ITypedElement; +import org.eclipse.compare.SharedDocumentAdapter; +import org.eclipse.compare.internal.CompareEditor; +import org.eclipse.compare.internal.ComparePreferencePage; +import org.eclipse.compare.internal.CompareUIPlugin; +import org.eclipse.compare.structuremergeviewer.DiffNode; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IAdaptable; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.source.Annotation; +import org.eclipse.jface.text.source.IAnnotationModel; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IFileEditorInput; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.texteditor.IDocumentProvider; +import org.eclipse.ui.texteditor.ITextEditor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests the unified diff path of opening a compare editor: with the unified + * diff preference enabled, a qualifying input opens a plain text editor with + * unified diff annotations instead of the classic compare editor, and a + * non-qualifying input falls back to the classic compare editor. + */ +public class UnifiedDiffOpenTest { + + private static final String UNIFIED_DIFF_ANNOTATION_PREFIX = "org.eclipse.compare.unifieddiff"; //$NON-NLS-1$ + private static final long TIMEOUT_MILLIS = 30_000; + + private boolean originalUnifiedDiff; + private IProject project; + + /** + * A workspace-file element providing stream contents and a shared document + * adapter, so it qualifies for the unified diff display. + */ + private static final class WorkspaceFileElement + implements ITypedElement, IEncodedStreamContentAccessor, IResourceProvider, IAdaptable { + + private final IFile file; + private final ISharedDocumentAdapter sharedDocumentAdapter = new SharedDocumentAdapter() { + @Override + public void flushDocument(IDocumentProvider provider, IEditorInput documentKey, IDocument document, + boolean overwrite) { + // not needed by these tests + } + }; + + WorkspaceFileElement(IFile file) { + this.file = file; + } + + @Override + public InputStream getContents() throws CoreException { + return file.getContents(); + } + + @Override + public String getCharset() { + return "UTF-8"; //$NON-NLS-1$ + } + + @Override + public String getName() { + return file.getName(); + } + + @Override + public String getType() { + return TEXT_TYPE; + } + + @Override + public Image getImage() { + return null; + } + + @Override + public IResource getResource() { + return file; + } + + @Override + public T getAdapter(Class adapter) { + if (adapter == ISharedDocumentAdapter.class) { + return adapter.cast(sharedDocumentAdapter); + } + return null; + } + } + + /** A text element without a shared document adapter (does not qualify). */ + private static final class InMemoryElement implements ITypedElement, IEncodedStreamContentAccessor { + + private final String name; + private final byte[] bytes; + + InMemoryElement(String name, String content) { + this.name = name; + this.bytes = content.getBytes(StandardCharsets.UTF_8); + } + + @Override + public InputStream getContents() { + return new ByteArrayInputStream(bytes); + } + + @Override + public String getCharset() { + return "UTF-8"; //$NON-NLS-1$ + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return TEXT_TYPE; + } + + @Override + public Image getImage() { + return null; + } + } + + /** Records prepareInput invocations and the thread they ran on. */ + private static final class RecordingCompareEditorInput extends CompareEditorInput { + + private final ITypedElement left; + private final ITypedElement right; + private final AtomicInteger prepareInputCount = new AtomicInteger(); + private volatile boolean anyRunOnUiThread; + + RecordingCompareEditorInput(ITypedElement left, ITypedElement right) { + super(new CompareConfiguration()); + this.left = left; + this.right = right; + setTitle("Unified diff open"); //$NON-NLS-1$ + } + + @Override + protected Object prepareInput(IProgressMonitor monitor) { + prepareInputCount.incrementAndGet(); + if (Display.getCurrent() != null) { + anyRunOnUiThread = true; + } + return new DiffNode(left, right); + } + + @Override + public boolean canRunAsJob() { + return true; + } + } + + @BeforeEach + public void setUp() throws CoreException { + assertNotNull(Display.getCurrent(), "tests require a UI thread / Display"); //$NON-NLS-1$ + originalUnifiedDiff = store().getBoolean(ComparePreferencePage.UNIFIED_DIFF); + store().setValue(ComparePreferencePage.UNIFIED_DIFF, true); + project = ResourcesPlugin.getWorkspace().getRoot().getProject("UnifiedDiffOpenTest"); //$NON-NLS-1$ + if (!project.exists()) { + project.create(null); + } + if (!project.isOpen()) { + project.open(null); + } + } + + @AfterEach + public void tearDown() throws CoreException { + store().setValue(ComparePreferencePage.UNIFIED_DIFF, originalUnifiedDiff); + IWorkbenchPage page = activePage(); + if (page != null) { + page.closeAllEditors(false); + } + processQueuedEvents(); + if (project != null && project.exists()) { + project.delete(true, null); + } + } + + @Test + public void testUnifiedEditorOpensForWorkspaceFileInput() throws Exception { + RecordingCompareEditorInput input = openQualifyingInput(); + + IEditorPart editorPart = activePage().getActiveEditor(); + assertNotNull(editorPart, "an editor must be open"); //$NON-NLS-1$ + assertFalse(editorPart instanceof CompareEditor, + "a qualifying input must open a text editor, not the compare editor"); //$NON-NLS-1$ + ITextEditor textEditor = assertInstanceOf(ITextEditor.class, editorPart); + IFileEditorInput editorInput = assertInstanceOf(IFileEditorInput.class, textEditor.getEditorInput()); + assertEquals("left.txt", editorInput.getFile().getName()); //$NON-NLS-1$ + + IAnnotationModel model = textEditor.getDocumentProvider().getAnnotationModel(textEditor.getEditorInput()); + assertNotNull(model, "the unified diff editor must have an annotation model"); //$NON-NLS-1$ + pumpUntil(() -> hasUnifiedDiffAnnotation(model), "unified diff annotations did not appear"); //$NON-NLS-1$ + + assertEquals(1, input.prepareInputCount.get(), "prepareInput must run exactly once per unified open"); //$NON-NLS-1$ + } + + // Documents current behavior: the unified diff path prepares the input on the + // UI thread. This assertion flips once the preparation moves to a background job. + @Test + public void testUnifiedOpenRunsPrepareInputOnUiThreadDocumentsCurrentBehavior() throws Exception { + RecordingCompareEditorInput input = openQualifyingInput(); + assertTrue(input.prepareInputCount.get() >= 1, "prepareInput did not run"); //$NON-NLS-1$ + assertEquals(true, input.anyRunOnUiThread, + "documents current behavior: the unified diff path runs prepareInput on the UI thread"); //$NON-NLS-1$ + } + + @Test + public void testClassicEditorOpensWhenInputDoesNotQualify() { + RecordingCompareEditorInput input = new RecordingCompareEditorInput( + new InMemoryElement("left.txt", "alpha\nbravo\n"), //$NON-NLS-1$ //$NON-NLS-2$ + new InMemoryElement("right.txt", "alpha\nBRAVO\n")); //$NON-NLS-1$ //$NON-NLS-2$ + CompareUI.openCompareEditor(input); + pumpUntil(() -> activePage().getActiveEditor() != null, "no editor opened"); //$NON-NLS-1$ + assertInstanceOf(CompareEditor.class, activePage().getActiveEditor(), + "an input without shared document adapters must fall back to the compare editor"); //$NON-NLS-1$ + } + + private RecordingCompareEditorInput openQualifyingInput() throws CoreException { + IFile left = createFile("left.txt", "alpha\nbravo\ncharlie\ndelta\n"); //$NON-NLS-1$ //$NON-NLS-2$ + IFile right = createFile("right.txt", "alpha\nBRAVO\ncharlie\ndelta\n"); //$NON-NLS-1$ //$NON-NLS-2$ + RecordingCompareEditorInput input = new RecordingCompareEditorInput( + new WorkspaceFileElement(left), new WorkspaceFileElement(right)); + CompareUI.openCompareEditor(input); + pumpUntil(() -> activePage().getActiveEditor() != null, "no editor opened"); //$NON-NLS-1$ + return input; + } + + private IFile createFile(String name, String content) throws CoreException { + IFile file = project.getFile(name); + ByteArrayInputStream source = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + if (file.exists()) { + file.setContents(source, true, false, null); + } else { + file.create(source, true, null); + } + return file; + } + + private static boolean hasUnifiedDiffAnnotation(IAnnotationModel model) { + Iterator it = model.getAnnotationIterator(); + while (it.hasNext()) { + String type = it.next().getType(); + if (type != null && type.startsWith(UNIFIED_DIFF_ANNOTATION_PREFIX)) { + return true; + } + } + return false; + } + + private static void pumpUntil(BooleanSupplier condition, String failMessage) { + Display display = Display.getCurrent(); + long deadline = System.currentTimeMillis() + TIMEOUT_MILLIS; + // A self-rescheduling timer keeps the loop waking so the deadline is + // enforced even while blocked in Display.sleep(). + Runnable[] wake = new Runnable[1]; + wake[0] = () -> display.timerExec(50, wake[0]); + display.timerExec(50, wake[0]); + try { + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + fail(failMessage + " within " + TIMEOUT_MILLIS + "ms"); //$NON-NLS-1$ //$NON-NLS-2$ + } + if (!display.readAndDispatch()) { + display.sleep(); + } + } + } finally { + display.timerExec(-1, wake[0]); + } + } + + private static void processQueuedEvents() { + Display display = Display.getCurrent(); + while (display.readAndDispatch()) { + // drain the event queue + } + } + + private static IWorkbenchPage activePage() { + return PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + } + + private static IPreferenceStore store() { + return CompareUIPlugin.getDefault().getPreferenceStore(); + } +}