diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/dataobject.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/dataobject.cs index 2b681df6f52..d2b0636c3f8 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/dataobject.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/dataobject.cs @@ -2222,12 +2222,14 @@ private static object EnsureBitmapDataFromFormat(string format, bool autoConvert private const int DV_E_FORMATETC = unchecked((int)0x80040064); private const int DV_E_LINDEX = unchecked((int)0x80040068); - private const int DV_E_TYMED = unchecked((int)0x80040069); + internal const int DV_E_TYMED = unchecked((int)0x80040069); private const int DV_E_DVASPECT = unchecked((int)0x8004006B); private const int OLE_E_NOTRUNNING = unchecked((int)0x80040005); private const int OLE_E_ADVISENOTSUPPORTED = unchecked((int)0x80040003); private const int DATA_S_SAMEFORMATETC = unchecked((int)0x00040130); private const int STG_E_MEDIUMFULL = unchecked((int)0x80030070); + internal const int CLIPBRD_E_CANT_OPEN = unchecked((int)0x800401D0); + internal const int CLIPBRD_E_BAD_DATA = unchecked((int)0x800401D3); // Const integer base size of the file drop list: "4 + 8 + 4 + 4" private const int FILEDROPBASESIZE = 20; diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/PresentationCore.Tests.csproj b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/PresentationCore.Tests.csproj index 3a4f208b437..afb7150dd77 100644 --- a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/PresentationCore.Tests.csproj +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/PresentationCore.Tests.csproj @@ -15,18 +15,84 @@ TargetFramework;TargetFrameworks + + + + + + + + false + true + TargetFramework;TargetFrameworks + + + false + true + TargetFramework;TargetFrameworks + + + false + true + TargetFramework;TargetFrameworks + + + false + true + TargetFramework;TargetFrameworks + + + + + + + + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\PresentationNative\$(Platform)\$(Configuration)\PresentationNative_cor3.dll" + Condition="Exists('$(ArtifactsDir)bin\PresentationNative\$(Platform)\$(Configuration)\PresentationNative_cor3.dll')" /> + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\wpfgfx\$(Platform)\$(Configuration)\wpfgfx_cor3.dll" + Condition="Exists('$(ArtifactsDir)bin\wpfgfx\$(Platform)\$(Configuration)\wpfgfx_cor3.dll')" /> + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\D3DCompiler\$(Platform)\$(Configuration)\D3DCompiler_47_cor3.dll" + Condition="Exists('$(ArtifactsDir)bin\D3DCompiler\$(Platform)\$(Configuration)\D3DCompiler_47_cor3.dll')" /> + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\VCRuntime\$(Platform)\$(Configuration)\**\*.dll" + Condition="Exists('$(ArtifactsDir)bin\VCRuntime\$(Platform)\$(Configuration)')" /> + + + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\PresentationNative\Win32\$(Configuration)\PresentationNative_cor3.dll" + Condition="'$(Platform)' == 'x86' And Exists('$(ArtifactsDir)bin\PresentationNative\Win32\$(Configuration)\PresentationNative_cor3.dll')" /> + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\wpfgfx\Win32\$(Configuration)\wpfgfx_cor3.dll" + Condition="'$(Platform)' == 'x86' And Exists('$(ArtifactsDir)bin\wpfgfx\Win32\$(Configuration)\wpfgfx_cor3.dll')" /> + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\D3DCompiler\Win32\$(Configuration)\D3DCompiler_47_cor3.dll" + Condition="'$(Platform)' == 'x86' And Exists('$(ArtifactsDir)bin\D3DCompiler\Win32\$(Configuration)\D3DCompiler_47_cor3.dll')" /> + <_ClipboardTestNativeDependency + Include="$(ArtifactsDir)bin\VCRuntime\Win32\$(Configuration)\**\*.dll" + Condition="'$(Platform)' == 'x86' And Exists('$(ArtifactsDir)bin\VCRuntime\Win32\$(Configuration)')" /> + + + + diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs new file mode 100644 index 00000000000..09611b73489 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs @@ -0,0 +1,1419 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Specialized; +using System.ComponentModel; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Moq; + +namespace PresentationCore.Tests; + +[Collection(ClipboardCollection.Name)] +public sealed class ClipboardTests +{ + private static readonly Lazy s_wpfGraphics = new( + () => NativeLibrary.Load(Path.Combine(AppContext.BaseDirectory, "wpfgfx_cor3.dll"))); + + // Ensures every string-format API rejects a null format before attempting clipboard access. + [Fact] + public void FormatTakingApis_NullFormat_ThrowArgumentNullException() + { + AssertArgumentNull("format", () => Clipboard.ContainsData(null!)); + AssertArgumentNull("format", () => Clipboard.GetData(null!)); + AssertArgumentNull("format", () => Clipboard.SetData(null!, "value")); + } + + // Ensures every string-format API rejects the unsupported empty format consistently. + [Fact] + public void FormatTakingApis_EmptyFormat_ThrowArgumentException() + { + Assert.Throws(() => Clipboard.ContainsData(string.Empty)); + Assert.Throws(() => Clipboard.GetData(string.Empty)); + Assert.Throws(() => Clipboard.SetData(string.Empty, "value")); + } + + // Verifies SetData validates its payload independently of format validation. + [Fact] + public void SetData_NullData_ThrowsArgumentNullException() + { + AssertArgumentNull("data", () => Clipboard.SetData("format", null!)); + } + + // Verifies each setter overload reports the documented parameter name for a null payload. + [Fact] + public void SetApis_NullData_ThrowArgumentNullException() + { + AssertArgumentNull("audioBytes", () => Clipboard.SetAudio((byte[])null!)); + AssertArgumentNull("audioStream", () => Clipboard.SetAudio((Stream)null!)); + AssertArgumentNull("fileDropList", () => Clipboard.SetFileDropList(null!)); + AssertArgumentNull("image", () => Clipboard.SetImage(null!)); + AssertArgumentNull("text", () => Clipboard.SetText(null!)); + AssertArgumentNull("text", () => Clipboard.SetText(null!, TextDataFormat.Text)); + AssertArgumentNull("data", () => Clipboard.SetDataObject(null!)); + AssertArgumentNull("data", () => Clipboard.SetDataObject(null!, false)); + } + + // Confirms all text APIs reject values outside the defined TextDataFormat range. + [Theory] + [InlineData(-1)] + [InlineData(6)] + [InlineData(int.MaxValue)] + public void TextFormatApis_InvalidFormat_ThrowInvalidEnumArgumentException(int value) + { + TextDataFormat format = (TextDataFormat)value; + + Assert.Equal("format", Assert.Throws(() => Clipboard.ContainsText(format)).ParamName); + Assert.Equal("format", Assert.Throws(() => Clipboard.GetText(format)).ParamName); + Assert.Equal("format", Assert.Throws(() => Clipboard.SetText("value", format)).ParamName); + } + + // Verifies IsCurrent rejects a null IDataObject before performing an OLE query. + [Fact] + public void IsCurrent_NullData_ThrowsArgumentNullException() + { + AssertArgumentNull("data", () => Clipboard.IsCurrent(null!)); + } + + // Confirms IsCurrent short-circuits for managed IDataObject implementations that are not COM data objects. + [Fact] + public void IsCurrent_ManagedIDataObject_ReturnsFalseWithoutOleCall() + { + IDataObject dataObject = new Mock(MockBehavior.Strict).Object; + + Assert.False(Clipboard.IsCurrent(dataObject)); + } + + // Verifies an empty file list is not accepted as clipboard file-drop data. + [Fact] + public void SetFileDropList_EmptyList_ThrowsArgumentException() + { + Assert.Throws(() => Clipboard.SetFileDropList(new StringCollection())); + } + + // Verifies every file-drop entry must contain a non-null, non-empty path. + [Theory] + [InlineData(null)] + [InlineData("")] + public void SetFileDropList_NullOrEmptyPath_ThrowsArgumentException(string? path) + { + StringCollection paths = new() { path! }; + + Assert.Throws(() => Clipboard.SetFileDropList(paths)); + } + + // Verifies malformed paths are rejected before file-drop data is placed on the clipboard. + [Fact] + public void SetFileDropList_InvalidPath_ThrowsArgumentException() + { + StringCollection paths = new() { "\0" }; + + Assert.Throws(() => Clipboard.SetFileDropList(paths)); + } + + // Documents that every OLE-backed Clipboard API requires an STA thread, including all typed overloads. + [Fact] + public void OleBackedApis_MtaThread_ThrowThreadStateException() + { + EnsureWpfGraphicsLoaded(); + MemoryStream audioStream = new([1, 2, 3]); + DataObject dataObject = new("format", "value"); + StringCollection fileDropList = new() { Path.Combine(Path.GetTempPath(), "clipboard.txt") }; + WriteableBitmap image = new(1, 1, 96, 96, PixelFormats.Bgra32, palette: null); + Action[] actions = + [ + Clipboard.Clear, + Clipboard.Flush, + () => Clipboard.GetAudioStream(), + () => Clipboard.GetDataObject(), + () => Clipboard.GetData("format"), + () => Clipboard.GetFileDropList(), + () => Clipboard.GetImage(), + () => Clipboard.GetText(), + () => Clipboard.GetText(TextDataFormat.Text), + () => Clipboard.SetAudio([1, 2, 3]), + () => Clipboard.SetAudio(audioStream), + () => Clipboard.SetData("format", "value"), + () => Clipboard.SetDataObject(dataObject), + () => Clipboard.SetDataObject(dataObject, true), + () => Clipboard.SetFileDropList(fileDropList), + () => Clipboard.SetImage(image), + () => Clipboard.SetText("value"), + () => Clipboard.SetText("value", TextDataFormat.Text), + () => Clipboard.IsCurrent(dataObject), + ]; + + foreach (Action action in actions) + { + Exception? exception = null; + Thread thread = new(() => exception = Xunit.Record.Exception(action)); + thread.SetApartmentState(ApartmentState.MTA); + thread.Start(); + thread.Join(); + + Assert.IsType(exception); + } + } + + // Documents that Contains APIs use Win32 format probing and therefore remain valid on an MTA thread. + [Fact] + public void ContainsApis_MtaThread_DoNotRequireSta() + { + Action[] actions = + [ + () => Clipboard.ContainsAudio(), + () => Clipboard.ContainsData("format"), + () => Clipboard.ContainsFileDropList(), + () => Clipboard.ContainsImage(), + () => Clipboard.ContainsText(), + () => Clipboard.ContainsText(TextDataFormat.Text), + ]; + + foreach (Action action in actions) + { + Exception? exception = null; + Thread thread = new(() => exception = Xunit.Record.Exception(action)); + thread.SetApartmentState(ApartmentState.MTA); + thread.Start(); + thread.Join(); + + Assert.Null(exception); + } + } + + // Verifies Clear removes every typed view and leaves only an empty clipboard data object. + [StaFact] + public void Clear_PopulatedClipboard_ResetsAllTypedViews() + { + Clipboard.SetText("value"); + + Clipboard.Clear(); + + IDataObject dataObject = Assert.IsAssignableFrom(Clipboard.GetDataObject()); + Assert.Empty(RetryClipboardAccess(dataObject.GetFormats)); + Assert.False(Clipboard.ContainsAudio()); + Assert.False(Clipboard.ContainsData(DataFormats.UnicodeText)); + Assert.False(Clipboard.ContainsFileDropList()); + Assert.False(Clipboard.ContainsImage()); + Assert.False(Clipboard.ContainsText()); + Assert.False(Clipboard.ContainsText(TextDataFormat.UnicodeText)); + Assert.Null(Clipboard.GetAudioStream()); + Assert.Null(Clipboard.GetData(DataFormats.UnicodeText)); + Assert.Empty(Clipboard.GetFileDropList().Cast()); + Assert.Null(Clipboard.GetImage()); + Assert.Equal(string.Empty, Clipboard.GetText()); + Assert.Equal(string.Empty, Clipboard.GetText(TextDataFormat.UnicodeText)); + } + + // Verifies the production Clipboard retry loop survives temporary native clipboard ownership by another thread. + [StaFact] + public void Clear_ClipboardTemporarilyLocked_RetriesUntilAvailable() + { + using ManualResetEventSlim clipboardOpened = new(); + using ManualResetEventSlim releaseClipboard = new(); + Exception? lockerException = null; + + Thread locker = new(() => + { + bool opened = false; + try + { + opened = OpenClipboardWithRetry(); + clipboardOpened.Set(); + releaseClipboard.Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception exception) + { + lockerException = exception; + clipboardOpened.Set(); + } + finally + { + if (opened && !CloseClipboard()) + { + lockerException ??= new Win32Exception(Marshal.GetLastWin32Error()); + } + } + }) + { + IsBackground = true, + Name = $"{nameof(ClipboardTests)} native clipboard owner" + }; + locker.SetApartmentState(ApartmentState.MTA); + locker.Start(); + + Assert.True(clipboardOpened.Wait(TimeSpan.FromSeconds(5))); + Assert.Null(lockerException); + + Thread releaser = new(() => + { + Thread.Sleep(250); + releaseClipboard.Set(); + }) + { + IsBackground = true, + Name = $"{nameof(ClipboardTests)} native clipboard releaser" + }; + releaser.Start(); + + try + { + // This call must initially receive CLIPBRD_E_CANT_OPEN and then succeed after the locker releases. + Clipboard.Clear(); + } + finally + { + releaseClipboard.Set(); + releaser.Join(); + locker.Join(); + } + + Assert.Null(lockerException); + } + + // Verifies custom formats register case-insensitively and round-trip through Clipboard and IDataObject APIs. + [StaFact] + public void SetData_CustomFormat_RoundTripsThroughIDataObject() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + const string Value = "custom clipboard value"; + + try + { + Clipboard.SetData(format, Value); + DataFormat registeredFormat = DataFormats.GetDataFormat(format); + + Assert.True(Clipboard.ContainsData(format)); + Assert.True(Clipboard.ContainsData(format.ToUpperInvariant())); + Assert.Equal(Value, Clipboard.GetData(format)); + Assert.Equal(Value, Clipboard.GetData(format.ToUpperInvariant())); + Assert.Equal(format, registeredFormat.Name); + Assert.Same(registeredFormat, DataFormats.GetDataFormat(registeredFormat.Id)); + Assert.Same(registeredFormat, DataFormats.GetDataFormat(format.ToUpperInvariant())); + + IDataObject dataObject = Assert.IsAssignableFrom(Clipboard.GetDataObject()); + Assert.True(RetryClipboardAccess(() => dataObject.GetDataPresent(format))); + Assert.True(RetryClipboardAccess(() => dataObject.GetDataPresent(format, false))); + Assert.Equal(Value, RetryClipboardAccess(() => dataObject.GetData(format))); + Assert.Equal(Value, RetryClipboardAccess(() => dataObject.GetData(format, false))); + Assert.Contains(format, RetryClipboardAccess(dataObject.GetFormats)); + Assert.Contains(format, RetryClipboardAccess(() => dataObject.GetFormats(false))); + + Assert.False(Clipboard.ContainsAudio()); + Assert.False(Clipboard.ContainsFileDropList()); + Assert.False(Clipboard.ContainsImage()); + Assert.False(Clipboard.ContainsText()); + Assert.Null(Clipboard.GetAudioStream()); + Assert.Empty(Clipboard.GetFileDropList().Cast()); + Assert.Null(Clipboard.GetImage()); + Assert.Equal(string.Empty, Clipboard.GetText()); + } + finally + { + Clipboard.Clear(); + } + } + + // Confirms the default and copy:false overloads retain a live data source whose later mutations are observable. + [StaFact] + public void SetDataObject_DefaultAndFalseOverloads_PreserveLiveDataObject() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject dataObject = new(format, "first"); + + try + { + Clipboard.SetDataObject(dataObject); + + Assert.True(Clipboard.IsCurrent(dataObject)); + dataObject.SetData(format, "first changed"); + Assert.Equal("first changed", Clipboard.GetData(format)); + + Clipboard.Clear(); + dataObject.SetData(format, "second"); + Clipboard.SetDataObject(dataObject, copy: false); + + Assert.True(Clipboard.IsCurrent(dataObject)); + dataObject.SetData(format, "second changed"); + Assert.Equal("second changed", Clipboard.GetData(format)); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies replacing the clipboard owner updates IsCurrent for both the previous and new data objects. + [StaFact] + public void SetDataObject_ReplacingCurrentObject_UpdatesIsCurrent() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject first = new(format, "first"); + DataObject second = new(format, "second"); + + try + { + Clipboard.SetDataObject(first, copy: false); + Assert.True(Clipboard.IsCurrent(first)); + + Clipboard.SetDataObject(second, copy: false); + + Assert.False(Clipboard.IsCurrent(first)); + Assert.True(Clipboard.IsCurrent(second)); + Assert.Equal("second", Clipboard.GetData(format)); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies a live DataObject preserves the type-, format-, and auto-conversion IDataObject contracts. + [StaFact] + public void SetDataObject_LiveDataObject_PreservesIDataObjectOverloads() + { + string nativeFormat = $"WPF ClipboardTests {Guid.NewGuid():N}"; + Uri uri = new("https://example.test/clipboard"); + DataObject source = new(); + source.SetData("typed string"); + source.SetData(typeof(Uri), uri); + source.SetData(nativeFormat, "native", autoConvert: false); + + try + { + Clipboard.SetDataObject(source, copy: false); + + IDataObject result = Assert.IsAssignableFrom(Clipboard.GetDataObject()); + Assert.True(result.GetDataPresent(typeof(string))); + Assert.True(result.GetDataPresent(typeof(Uri))); + Assert.True(result.GetDataPresent(nativeFormat)); + Assert.True(result.GetDataPresent(nativeFormat, autoConvert: false)); + Assert.Equal("typed string", result.GetData(typeof(string))); + Assert.Equal(uri, result.GetData(typeof(Uri))); + Assert.Equal("native", result.GetData(nativeFormat)); + Assert.Equal("native", result.GetData(nativeFormat, autoConvert: false)); + Assert.Contains(nativeFormat, result.GetFormats()); + Assert.Contains(nativeFormat, result.GetFormats(autoConvert: false)); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures how the system clipboard synthesizes UnicodeText even when the source DataObject disables text auto-conversion. + [StaFact] + public void SetDataObject_AnsiText_ExposesSystemGeneratedUnicodeTextToClipboardApis() + { + const string Value = "ahoj"; + DataObject source = new(); + source.SetText(Value, TextDataFormat.Text); + + try + { + Assert.False(source.GetDataPresent(DataFormats.UnicodeText)); + Assert.False(source.GetDataPresent(DataFormats.UnicodeText, autoConvert: false)); + + Clipboard.SetDataObject(source); + + IDataObject result = Assert.IsAssignableFrom(Clipboard.GetDataObject()); + Assert.True(RetryClipboardAccess(() => result.GetDataPresent(DataFormats.UnicodeText))); + Assert.True(RetryClipboardAccess(() => result.GetDataPresent(DataFormats.UnicodeText, autoConvert: false))); + Assert.Equal(Value, RetryClipboardAccess(() => result.GetData(DataFormats.UnicodeText))); + Assert.Equal(Value, RetryClipboardAccess(() => result.GetData(DataFormats.UnicodeText, autoConvert: false))); + + Assert.True(Clipboard.ContainsText()); + Assert.True(Clipboard.ContainsText(TextDataFormat.Text)); + Assert.True(Clipboard.ContainsText(TextDataFormat.UnicodeText)); + Assert.Equal(Value, Clipboard.GetText()); + Assert.Equal(Value, Clipboard.GetText(TextDataFormat.Text)); + Assert.Equal(Value, Clipboard.GetText(TextDataFormat.UnicodeText)); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies autoConvert controls text format advertisement while default GetData still searches the mapped format group. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DataObject_TextAutoConvert_ControlsMappedFormatAdvertisement(bool autoConvert) + { + const string Value = "text conversion verification"; + string[] mappedFormats = + [ + DataFormats.Text, + DataFormats.UnicodeText, + DataFormats.StringFormat, + ]; + DataObject dataObject = new(); + dataObject.SetData(DataFormats.Text, Value, autoConvert); + + foreach (string format in mappedFormats) + { + bool expected = autoConvert || format == DataFormats.Text; + + Assert.Equal(expected, dataObject.GetDataPresent(format)); + Assert.Equal(format == DataFormats.Text, dataObject.GetDataPresent(format, autoConvert: false)); + Assert.Equal(Value, dataObject.GetData(format)); + Assert.Equal(format == DataFormats.Text ? Value : null, dataObject.GetData(format, autoConvert: false)); + } + + string[] expectedFormats = autoConvert ? mappedFormats : [DataFormats.Text]; + Assert.Equal(expectedFormats.Order(), dataObject.GetFormats().Order()); + Assert.Equal(new[] { DataFormats.Text }, dataObject.GetFormats(autoConvert: false)); + } + + // Verifies every built-in DataObject format mapping advertises all synonyms only when autoConvert is enabled. + [Fact] + public void DataObject_AutoConvert_ControlsAllBuiltInFormatMappings() + { + string[][] formatGroups = + [ + [DataFormats.Text, DataFormats.UnicodeText, DataFormats.StringFormat], + [DataFormats.FileDrop, DataFormats.FileNameW, DataFormats.FileName], + [DataFormats.Bitmap, typeof(System.Drawing.Bitmap).FullName!, typeof(BitmapSource).FullName!], + [DataFormats.EnhancedMetafile, typeof(System.Drawing.Imaging.Metafile).FullName!], + ]; + + foreach (string[] formatGroup in formatGroups) + { + foreach (string sourceFormat in formatGroup) + { + foreach (bool autoConvert in new[] { false, true }) + { + DataObject dataObject = new(); + dataObject.SetData(sourceFormat, "value", autoConvert); + string[] expectedFormats = autoConvert ? formatGroup : [sourceFormat]; + + Assert.Equal(expectedFormats.Order(), dataObject.GetFormats().Order()); + Assert.Equal(new[] { sourceFormat }, dataObject.GetFormats(autoConvert: false)); + + foreach (string targetFormat in formatGroup) + { + Assert.Equal( + autoConvert || targetFormat == sourceFormat, + dataObject.GetDataPresent(targetFormat)); + Assert.Equal( + targetFormat == sourceFormat, + dataObject.GetDataPresent(targetFormat, autoConvert: false)); + } + } + } + } + } + + // Confirms SetDataObject wraps ordinary CLR values in a DataObject with their expected native formats. + [StaFact] + public void SetDataObject_RawObject_WrapsItInADataObject() + { + const string Value = "raw string"; + + try + { + Clipboard.SetDataObject(Value); + + Assert.True(Clipboard.ContainsText()); + Assert.Equal(Value, Clipboard.GetText()); + IDataObject result = Assert.IsAssignableFrom(Clipboard.GetDataObject()); + Assert.True(result.GetDataPresent(typeof(string))); + Assert.Equal(Value, result.GetData(typeof(string))); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies Flush materializes delayed data, releases ownership, and preserves the rendered value. + [StaFact] + public void Flush_NonPersistentDataObject_RendersDataAndReleasesSource() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject dataObject = new(format, "persisted"); + + try + { + Clipboard.SetDataObject(dataObject, copy: false); + Assert.True(Clipboard.IsCurrent(dataObject)); + + Clipboard.Flush(); + + Assert.False(Clipboard.IsCurrent(dataObject)); + Assert.Equal("persisted", Clipboard.GetData(format)); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies copy:true snapshots the source immediately instead of retaining a live data object. + [StaFact] + public void SetDataObject_CopyTrue_RendersDataAndReleasesSource() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject dataObject = new(format, "persisted"); + + try + { + Clipboard.SetDataObject(dataObject, copy: true); + dataObject.SetData(format, "changed after copy"); + + Assert.False(Clipboard.IsCurrent(dataObject)); + Assert.Equal("persisted", Clipboard.GetData(format)); + } + finally + { + Clipboard.Clear(); + } + } + + // Confirms the default text overload uses UnicodeText and preserves non-ASCII and surrogate-pair content. + [StaFact] + public void SetText_DefaultOverload_RoundTripsUnicodeText() + { + const string Value = "Clipboard \u03A9 \U0001F600"; + + try + { + Clipboard.SetText(Value); + + Assert.True(Clipboard.ContainsText()); + Assert.True(Clipboard.ContainsText(TextDataFormat.UnicodeText)); + Assert.True(Clipboard.ContainsData(DataFormats.UnicodeText)); + Assert.Equal(Value, Clipboard.GetText()); + Assert.Equal(Value, Clipboard.GetText(TextDataFormat.UnicodeText)); + Assert.Equal(Value, Clipboard.GetData(DataFormats.UnicodeText)); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies each supported TextDataFormat maps to its corresponding clipboard format and round-trips unchanged. + [StaTheory] + [InlineData(TextDataFormat.Text)] + [InlineData(TextDataFormat.UnicodeText)] + [InlineData(TextDataFormat.Rtf)] + [InlineData(TextDataFormat.Html)] + [InlineData(TextDataFormat.CommaSeparatedValue)] + [InlineData(TextDataFormat.Xaml)] + public void SetText_AllSupportedFormats_RoundTrip(TextDataFormat format) + { + string value = $"text for {format}"; + string dataFormat = GetDataFormat(format); + + try + { + Clipboard.SetText(value, format); + + Assert.True(Clipboard.ContainsText(format)); + Assert.True(Clipboard.ContainsData(dataFormat)); + Assert.Equal(value, Clipboard.GetText(format)); + Assert.Equal(value, Clipboard.GetData(dataFormat)); + } + finally + { + Clipboard.Clear(); + } + } + + // Confirms an empty string is valid clipboard text and remains distinguishable from an absent format. + [StaFact] + public void SetText_EmptyString_RoundTrips() + { + try + { + Clipboard.SetText(string.Empty); + + Assert.True(Clipboard.ContainsText()); + Assert.Equal(string.Empty, Clipboard.GetText()); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies SetAudio(byte[]) snapshots the input buffer and exposes identical WaveAudio data. + [StaFact] + public void SetAudio_ByteArray_CopiesAndRoundTripsData() + { + byte[] source = [1, 2, 3, 4, 5]; + + try + { + Clipboard.SetAudio(source); + source[0] = 42; + + Assert.True(Clipboard.ContainsAudio()); + Assert.True(Clipboard.ContainsData(DataFormats.WaveAudio)); + Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, ReadAllBytes(Clipboard.GetAudioStream())); + Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, ReadAllBytes(Assert.IsAssignableFrom(Clipboard.GetData(DataFormats.WaveAudio)))); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies SetAudio(Stream) copies the complete stream, regardless of position, and outlives its source. + [StaFact] + public void SetAudio_Stream_CopiesWholeStreamAndSurvivesSourceDisposal() + { + MemoryStream source = new([6, 7, 8, 9]); + source.Position = 2; + + try + { + Clipboard.SetAudio(source); + source.Dispose(); + + Assert.True(Clipboard.ContainsAudio()); + Assert.Equal(new byte[] { 6, 7, 8, 9 }, ReadAllBytes(Clipboard.GetAudioStream())); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies file-drop paths and returned collections are copied rather than sharing mutable state. + [StaFact] + public void SetFileDropList_CopiesAndRoundTripsPaths() + { + string first = Path.Combine(Path.GetTempPath(), $"clipboard-{Guid.NewGuid():N}-1.txt"); + string second = Path.Combine(Path.GetTempPath(), $"clipboard-{Guid.NewGuid():N}-2.txt"); + StringCollection source = new() { first, second }; + + try + { + Clipboard.SetFileDropList(source); + source.Clear(); + + Assert.True(Clipboard.ContainsFileDropList()); + Assert.True(Clipboard.ContainsData(DataFormats.FileDrop)); + StringCollection returned = RetryClipboardAccess(Clipboard.GetFileDropList); + Assert.Equal(new[] { first, second }, returned.Cast()); + Assert.Equal( + new[] { first, second }, + Assert.IsType(RetryClipboardAccess(() => Clipboard.GetData(DataFormats.FileDrop)))); + + returned[0] = "changed"; + returned.RemoveAt(1); + + Assert.Equal( + new[] { first, second }, + RetryClipboardAccess(Clipboard.GetFileDropList).Cast()); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies SetImage snapshots source pixels and publishes a retrievable native bitmap representation. + [StaFact] + public void SetImage_CopiesAndRoundTripsPixels() + { + EnsureWpfGraphicsLoaded(); + byte[] originalPixels = + [ + 0x10, 0x20, 0x30, 0xFF, + 0x40, 0x50, 0x60, 0xFF, + ]; + WriteableBitmap source = new(2, 1, 96, 96, PixelFormats.Bgra32, palette: null); + source.WritePixels(new Int32Rect(0, 0, 2, 1), originalPixels, stride: 8, offset: 0); + + try + { + Clipboard.SetImage(source); + source.WritePixels( + new Int32Rect(0, 0, 2, 1), + new byte[] + { + 0x70, 0x80, 0x90, 0xFF, + 0xA0, 0xB0, 0xC0, 0xFF, + }, + stride: 8, + offset: 0); + + Assert.True(RetryClipboardAccess(Clipboard.ContainsImage)); + Assert.True(RetryClipboardAccess(() => Clipboard.ContainsData(DataFormats.Bitmap))); + BitmapSource image = Assert.IsAssignableFrom( + RetryClipboardAccess(Clipboard.GetImage)); + Assert.Equal(2, image.PixelWidth); + Assert.Equal(1, image.PixelHeight); + Assert.NotSame(source, image); + BitmapSource normalized = new FormatConvertedBitmap(image, PixelFormats.Bgr24, null, 0); + byte[] actualPixels = new byte[6]; + normalized.CopyPixels(actualPixels, stride: 6, offset: 0); + Assert.Equal( + new byte[] + { + 0x10, 0x20, 0x30, + 0x40, 0x50, 0x60, + }, + actualPixels); + Assert.IsAssignableFrom( + RetryClipboardAccess(() => Clipboard.GetData(DataFormats.Bitmap))); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that delayed EMF rendering returns a non-renderable copy and invalidates the source Metafile. + [StaFact] + public void SetDataObject_Metafile_CopyFalse_ReturnsNonRenderableCopyAndInvalidatesSource() + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + + Assert.True(Clipboard.IsCurrent(sourceDataObject)); + Assert.True(Clipboard.ContainsData(DataFormats.EnhancedMetafile)); + IDataObject clipboardDataObject = Assert.IsAssignableFrom( + RetryClipboardAccess(Clipboard.GetDataObject)); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + DataFormats.EnhancedMetafile, + autoConvert: false))); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + typeof(Metafile).FullName!, + autoConvert: true))); + + using Metafile result = GetClipboardMetafile(DataFormats.EnhancedMetafile); + Assert.NotSame(source, result); + AssertMetafileIsNotRenderable(result); + Assert.Throws(() => source.GetMetafileHeader()); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that Flush persists an EMF object that cannot be rendered after the source is disposed. + [StaFact] + public void SetDataObject_Metafile_CopyFalseFlush_ReturnsNonRenderableMetafile() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + Assert.True(Clipboard.IsCurrent(sourceDataObject)); + Clipboard.Flush(); + Assert.False(Clipboard.IsCurrent(sourceDataObject)); + + source.Dispose(); + source = null; + + using Metafile result = GetClipboardMetafile(DataFormats.EnhancedMetafile); + AssertMetafileIsNotRenderable(result); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Captures that copy:true returns an EMF object that cannot be rendered after the source is disposed. + [StaFact] + public void SetDataObject_Metafile_CopyTrue_ReturnsNonRenderableMetafile() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + Assert.False(Clipboard.IsCurrent(sourceDataObject)); + source.Dispose(); + source = null; + + Assert.True(Clipboard.ContainsData(DataFormats.EnhancedMetafile)); + using Metafile result = GetClipboardMetafile(DataFormats.EnhancedMetafile); + AssertMetafileIsNotRenderable(result); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Characterizes the distinct SetDataObject(object, copy) path that wraps a raw Metafile by its CLR type. + [StaTheory] + [InlineData(false)] + [InlineData(true)] + public void SetDataObject_RawMetafile_AdvertisesMappedFormatsAndInvalidatesSource(bool copy) + { + using Metafile source = EmfTestData.CreateMetafile(); + + try + { + Clipboard.SetDataObject(source, copy); + string mappedFormat = typeof(Metafile).FullName!; + IDataObject clipboardDataObject = Assert.IsAssignableFrom( + RetryClipboardAccess(Clipboard.GetDataObject)); + + Assert.Contains( + mappedFormat, + RetryClipboardAccess(() => clipboardDataObject.GetFormats(autoConvert: false))); + Assert.Contains( + DataFormats.EnhancedMetafile, + RetryClipboardAccess(() => clipboardDataObject.GetFormats(autoConvert: true))); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent(mappedFormat, autoConvert: false))); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + DataFormats.EnhancedMetafile, + autoConvert: true))); + Assert.True(RetryClipboardAccess(() => Clipboard.ContainsData(mappedFormat))); + + COMException mappedException = Assert.Throws( + () => RetryClipboardAccess(() => Clipboard.GetData(mappedFormat))); + // A live object fails immediately with DV_E_TYMED. After copy:true, the failed eager render is surfaced + // by the system clipboard as CLIPBRD_E_BAD_DATA. + int expectedHResult = copy + ? DataObject.CLIPBRD_E_BAD_DATA + : DataObject.DV_E_TYMED; + Assert.Equal(expectedHResult, mappedException.HResult); + + using Metafile result = GetClipboardMetafile(DataFormats.EnhancedMetafile); + AssertMetafileIsNotRenderable(result); + Assert.Throws(() => source.GetMetafileHeader()); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that mapped-format auto-conversion returns a Metafile object whose native handle is not renderable. + [StaFact] + public void SetDataObject_Metafile_MappedFormatRequiresAutoConvert() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + source.Dispose(); + source = null; + + IDataObject clipboardDataObject = Assert.IsAssignableFrom( + RetryClipboardAccess(Clipboard.GetDataObject)); + string mappedFormat = typeof(Metafile).FullName!; + + Assert.False(Clipboard.ContainsData(mappedFormat)); + Assert.Null(Clipboard.GetData(mappedFormat)); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + DataFormats.EnhancedMetafile, + autoConvert: false))); + Assert.False(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + mappedFormat, + autoConvert: false))); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + mappedFormat, + autoConvert: true))); + Assert.Contains( + DataFormats.EnhancedMetafile, + RetryClipboardAccess(() => clipboardDataObject.GetFormats(autoConvert: false))); + Assert.DoesNotContain( + mappedFormat, + RetryClipboardAccess(() => clipboardDataObject.GetFormats(autoConvert: false))); + Assert.Contains( + mappedFormat, + RetryClipboardAccess(() => clipboardDataObject.GetFormats(autoConvert: true))); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent(typeof(Metafile)))); + + Assert.Null(RetryClipboardAccess( + () => clipboardDataObject.GetData( + mappedFormat, + autoConvert: false))); + + using Metafile mappedResult = Assert.IsType(RetryClipboardAccess( + () => clipboardDataObject.GetData( + mappedFormat, + autoConvert: true))); + AssertMetafileIsNotRenderable(mappedResult); + + using Metafile typedResult = Assert.IsType(RetryClipboardAccess( + () => clipboardDataObject.GetData(typeof(Metafile)))); + AssertMetafileIsNotRenderable(typedResult); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Captures the Clipboard facade result for the mapped CLR format on a fresh live data object. + [StaFact] + public void SetDataObject_Metafile_AutoConvertTrue_ClipboardMappedGetThrowsDvETymed() + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: true); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + string mappedFormat = typeof(Metafile).FullName!; + + Assert.True(Clipboard.ContainsData(mappedFormat)); + COMException facadeException = Assert.Throws( + () => RetryClipboardAccess(() => Clipboard.GetData(mappedFormat))); + Assert.Equal(DataObject.DV_E_TYMED, facadeException.HResult); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures the IDataObject result for the mapped CLR format without a preceding facade retrieval. + [StaFact] + public void SetDataObject_Metafile_AutoConvertTrue_IDataObjectMappedGetThrowsDvETymed() + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: true); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + string mappedFormat = typeof(Metafile).FullName!; + IDataObject clipboardDataObject = Assert.IsAssignableFrom( + RetryClipboardAccess(Clipboard.GetDataObject)); + + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + DataFormats.EnhancedMetafile, + autoConvert: false))); + Assert.True(RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + mappedFormat, + autoConvert: false))); + COMException exception = Assert.Throws( + () => RetryClipboardAccess( + () => clipboardDataObject.GetData( + mappedFormat, + autoConvert: false))); + Assert.Equal(DataObject.DV_E_TYMED, exception.HResult); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures distinct managed lifetimes: disposing one non-renderable result does not alter the other result's behavior. + [StaFact] + public void SetDataObject_Metafile_RepeatedGetsReturnIndependentlyDisposableResults() + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + Metafile? first = GetClipboardMetafile(DataFormats.EnhancedMetafile); + Metafile? second = null; + try + { + second = GetClipboardMetafile(DataFormats.EnhancedMetafile); + Assert.NotSame(first, second); + AssertMetafileIsNotRenderable(first); + first.Dispose(); + first = null; + AssertMetafileIsNotRenderable(second); + } + finally + { + first?.Dispose(); + second?.Dispose(); + } + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that a valid non-public EMF stream is not consumed but produces CLIPBRD_E_BAD_DATA on retrieval. + [StaFact] + public void SetDataObject_ValidNonExposableMetafileStream_CopyTrue_GetDataThrowsClipboardBadData() + { + byte[] bytes; + using (Metafile metafile = EmfTestData.CreateMetafile()) + { + bytes = EmfTestData.GetBytes(metafile); + } + + using MemoryStream sourceStream = EmfTestData.CreateNonExposableStream(bytes); + sourceStream.Position = Math.Min(7, sourceStream.Length); + long originalPosition = sourceStream.Position; + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, sourceStream, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + + Assert.Equal(originalPosition, sourceStream.Position); + AssertClipboardBadData(() => Clipboard.GetData(DataFormats.EnhancedMetafile)); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that a valid exposable EMF stream returns a non-renderable Metafile without moving the stream. + [StaFact] + public void SetDataObject_ValidExposableMetafileStream_CopyTrue_ReturnsNonRenderableMetafile() + { + byte[] bytes; + using (Metafile metafile = EmfTestData.CreateMetafile()) + { + bytes = EmfTestData.GetBytes(metafile); + } + + using MemoryStream source = EmfTestData.CreateExposableStream(bytes); + source.Position = Math.Min(7, source.Length); + long originalPosition = source.Position; + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + + Assert.Equal(originalPosition, source.Position); + using Metafile result = GetClipboardMetafile(DataFormats.EnhancedMetafile); + AssertMetafileIsNotRenderable(result); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that GetBuffer's trailing capacity bytes are accepted but still produce a non-renderable Metafile. + [StaFact] + public void SetDataObject_OverallocatedExposableMetafileStream_CopyTrue_ReturnsNonRenderableMetafile() + { + byte[] bytes; + using (Metafile metafile = EmfTestData.CreateMetafile()) + { + bytes = EmfTestData.GetBytes(metafile); + } + + using MemoryStream source = EmfTestData.CreateOverallocatedExposableStream(bytes); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + + using Metafile result = GetClipboardMetafile(DataFormats.EnhancedMetafile); + AssertMetafileIsNotRenderable(result); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that only MemoryStream receives the .NET 9 native EMF stream treatment. + [StaFact] + public void SetDataObject_NonMemoryMetafileStream_CopyTrue_GetDataThrowsClipboardBadData() + { + byte[] bytes; + using (Metafile metafile = EmfTestData.CreateMetafile()) + { + bytes = EmfTestData.GetBytes(metafile); + } + + using MemoryStream inner = new(bytes, writable: false); + using BufferedStream source = new(inner); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + + AssertClipboardBadData(() => Clipboard.GetData(DataFormats.EnhancedMetafile)); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures the in-process delayed-rendering exception for a non-exposable MemoryStream. + [StaFact] + public void SetDataObject_ValidNonExposableMetafileStream_CopyFalse_GetDataThrowsUnauthorizedAccessException() + { + byte[] bytes; + using (Metafile metafile = EmfTestData.CreateMetafile()) + { + bytes = EmfTestData.GetBytes(metafile); + } + + using MemoryStream source = EmfTestData.CreateNonExposableStream(bytes); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + + Assert.Throws( + () => RetryClipboardAccess( + () => Clipboard.GetData(DataFormats.EnhancedMetafile))); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures that publishing WMF as EnhancedMetafile produces CLIPBRD_E_BAD_DATA and invalidates the WMF source. + [StaFact] + public void SetDataObject_WmfAsEnhancedMetafile_CopyTrue_DoesNotReturnEmf() + { + using MemoryStream stream = new(EmfTestData.CreateWmfBytes(), writable: false); + using Metafile source = new(stream); + Assert.True(source.GetMetafileHeader().IsWmf()); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + + AssertClipboardBadData(() => Clipboard.GetData(DataFormats.EnhancedMetafile)); + Assert.Throws(() => source.GetMetafileHeader()); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures the behavior of the Clipboard.SetData convenience API for a real EMF payload. + [StaFact] + public void SetData_Metafile_ReturnsNonRenderableMetafile() + { + using Metafile source = EmfTestData.CreateMetafile(); + + try + { + Clipboard.SetData(DataFormats.EnhancedMetafile, source); + + Assert.True(Clipboard.ContainsData(DataFormats.EnhancedMetafile)); + using Metafile result = GetClipboardMetafile(DataFormats.EnhancedMetafile); + AssertMetafileIsNotRenderable(result); + Assert.Throws(() => source.GetMetafileHeader()); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies Clear removes EMF availability, retrieval state, and ownership even when assertions fail. + [StaFact] + public void Clear_EnhancedMetafile_RemovesData() + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + Assert.True(Clipboard.ContainsData(DataFormats.EnhancedMetafile)); + Assert.True(Clipboard.IsCurrent(sourceDataObject)); + + Clipboard.Clear(); + + Assert.False(Clipboard.IsCurrent(sourceDataObject)); + Assert.False(Clipboard.ContainsData(DataFormats.EnhancedMetafile)); + Assert.Null(Clipboard.GetData(DataFormats.EnhancedMetafile)); + } + finally + { + Clipboard.Clear(); + } + } + + // Captures empty and malformed publicly exposable EMF stream behavior. + [StaTheory] + [InlineData(new byte[] { })] + [InlineData(new byte[] { 1, 2, 3 })] + public void SetDataObject_InvalidMetafileStream_CopyTrue_GetDataThrowsClipboardBadData(byte[] bytes) + { + using MemoryStream source = EmfTestData.CreateExposableStream(bytes); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + + AssertClipboardBadData(() => Clipboard.GetData(DataFormats.EnhancedMetafile)); + } + finally + { + Clipboard.Clear(); + } + } + + private static byte[] ReadAllBytes(Stream stream) + { + Assert.NotNull(stream); + if (stream.CanSeek) + { + stream.Position = 0; + } + + using MemoryStream copy = new(); + stream.CopyTo(copy); + return copy.ToArray(); + } + + private static void EnsureWpfGraphicsLoaded() => + _ = s_wpfGraphics.Value; + + private static Metafile GetClipboardMetafile(string format) => + Assert.IsType(RetryClipboardAccess(() => Clipboard.GetData(format))); + + private static void AssertMetafileIsNotRenderable(Metafile metafile) => + Assert.Throws(() => EmfTestData.AssertValid(metafile)); + + private static void AssertClipboardBadData(Action action) + { + COMException exception = Assert.Throws( + () => RetryClipboardAccess( + () => + { + action(); + return true; + })); + Assert.Equal(DataObject.CLIPBRD_E_BAD_DATA, exception.HResult); + } + + private static T RetryClipboardAccess(Func operation) + { + for (int attemptsRemaining = 10; ; attemptsRemaining--) + { + try + { + return operation(); + } + catch (COMException exception) when ( + exception.HResult == DataObject.CLIPBRD_E_CANT_OPEN + && attemptsRemaining > 1) + { + Thread.Sleep(100); + } + } + } + + private static bool OpenClipboardWithRetry() + { + for (int attemptsRemaining = 10; attemptsRemaining > 0; attemptsRemaining--) + { + if (OpenClipboard(0)) + { + return true; + } + + Thread.Sleep(100); + } + + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + private static void AssertArgumentNull(string paramName, Action action) => + Assert.Equal(paramName, Assert.Throws(action).ParamName); + + private static string GetDataFormat(TextDataFormat format) => + format switch + { + TextDataFormat.Text => DataFormats.Text, + TextDataFormat.UnicodeText => DataFormats.UnicodeText, + TextDataFormat.Rtf => DataFormats.Rtf, + TextDataFormat.Html => DataFormats.Html, + TextDataFormat.CommaSeparatedValue => DataFormats.CommaSeparatedValue, + TextDataFormat.Xaml => DataFormats.Xaml, + _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(TextDataFormat)), + }; + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenClipboard(nint newOwner); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseClipboard(); +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ClipboardCollection +{ + public const string Name = "System clipboard serial"; +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/DataObjectMetafileTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/DataObjectMetafileTests.cs new file mode 100644 index 00000000000..1edf8a0648c --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/DataObjectMetafileTests.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing.Imaging; + +namespace PresentationCore.Tests; + +public sealed class DataObjectMetafileTests +{ + // Defines the managed DataObject auto-conversion contract independently from the system clipboard. + [Theory] + [InlineData("EnhancedMetafile", "System.Drawing.Imaging.Metafile")] + [InlineData("System.Drawing.Imaging.Metafile", "EnhancedMetafile")] + public void GetData_MetafileMappedFormat_RespectsAutoConvert( + string sourceFormat, + string mappedFormat) + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject dataObject = new(); + dataObject.SetData(sourceFormat, source, autoConvert: true); + + Assert.True(dataObject.GetDataPresent(sourceFormat, autoConvert: false)); + Assert.False(dataObject.GetDataPresent(mappedFormat, autoConvert: false)); + Assert.True(dataObject.GetDataPresent(mappedFormat, autoConvert: true)); + Assert.Same(source, dataObject.GetData(sourceFormat, autoConvert: false)); + Assert.Null(dataObject.GetData(mappedFormat, autoConvert: false)); + Assert.Same(source, dataObject.GetData(mappedFormat, autoConvert: true)); + Assert.Contains(sourceFormat, dataObject.GetFormats(autoConvert: false)); + Assert.Contains(mappedFormat, dataObject.GetFormats(autoConvert: true)); + EmfTestData.AssertValid(source); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/EmfTestData.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/EmfTestData.cs new file mode 100644 index 00000000000..72317259e21 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/EmfTestData.cs @@ -0,0 +1,245 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.ComponentModel; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace PresentationCore.Tests; + +// Creates deterministic EMF/WMF payloads and validates native records without relying on external test assets. +internal static class EmfTestData +{ + internal static Rectangle Frame { get; } = new(0, 0, 120, 80); + + // Records a rectangle into an in-memory EMF, then transfers ownership of an independent HENHMETAFILE. + public static Metafile CreateMetafile() + { + using MemoryStream stream = new(); + using Bitmap referenceBitmap = new(1, 1); + using Graphics referenceGraphics = Graphics.FromImage(referenceBitmap); + + Metafile recording; + nint hdc = referenceGraphics.GetHdc(); + try + { + recording = new( + stream, + hdc, + Frame, + MetafileFrameUnit.Pixel, + EmfType.EmfOnly); + } + finally + { + referenceGraphics.ReleaseHdc(hdc); + } + + using (recording) + { + using Graphics graphics = Graphics.FromImage(recording); + nint recordingHdc = graphics.GetHdc(); + try + { + Assert.NotEqual(0, GdiRectangle(recordingHdc, 10, 10, 110, 62)); + } + finally + { + graphics.ReleaseHdc(recordingHdc); + } + } + + using MemoryStream source = new(stream.ToArray(), writable: false); + using Metafile reader = new(source); + nint handle = reader.GetHenhmetafile(); + if (handle == 0) + { + throw new Win32Exception(); + } + + try + { + return new Metafile(handle, deleteEmf: true); + } + catch + { + DeleteEnhMetaFile(handle); + throw; + } + } + + // Copies the native EMF bits. GetHenhmetafile transfers a handle that this method must release. + public static byte[] GetBytes(Metafile metafile) + { + nint handle = metafile.GetHenhmetafile(); + if (handle == 0) + { + throw new Win32Exception(); + } + + bool completed = false; + try + { + uint size = GetEnhMetaFileBits(handle, 0, null); + if (size == 0) + { + throw new Win32Exception(); + } + + byte[] bytes = new byte[size]; + if (GetEnhMetaFileBits(handle, size, bytes) != size) + { + throw new Win32Exception(); + } + + completed = true; + return bytes; + } + finally + { + bool deleted = DeleteEnhMetaFile(handle); + if (completed) + { + Assert.True(deleted); + } + } + } + + // The production .NET 9 path calls MemoryStream.GetBuffer, so public buffer visibility changes behavior. + public static MemoryStream CreateNonExposableStream(byte[] bytes) => + new(bytes, 0, bytes.Length, writable: false, publiclyVisible: false); + + public static MemoryStream CreateExposableStream(byte[] bytes) => + new(bytes, 0, bytes.Length, writable: false, publiclyVisible: true); + + // GetBuffer returns the full capacity, not Length. This shape exposes trailing capacity bytes to production. + public static MemoryStream CreateOverallocatedExposableStream(byte[] bytes) + { + MemoryStream stream = new(bytes.Length + 32); + stream.Write(bytes); + stream.Position = 0; + return stream; + } + + // Converts the deterministic EMF to WMF bits and adds the placeable header required by GDI+ stream loading. + public static byte[] CreateWmfBytes() + { + using Metafile emf = CreateMetafile(); + nint emfHandle = emf.GetHenhmetafile(); + if (emfHandle == 0) + { + throw new Win32Exception(); + } + + using Bitmap referenceBitmap = new(1, 1); + using Graphics referenceGraphics = Graphics.FromImage(referenceBitmap); + nint referenceHdc = 0; + bool completed = false; + try + { + referenceHdc = referenceGraphics.GetHdc(); + const int MmAnisotropic = 8; + uint size = GetWinMetaFileBits(emfHandle, 0, null, MmAnisotropic, referenceHdc); + if (size == 0) + { + throw new Win32Exception(); + } + + byte[] wmfBits = new byte[size]; + if (GetWinMetaFileBits(emfHandle, size, wmfBits, MmAnisotropic, referenceHdc) != size) + { + throw new Win32Exception(); + } + + byte[] result = AddPlaceableWmfHeader(wmfBits); + completed = true; + return result; + } + finally + { + try + { + if (referenceHdc != 0) + { + referenceGraphics.ReleaseHdc(referenceHdc); + } + } + finally + { + bool deleted = DeleteEnhMetaFile(emfHandle); + if (completed) + { + Assert.True(deleted); + } + } + } + } + + // Verifies structure and meaningful drawing records rather than accepting any non-null Metafile. + public static void AssertValid(Metafile metafile) + { + MetafileHeader header = metafile.GetMetafileHeader(); + Assert.True(header.IsEmfOrEmfPlus()); + Assert.True(header.Bounds.Width > 0); + Assert.True(header.Bounds.Height > 0); + + List records = []; + using Bitmap target = new(Frame.Width, Frame.Height); + using Graphics graphics = Graphics.FromImage(target); + graphics.EnumerateMetafile( + metafile, + System.Drawing.Point.Empty, + (recordType, _, _, _, _) => + { + records.Add(recordType); + return true; + }); + + Assert.Contains(EmfPlusRecordType.EmfHeader, records); + Assert.Contains(EmfPlusRecordType.EmfRectangle, records); + Assert.Contains(EmfPlusRecordType.EmfEof, records); + } + + // Prepends an Aldus placeable header and computes its XOR checksum over the first ten WORDs. + private static byte[] AddPlaceableWmfHeader(byte[] wmfBits) + { + const int HeaderSize = 22; + byte[] result = new byte[HeaderSize + wmfBits.Length]; + Span header = result.AsSpan(0, HeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(header, 0x9AC6CDD7); + BinaryPrimitives.WriteInt16LittleEndian(header[6..], (short)Frame.Left); + BinaryPrimitives.WriteInt16LittleEndian(header[8..], (short)Frame.Top); + BinaryPrimitives.WriteInt16LittleEndian(header[10..], (short)Frame.Right); + BinaryPrimitives.WriteInt16LittleEndian(header[12..], (short)Frame.Bottom); + BinaryPrimitives.WriteUInt16LittleEndian(header[14..], 1440); + + ushort checksum = 0; + for (int offset = 0; offset < 20; offset += sizeof(ushort)) + { + checksum ^= BinaryPrimitives.ReadUInt16LittleEndian(header[offset..]); + } + + BinaryPrimitives.WriteUInt16LittleEndian(header[20..], checksum); + wmfBits.CopyTo(result, HeaderSize); + return result; + } + + [DllImport("gdi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteEnhMetaFile(nint hEnhMetaFile); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern uint GetEnhMetaFileBits(nint hEnhMetaFile, uint bufferSize, [Out] byte[]? buffer); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern uint GetWinMetaFileBits( + nint hEnhMetaFile, + uint bufferSize, + [Out] byte[]? buffer, + int mapMode, + nint referenceHdc); + + [DllImport("gdi32.dll", EntryPoint = "Rectangle", SetLastError = true)] + private static extern int GdiRectangle(nint hdc, int left, int top, int right, int bottom); +}