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(); + } +}