diff --git a/src/Tests/UnitTest/TestComponentCSharp_Tests.cs b/src/Tests/UnitTest/TestComponentCSharp_Tests.cs index 06061bee5..286293ddc 100644 --- a/src/Tests/UnitTest/TestComponentCSharp_Tests.cs +++ b/src/Tests/UnitTest/TestComponentCSharp_Tests.cs @@ -2804,6 +2804,87 @@ static void AssertHasInterface(void* ccw, in Guid iid) } } + [TestMethod] + public unsafe void TestDictionaryKeyAndValueCollectionInterfaceMarshalling() + { + // Binding a XAML control to a dictionary's keys or values (e.g. '{x:Bind vm.Dict.Values}') marshals + // the 'Dictionary.KeyCollection'/'ValueCollection' nested types across the ABI, not the + // dictionary itself, which builds a CCW for them. Those types are never named in user code, because + // 'Keys'/'Values' are declared as 'IEnumerable' on 'IReadOnlyDictionary' and as + // 'ICollection' on 'IDictionary', so they only get one if the interop generator + // tracks them off the dictionary instantiation itself. + Dictionary dictionary = new() { [0] = "zero", [1] = "one", [2] = "two" }; + + IReadOnlyDictionary readOnlyDictionary = dictionary; + IDictionary mutableDictionary = dictionary; + + IEnumerable readOnlyKeys = readOnlyDictionary.Keys; + IEnumerable readOnlyValues = readOnlyDictionary.Values; + ICollection keys = mutableDictionary.Keys; + ICollection values = mutableDictionary.Values; + + // Guard against the BCL changing which types it hands out, so that this test fails loudly rather + // than silently covering nothing. The types are deliberately identified by name: naming them in + // code would put them in this assembly's metadata, which is precisely what the scenario cannot + // rely on (the whole point is that they are only reachable through the interface members). + AssertIsDictionaryCollection(readOnlyKeys, "KeyCollection"); + AssertIsDictionaryCollection(readOnlyValues, "ValueCollection"); + AssertIsDictionaryCollection(keys, "KeyCollection"); + AssertIsDictionaryCollection(values, "ValueCollection"); + + // 'IEnumerable' is projected as 'IIterable', and 'IEnumerable' as 'IBindableIterable' + AssertCcwExposesIterableInterfaces(readOnlyKeys, new Guid("81A643FB-F51C-5565-83C4-F96425777B66")); + AssertCcwExposesIterableInterfaces(readOnlyValues, new Guid("E2FCC7C1-3BFC-5A0B-B2B0-72E769D1CB7E")); + + // Enumerate the keys from native code, through 'IIterable' + int sum = 0; + + using (IEnumerator iterator = TestObject.GetIteratorForCollection(readOnlyKeys)) + { + while (iterator.MoveNext()) + { + sum += iterator.Current; + } + } + + Assert.AreEqual(3, sum); + + // Same thing through a projected API taking a bindable iterable, which is what XAML uses to bind + // an 'ItemsSource'. The values have to be sequential from 0, because the native setter validates that. + TestObject.BindableIterableProperty = readOnlyKeys; + CollectionAssert.AreEqual(new[] { 0, 1, 2 }, TestObject.BindableIterableProperty.Cast().ToArray()); + + static void AssertIsDictionaryCollection(IEnumerable source, string name) + { + Assert.AreEqual($"System.Collections.Generic.Dictionary`2+{name}", source.GetType().GetGenericTypeDefinition().FullName); + } + + static void AssertCcwExposesIterableInterfaces(IEnumerable source, Guid iidIIterable) + { + Guid iidIBindableIterable = new("036D2C08-DF29-41AF-8AA2-D774BE62BA6F"); + + void* ccw = WindowsRuntimeMarshal.ConvertToUnmanaged(source); + + try + { + AssertHasInterface(ccw, in iidIIterable); + AssertHasInterface(ccw, in iidIBindableIterable); + } + finally + { + _ = Marshal.Release((nint)ccw); + } + + static void AssertHasInterface(void* ccw, in Guid iid) + { + Marshal.ThrowExceptionForHR(Marshal.QueryInterface((nint)ccw, in iid, out nint interfaceCcw)); + Assert.AreNotEqual(IntPtr.Zero, interfaceCcw); + + _ = Marshal.Release(interfaceCcw); + } + } + } + [TestMethod] public void TestClassGeneric() {