diff --git a/Microsoft.Dotnet.Wpf.sln b/Microsoft.Dotnet.Wpf.sln index 09509d6f269..b50aa32c677 100644 --- a/Microsoft.Dotnet.Wpf.sln +++ b/Microsoft.Dotnet.Wpf.sln @@ -386,6 +386,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.Printing.Tests", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PresentationFramework.Tests", "src\Microsoft.DotNet.Wpf\tests\UnitTests\PresentationFramework.Tests\PresentationFramework.Tests.csproj", "{33BA28FA-887A-45AE-BEC2-7254E0044DE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DirectWriteForwarder.Tests", "src\Microsoft.DotNet.Wpf\tests\UnitTests\DirectWriteForwarder.Tests\DirectWriteForwarder.Tests.csproj", "{67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|arm64 = Debug|arm64 @@ -1682,6 +1684,18 @@ Global {33BA28FA-887A-45AE-BEC2-7254E0044DE0}.Release|x64.Build.0 = Release|x64 {33BA28FA-887A-45AE-BEC2-7254E0044DE0}.Release|x86.ActiveCfg = Release|x86 {33BA28FA-887A-45AE-BEC2-7254E0044DE0}.Release|x86.Build.0 = Release|x86 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Debug|arm64.ActiveCfg = Debug|arm64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Debug|arm64.Build.0 = Debug|arm64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Debug|x64.ActiveCfg = Debug|x64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Debug|x64.Build.0 = Debug|x64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Debug|x86.ActiveCfg = Debug|x86 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Debug|x86.Build.0 = Debug|x86 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Release|arm64.ActiveCfg = Release|arm64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Release|arm64.Build.0 = Release|arm64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Release|x64.ActiveCfg = Release|x64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Release|x64.Build.0 = Release|x64 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Release|x86.ActiveCfg = Release|x86 + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1811,6 +1825,7 @@ Global {56833D74-2D0B-5516-C1D6-B93D4FFF7612} = {A48B585E-6AB0-4F8D-8484-77F37CB44437} {762F6671-44CA-672D-B9C5-CFB69999F152} = {A48B585E-6AB0-4F8D-8484-77F37CB44437} {33BA28FA-887A-45AE-BEC2-7254E0044DE0} = {A48B585E-6AB0-4F8D-8484-77F37CB44437} + {67AE2A3E-9E50-E2D7-6913-9399F6D4EDDF} = {A48B585E-6AB0-4F8D-8484-77F37CB44437} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B4340004-DAC0-497D-B69D-CFA7CD93F567} diff --git a/src/Microsoft.DotNet.Wpf/src/DirectWriteForwarder/OtherAssemblyAttrs.cpp b/src/Microsoft.DotNet.Wpf/src/DirectWriteForwarder/OtherAssemblyAttrs.cpp index a1f2761ff63..e64d9f0e693 100644 --- a/src/Microsoft.DotNet.Wpf/src/DirectWriteForwarder/OtherAssemblyAttrs.cpp +++ b/src/Microsoft.DotNet.Wpf/src/DirectWriteForwarder/OtherAssemblyAttrs.cpp @@ -6,4 +6,5 @@ using namespace System::Runtime::CompilerServices; #using WINDOWS_BASE_DLL [assembly:InternalsVisibleTo("PresentationCore, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")]; +[assembly:InternalsVisibleTo("DirectWriteForwarder.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")]; [assembly:System::Runtime::CompilerServices::TypeForwardedTo(System::Windows::Media::TextFormattingMode::typeid)] ; diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/OtherAssemblyAttrs.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/OtherAssemblyAttrs.cs index 7cf2993f90c..862a7f081bc 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/OtherAssemblyAttrs.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/OtherAssemblyAttrs.cs @@ -17,6 +17,7 @@ [assembly: InternalsVisibleTo(BuildInfo.SystemWindowsControlsRibbon)] [assembly: InternalsVisibleTo(BuildInfo.WindowsFormsIntegration)] [assembly: InternalsVisibleTo($"PresentationCore.Tests, PublicKey={BuildInfo.WCP_PUBLIC_KEY_STRING}")] +[assembly: InternalsVisibleTo($"DirectWriteForwarder.Tests, PublicKey={BuildInfo.WCP_PUBLIC_KEY_STRING}")] [assembly: TypeForwardedTo(typeof(System.Windows.Markup.IUriContext))] [assembly: TypeForwardedTo(typeof(System.Windows.Media.TextFormattingMode))] [assembly: TypeForwardedTo(typeof(System.Windows.Input.ICommand))] diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/AdditionalCoverageTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/AdditionalCoverageTests.cs new file mode 100644 index 00000000000..6869607022f --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/AdditionalCoverageTests.cs @@ -0,0 +1,619 @@ +// 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; +using System.Globalization; + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Additional tests to increase code coverage for DirectWriteForwarder. +/// Targets uncovered methods in LocalizedStrings, Font, FontList, FontFace, FontCollection. +/// +public class AdditionalCoverageTests +{ + #region LocalizedStrings IDictionary method coverage + + [Fact] + public void LocalizedStrings_SetItem_ShouldThrowNotSupportedException() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + Action act = () => localizedStrings[CultureInfo.InvariantCulture] = "Test"; + + act.Should().Throw(); + } + + [Fact] + public void LocalizedStrings_AddKeyValuePair_ShouldThrowNotSupportedException() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var pair = new KeyValuePair(CultureInfo.InvariantCulture, "Test"); + Action act = () => ((ICollection>)localizedStrings).Add(pair); + + act.Should().Throw(); + } + + [Fact] + public void LocalizedStrings_RemoveKeyValuePair_ShouldThrowNotSupportedException() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var firstCulture = TestHelpers.GetFirstCultureOrSkip(localizedStrings); + + var pair = new KeyValuePair(firstCulture, localizedStrings[firstCulture]); + Action act = () => ((ICollection>)localizedStrings).Remove(pair); + + act.Should().Throw(); + } + + [Fact] + public void LocalizedStrings_GetEnumerator2_NonGeneric_ShouldWork() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + // Cast to non-generic IEnumerable and get enumerator + var enumerable = (IEnumerable)localizedStrings; + var enumerator = enumerable.GetEnumerator(); + + enumerator.Should().NotBeNull(); + + // Should be able to enumerate + int count = 0; + while (enumerator.MoveNext()) + { + enumerator.Current.Should().NotBeNull(); + count++; + } + + count.Should().Be(localizedStrings.Count); + } + + [Fact] + public void LocalizedStrings_Indexer_WithNonExistentCulture_ShouldReturnNull() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + // Use a culture very unlikely to be in the font + var rareCulture = new CultureInfo("zu-ZA"); // Zulu - South Africa + + // If this culture doesn't exist, the indexer should return null + if (!localizedStrings.ContainsKey(rareCulture)) + { + var result = localizedStrings[rareCulture]; + result.Should().BeNull(); + } + } + + [Fact] + public void LocalizedStrings_TryGetValue_WithNonExistentCulture_ShouldReturnFalse() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var rareCulture = new CultureInfo("zu-ZA"); + + if (!localizedStrings.ContainsKey(rareCulture)) + { + bool result = localizedStrings.TryGetValue(rareCulture, out _); + result.Should().BeFalse(); + } + } + + #endregion + + #region FontList coverage + + [Fact] + public void FontList_GetEnumerator2_NonGeneric_ShouldWork() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + // FontFamily inherits from FontList, so we can test the non-generic IEnumerable + var enumerable = (IEnumerable)arialFamily; + var enumerator = enumerable.GetEnumerator(); + + enumerator.Should().NotBeNull(); + + int count = 0; + while (enumerator.MoveNext()) + { + enumerator.Current.Should().NotBeNull(); + enumerator.Current.Should().BeOfType(); + count++; + } + + count.Should().BeGreaterThan(0); + } + + [Fact] + public void FontList_FontsCollection_ShouldReturnFontCollection() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + + // FontFamily has FontsCollection property from FontList + var fontsCollection = arialFamily.FontsCollection; + + fontsCollection.Should().NotBeNull(); + fontsCollection.FamilyCount.Should().BeGreaterThan(0); + } + + [Fact] + public void FontList_Enumerator_Reset_ShouldResetPosition() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var enumerator = arialFamily.GetEnumerator(); + + // Move forward + enumerator.MoveNext().Should().BeTrue(); + var firstFontWeight = enumerator.Current.Weight; + + // Move more + if (arialFamily.Count > 1) + { + enumerator.MoveNext(); + } + + // Reset + enumerator.Reset(); + + // After reset, MoveNext should give us the first element again + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Weight.Should().Be(firstFontWeight); + + enumerator.Dispose(); + } + + [Fact] + public void FontList_Enumerator_CurrentBeforeMoveNext_ShouldThrow() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var enumerator = arialFamily.GetEnumerator(); + + // Accessing Current before MoveNext should throw + Action act = () => { var _ = enumerator.Current; }; + act.Should().Throw(); + + enumerator.Dispose(); + } + + [Fact] + public void FontList_Enumerator_CurrentAfterEnd_ShouldThrow() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var enumerator = arialFamily.GetEnumerator(); + + // Move past the end + while (enumerator.MoveNext()) { } + + // Accessing Current after enumeration ends should throw + Action act = () => { var _ = enumerator.Current; }; + act.Should().Throw(); + + enumerator.Dispose(); + } + + #endregion + + #region FontCollection coverage + + [Fact] + public void FontCollection_GetFontFromFontFace_ShouldReturnMatchingFont() + { + var fontCollection = DWriteFactory.SystemFontCollection; + var factory = DWriteFactory.Instance; + + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + var font = fontCollection.GetFontFromFontFace(fontFace); + + font.Should().NotBeNull(); + // The font should be from the Arial family + font!.Family.FamilyNames.GetString(0).Should().Contain("Arial"); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void FontCollection_GetFontFromFontFace_WithSimulations_ShouldReturnFont() + { + var fontCollection = DWriteFactory.SystemFontCollection; + var factory = DWriteFactory.Instance; + + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + // Create a font face with Bold simulation + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0, FontSimulations.Bold); + try + { + var font = fontCollection.GetFontFromFontFace(fontFace); + + // Note: This may or may not return a font depending on how DirectWrite handles simulated faces + // The important thing is it doesn't throw + } + finally + { + fontFace.Release(); + } + } + + #endregion + + #region Font class coverage + + [Fact] + public void Font_DWriteFontAddRef_ShouldReturnValidPointer() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + + // Access the DWriteFontAddRef property - this adds a reference to the native object + var ptr = font.DWriteFontAddRef; + + ptr.Should().NotBe(IntPtr.Zero); + } + + [Fact] + public void Font_Version_ShouldBeConsistent() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + + // Call Version multiple times - it should be cached and return same value + var version1 = font.Version; + var version2 = font.Version; + + version1.Should().Be(version2); + version1.Should().BeGreaterThan(0); + } + + [Fact] + public void Font_GetFontFace_MultipleCalls_ShouldUseCacheEfficiently() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + + // Create multiple font faces - the caching logic should be exercised + var fontFaces = new List(); + for (int i = 0; i < 10; i++) + { + var fontFace = font.GetFontFace(); + fontFaces.Add(fontFace); + } + + try + { + // All should be valid + foreach (var ff in fontFaces) + { + ff.Should().NotBeNull(); + ff.GlyphCount.Should().BeGreaterThan(0); + } + } + finally + { + foreach (var ff in fontFaces) + { + ff.Release(); + } + } + } + + [Fact] + public void Font_ResetFontFaceCache_ShouldNotThrow() + { + // ResetFontFaceCache is a static method that clears cached FontFace instances + // We need to call it via reflection since it's internal + var fontType = typeof(Font); + var resetMethod = fontType.GetMethod("ResetFontFaceCache", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + + if (resetMethod != null) + { + Action act = () => resetMethod.Invoke(null, null); + act.Should().NotThrow(); + } + } + + [Fact] + public void Font_GetFontFace_AfterCacheReset_ShouldStillWork() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + + // Get a font face first to populate the cache + var fontFace1 = font.GetFontFace(); + fontFace1.Release(); + + // Reset the cache + var fontType = typeof(Font); + var resetMethod = fontType.GetMethod("ResetFontFaceCache", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + resetMethod?.Invoke(null, null); + + // Get another font face - should work after cache reset + var fontFace2 = font.GetFontFace(); + try + { + fontFace2.Should().NotBeNull(); + fontFace2.GlyphCount.Should().BeGreaterThan(0); + } + finally + { + fontFace2.Release(); + } + } + + #endregion + + #region FontFace coverage + + [Fact] + public void FontFace_DWriteFontFaceAddRef_ShouldReturnValidPointer() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + var ptr = fontFace.DWriteFontFaceAddRef; + ptr.Should().NotBe(IntPtr.Zero); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void FontFace_GetFileZero_ShouldReturnValidFontFile() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + var fontFile = fontFace.GetFileZero(); + fontFile.Should().NotBeNull(); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void FontFace_IsSymbolFont_ShouldReturnCorrectValue() + { + var factory = DWriteFactory.Instance; + + // Test with Arial (non-symbol) + var arialPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf"); + if (File.Exists(arialPath)) + { + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + fontFace.IsSymbolFont.Should().BeFalse(); + } + finally + { + fontFace.Release(); + } + } + + // Test with Symbol font if available + var symbolPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "symbol.ttf"); + if (File.Exists(symbolPath)) + { + var fontFace = factory.CreateFontFace(new Uri(symbolPath), 0); + try + { + fontFace.IsSymbolFont.Should().BeTrue(); + } + finally + { + fontFace.Release(); + } + } + } + + #endregion + + #region LocalizedStrings Enumerator coverage + + [Fact] + public void LocalizedStringsEnumerator_Reset_ShouldResetPosition() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + Assert.SkipUnless(localizedStrings.Count > 0, "Localized strings is empty"); + + var enumerator = localizedStrings.GetEnumerator(); + + // Move forward + enumerator.MoveNext().Should().BeTrue(); + var firstPair = enumerator.Current; + + // Move more + if (localizedStrings.Count > 1) + { + enumerator.MoveNext(); + } + + // Reset + enumerator.Reset(); + + // After reset, MoveNext should give us the first element again + enumerator.MoveNext().Should().BeTrue(); + enumerator.Current.Key.Should().Be(firstPair.Key); + + enumerator.Dispose(); + } + + [Fact] + public void LocalizedStringsEnumerator_MoveNextPastEnd_ShouldReturnFalse() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var enumerator = localizedStrings.GetEnumerator(); + + // Move to the end + while (enumerator.MoveNext()) { } + + // Calling MoveNext again should return false (not throw) + enumerator.MoveNext().Should().BeFalse(); + enumerator.MoveNext().Should().BeFalse(); // Multiple calls should still return false + + enumerator.Dispose(); + } + + [Fact] + public void LocalizedStringsEnumerator_Current2_NonGeneric_ShouldWork() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + Assert.SkipUnless(localizedStrings.Count > 0, "Localized strings is empty"); + + var enumerator = (IEnumerator)localizedStrings.GetEnumerator(); + + enumerator.MoveNext(); + var current = enumerator.Current; + + current.Should().NotBeNull(); + current.Should().BeOfType>(); + } + + #endregion + + #region Multiple fonts caching test (exercises LookupFontFaceSlow) + + [Fact] + public void Font_MultipleFonts_ShouldExerciseCacheLookup() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count >= 2, "Arial font family has fewer than 2 fonts"); + + var fontFaces = new List(); + + try + { + // Get font faces from multiple fonts in the same family + // This exercises the cache lookup paths + for (uint i = 0; i < Math.Min(arialFamily.Count, 5u); i++) + { + var font = arialFamily[i]; + var fontFace = font.GetFontFace(); + fontFaces.Add(fontFace); + } + + // Now access them again - should use cache + for (uint i = 0; i < Math.Min(arialFamily.Count, 5u); i++) + { + var font = arialFamily[i]; + var fontFace = font.GetFontFace(); + fontFace.Should().NotBeNull(); + fontFace.Release(); + } + } + finally + { + foreach (var ff in fontFaces) + { + ff.Release(); + } + } + } + + [Fact] + public void Font_DifferentFamilies_ShouldExerciseCacheEviction() + { + var fontCollection = DWriteFactory.SystemFontCollection; + var fontFaces = new List(); + + try + { + // Get fonts from multiple different families to fill and evict from cache + // Cache size is 4, so accessing more than 4 different fonts should trigger eviction + string[] familyNames = ["Arial", "Times New Roman", "Courier New", "Verdana", "Tahoma", "Georgia"]; + + foreach (var familyName in familyNames) + { + var family = fontCollection[familyName]; + if (family != null && family.Count > 0) + { + var font = family[0u]; + var fontFace = font.GetFontFace(); + fontFaces.Add(fontFace); + } + } + + // All should be valid + foreach (var ff in fontFaces) + { + ff.GlyphCount.Should().BeGreaterThan(0); + } + } + finally + { + foreach (var ff in fontFaces) + { + ff.Release(); + } + } + } + + #endregion + + #region FontFileEnumerator coverage + + [Fact] + public void FontFileEnumerator_MoveNextPastEnd_ShouldReturnFalse() + { + // FontFileEnumerator is accessed via factory.CreateFontFileEnumerator + // which is internal to the font loading infrastructure + // We test it indirectly through font file operations + + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + // Creating a font face exercises the font file loading infrastructure + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + fontFace.Should().NotBeNull(); + } + finally + { + fontFace.Release(); + } + } + + #endregion +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DWriteMatrixTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DWriteMatrixTests.cs new file mode 100644 index 00000000000..24ab75f1f19 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DWriteMatrixTests.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for value struct. +/// +public class DWriteMatrixTests +{ + [Fact] + public void DefaultValues_ShouldBeZero() + { + var matrix = new DWriteMatrix(); + + matrix.M11.Should().Be(0); + matrix.M12.Should().Be(0); + matrix.M21.Should().Be(0); + matrix.M22.Should().Be(0); + matrix.Dx.Should().Be(0); + matrix.Dy.Should().Be(0); + } + + [Fact] + public void IdentityMatrix_Values() + { + // An identity matrix should have M11=1, M22=1, all others=0 + var identity = new DWriteMatrix + { + M11 = 1.0f, + M12 = 0.0f, + M21 = 0.0f, + M22 = 1.0f, + Dx = 0.0f, + Dy = 0.0f + }; + + identity.M11.Should().Be(1.0f); + identity.M12.Should().Be(0.0f); + identity.M21.Should().Be(0.0f); + identity.M22.Should().Be(1.0f); + identity.Dx.Should().Be(0.0f); + identity.Dy.Should().Be(0.0f); + } + + [Fact] + public void TranslationMatrix_Values() + { + // A translation matrix should have M11=1, M22=1, Dx and Dy set to translation values + var translation = new DWriteMatrix + { + M11 = 1.0f, + M12 = 0.0f, + M21 = 0.0f, + M22 = 1.0f, + Dx = 100.0f, + Dy = 50.0f + }; + + translation.Dx.Should().Be(100.0f); + translation.Dy.Should().Be(50.0f); + } + + [Fact] + public void ScaleMatrix_Values() + { + // A scale matrix has M11 and M22 set to scale factors + var scale = new DWriteMatrix + { + M11 = 2.0f, + M12 = 0.0f, + M21 = 0.0f, + M22 = 0.5f, + Dx = 0.0f, + Dy = 0.0f + }; + + scale.M11.Should().Be(2.0f); + scale.M22.Should().Be(0.5f); + } + + [Fact] + public void RotationMatrix_90Degrees() + { + // 90-degree rotation: M11=cos(90)=0, M12=sin(90)=1, M21=-sin(90)=-1, M22=cos(90)=0 + var rotation90 = new DWriteMatrix + { + M11 = 0.0f, + M12 = 1.0f, + M21 = -1.0f, + M22 = 0.0f, + Dx = 0.0f, + Dy = 0.0f + }; + + rotation90.M11.Should().BeApproximately(0.0f, 0.0001f); + rotation90.M12.Should().BeApproximately(1.0f, 0.0001f); + rotation90.M21.Should().BeApproximately(-1.0f, 0.0001f); + rotation90.M22.Should().BeApproximately(0.0f, 0.0001f); + } + + [Theory] + [InlineData(1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f)] // Identity + [InlineData(2.0f, 0.0f, 0.0f, 2.0f, 0.0f, 0.0f)] // Uniform scale 2x + [InlineData(1.0f, 0.0f, 0.0f, 1.0f, 10.0f, 20.0f)] // Translation + [InlineData(-1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f)] // Horizontal flip + [InlineData(1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f)] // Vertical flip + public void AllFields_CanBeSetAndRetrieved(float m11, float m12, float m21, float m22, float dx, float dy) + { + var matrix = new DWriteMatrix + { + M11 = m11, + M12 = m12, + M21 = m21, + M22 = m22, + Dx = dx, + Dy = dy + }; + + matrix.M11.Should().Be(m11); + matrix.M12.Should().Be(m12); + matrix.M21.Should().Be(m21); + matrix.M22.Should().Be(m22); + matrix.Dx.Should().Be(dx); + matrix.Dy.Should().Be(dy); + } + + [Fact] + public void ShearMatrix_Values() + { + // A shear/skew matrix modifies M12 and M21 + var shear = new DWriteMatrix + { + M11 = 1.0f, + M12 = 0.5f, // Horizontal shear + M21 = 0.25f, // Vertical shear + M22 = 1.0f, + Dx = 0.0f, + Dy = 0.0f + }; + + shear.M12.Should().Be(0.5f); + shear.M21.Should().Be(0.25f); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DWriteTypeConverterTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DWriteTypeConverterTests.cs new file mode 100644 index 00000000000..ae5774ccfd6 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DWriteTypeConverterTests.cs @@ -0,0 +1,908 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for internal class. +/// These tests exercise the type conversion methods indirectly through public APIs. +/// Note: Enum types are internal, so we use integer values and cast internally. +/// +public class DWriteTypeConverterTests +{ + #region FactoryType conversion + + [Fact] + public void Convert_FactoryType_ShouldConvertWithoutException() + { + // FactoryType.Shared (0) is used by DWriteFactory.Instance + var factory = DWriteFactory.Instance; + factory.Should().NotBeNull(); + } + + #endregion + + #region FontWeight conversion + + [Theory] + [InlineData(100)] // Thin + [InlineData(200)] // ExtraLight + [InlineData(300)] // Light + [InlineData(400)] // Normal + [InlineData(500)] // Medium + [InlineData(600)] // SemiBold + [InlineData(700)] // Bold + [InlineData(800)] // ExtraBold + [InlineData(900)] // Black + [InlineData(950)] // ExtraBlack + public void Convert_FontWeight_ToNative_ShouldSucceed(int weightValue) + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + + var weight = (FontWeight)weightValue; + var matchingFonts = arialFamily.GetMatchingFonts(weight, FontStretch.Normal, FontStyle.Normal); + matchingFonts.Should().NotBeNull(); + } + + [Fact] + public void Convert_FontWeight_FromNative_ShouldSucceed() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + foreach (var font in arialFamily) + { + var weight = font.Weight; + weight.Should().BeDefined(); + } + } + + [Theory] + [InlineData(350)] // Non-standard weight + [InlineData(450)] + [InlineData(550)] + [InlineData(650)] + [InlineData(750)] + [InlineData(850)] + public void Convert_FontWeight_CustomValues_ShouldSucceed(int weightValue) + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + + var weight = (FontWeight)weightValue; + var matchingFonts = arialFamily.GetMatchingFonts(weight, FontStretch.Normal, FontStyle.Normal); + matchingFonts.Should().NotBeNull(); + } + + #endregion + + #region FontStretch conversion + + [Theory] + [InlineData(1)] // UltraCondensed + [InlineData(2)] // ExtraCondensed + [InlineData(3)] // Condensed + [InlineData(4)] // SemiCondensed + [InlineData(5)] // Normal + [InlineData(6)] // SemiExpanded + [InlineData(7)] // Expanded + [InlineData(8)] // ExtraExpanded + [InlineData(9)] // UltraExpanded + public void Convert_FontStretch_ToNative_ShouldSucceed(int stretchValue) + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + + var stretch = (FontStretch)stretchValue; + var matchingFonts = arialFamily.GetMatchingFonts(FontWeight.Normal, stretch, FontStyle.Normal); + matchingFonts.Should().NotBeNull(); + } + + [Fact] + public void Convert_FontStretch_FromNative_ShouldSucceed() + { + var fontCollection = DWriteFactory.SystemFontCollection; + string[] familyNames = ["Arial", "Arial Narrow", "Verdana"]; + + foreach (var familyName in familyNames) + { + var family = fontCollection[familyName]; + if (family == null) continue; + + foreach (var font in family) + { + var stretch = font.Stretch; + stretch.Should().BeDefined(); + } + } + } + + #endregion + + #region FontStyle conversion + + [Theory] + [InlineData(0)] // Normal + [InlineData(1)] // Oblique + [InlineData(2)] // Italic + public void Convert_FontStyle_ToNative_ShouldSucceed(int styleValue) + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + + var style = (FontStyle)styleValue; + var matchingFonts = arialFamily.GetMatchingFonts(FontWeight.Normal, FontStretch.Normal, style); + matchingFonts.Should().NotBeNull(); + } + + [Fact] + public void Convert_FontStyle_FromNative_ShouldSucceed() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + + foreach (var font in arialFamily) + { + var style = font.Style; + style.Should().BeDefined(); + } + } + + #endregion + + #region FontSimulations conversion + + [Theory] + [InlineData(0)] // None + [InlineData(1)] // Bold + [InlineData(2)] // Oblique + [InlineData(3)] // Bold | Oblique + public void Convert_FontSimulations_ToNative_ShouldSucceed(int simulationsValue) + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var simulations = (FontSimulations)simulationsValue; + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0, simulations); + try + { + fontFace.Should().NotBeNull(); + fontFace.SimulationFlags.Should().Be(simulations); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void Convert_FontSimulations_FromNative_ShouldSucceed() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + // SimulationFlags calls DWriteTypeConverter.Convert(DWRITE_FONT_SIMULATIONS) + font.SimulationFlags.Should().BeDefined(); + } + + #endregion + + #region FontFaceType conversion + + [Fact] + public void Convert_FontFaceType_TrueType_FromNative_ShouldSucceed() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + fontFace.Type.Should().Be(FontFaceType.TrueType); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void Convert_FontFaceType_TrueTypeCollection_FromNative_ShouldSucceed() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfFontNotAvailable(TestHelpers.CambriaPath, "Cambria"); + var cambriaPath = TestHelpers.CambriaPath; + + var fontFace = factory.CreateFontFace(new Uri(cambriaPath), 0); + try + { + fontFace.Type.Should().Be(FontFaceType.TrueTypeCollection); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void Convert_FontFaceType_VariousFonts_FromNative_ShouldSucceed() + { + var factory = DWriteFactory.Instance; + var fontsPath = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + string[] fontFiles = ["arial.ttf", "arialbd.ttf", "times.ttf", "cour.ttf", "verdana.ttf"]; + + foreach (var fontFile in fontFiles) + { + var fontPath = Path.Combine(fontsPath, fontFile); + if (!File.Exists(fontPath)) continue; + + var fontFace = factory.CreateFontFace(new Uri(fontPath), 0); + try + { + fontFace.Type.Should().BeDefined(); + } + finally + { + fontFace.Release(); + } + } + } + + #endregion + + #region InformationalStringID conversion + + [Theory] + [InlineData(0, null)] // None - no string expected + [InlineData(1, "Monotype")] // CopyrightNotice - contains "Monotype" + [InlineData(2, null)] // VersionStrings - version varies + [InlineData(3, "Arial")] // Trademark - contains "Arial" + [InlineData(4, "Monotype")] // Manufacturer - "Monotype" or "The Monotype Corporation" + [InlineData(5, null)] // Designer - may not exist + [InlineData(6, null)] // DesignerURL - may not exist + [InlineData(7, null)] // Description - may not exist + [InlineData(8, null)] // FontVendorURL - may not exist + [InlineData(9, null)] // LicenseDescription - may not exist + [InlineData(10, null)] // LicenseInfoURL - may not exist + [InlineData(11, "Arial")] // WIN32FamilyNames - "Arial" + [InlineData(12, "Normal")] // Win32SubFamilyNames - "Normal" (localized style name) + [InlineData(13, null)] // PreferredFamilyNames - may not exist + [InlineData(14, null)] // PreferredSubFamilyNames - may not exist + [InlineData(15, null)] // SampleText - may not exist + public void Convert_InformationalStringID_ToNative_ShouldSucceed(int stringIdValue, string? expectedSubstring) + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + var stringId = (InformationalStringID)stringIdValue; + + // GetInformationalStrings calls DWriteTypeConverter.Convert(InformationalStringID) + var exists = font.GetInformationalStrings(stringId, out var localizedStrings); + + if (expectedSubstring != null && exists) + { + // Validate the English string contains expected content + localizedStrings.Should().NotBeNull(); + var englishValue = localizedStrings!.Values.FirstOrDefault() ?? ""; + englishValue.Should().Contain(expectedSubstring, + $"InformationalStringID {stringId} should contain '{expectedSubstring}'"); + } + } + + #endregion + + #region TextFormattingMode / MeasuringMode conversion + + [Fact] + public unsafe void Convert_TextFormattingMode_Ideal_ShouldSucceed() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + var fontFace = font.GetFontFace(); + try + { + uint[] codePoints = [65]; // 'A' + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + // useDisplayNatural: false = Ideal mode + GlyphMetrics[] metrics = new GlyphMetrics[1]; + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + fontFace.GetDisplayGlyphMetrics(pGlyphIndices, 1, pMetrics, + 12.0f, useDisplayNatural: false, isSideways: false, pixelsPerDip: 1.0f); + } + + metrics[0].AdvanceWidth.Should().BeGreaterThan(0); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void Convert_TextFormattingMode_Display_ShouldSucceed() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + uint[] codePoints = [65]; // 'A' + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + // useDisplayNatural: true = Display mode + GlyphMetrics[] metrics = new GlyphMetrics[1]; + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + fontFace.GetDisplayGlyphMetrics(pGlyphIndices, 1, pMetrics, + 12.0f, useDisplayNatural: true, isSideways: false, pixelsPerDip: 1.0f); + } + + metrics[0].AdvanceWidth.Should().BeGreaterThan(0); + } + finally + { + fontFace.Release(); + } + } + + #endregion + + #region FontMetrics conversion + + [Fact] + public void Convert_FontMetrics_FromNative_ShouldSucceed() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + var metrics = font.Metrics; + + metrics.Should().NotBeNull(); + metrics.Ascent.Should().BeGreaterThan(0); + metrics.Descent.Should().BeGreaterThan(0); + metrics.DesignUnitsPerEm.Should().BeGreaterThan(0); + } + + [Fact] + public void Convert_FontMetrics_DisplayMetrics_FromNative_ShouldSucceed() + { + var arialFamily = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(arialFamily.Count > 0, "Arial font family has no fonts"); + + var font = arialFamily[0u]; + var displayMetrics = font.DisplayMetrics(12.0f, 96.0f); + + displayMetrics.Should().NotBeNull(); + displayMetrics.Ascent.Should().BeGreaterThan(0); + } + + #endregion + + #region DWriteMatrix struct + + [Fact] + public void DWriteMatrix_DefaultValues_ShouldBeValid() + { + var matrix = new DWriteMatrix(); + + matrix.M11.Should().Be(0); + matrix.M12.Should().Be(0); + matrix.M21.Should().Be(0); + matrix.M22.Should().Be(0); + matrix.Dx.Should().Be(0); + matrix.Dy.Should().Be(0); + } + + [Fact] + public void DWriteMatrix_IdentityMatrix_ShouldBeValid() + { + var matrix = new DWriteMatrix + { + M11 = 1.0f, + M12 = 0.0f, + M21 = 0.0f, + M22 = 1.0f, + Dx = 0.0f, + Dy = 0.0f + }; + + matrix.M11.Should().Be(1.0f); + matrix.M22.Should().Be(1.0f); + } + + #endregion + + #region Direct DWriteTypeConverter method calls + + // The following tests call DWriteTypeConverter methods directly + // since it's a private ref class (internal in C#) with internal methods + + // DWRITE_FACTORY_TYPE enum values (from dwrite.h) + private const int DWRITE_FACTORY_TYPE_SHARED = 0; + private const int DWRITE_FACTORY_TYPE_ISOLATED = 1; + + [Theory] + [InlineData(0, DWRITE_FACTORY_TYPE_SHARED)] // Shared + [InlineData(1, DWRITE_FACTORY_TYPE_ISOLATED)] // Isolated + public void DWriteTypeConverter_Convert_FactoryType_ReturnsExpectedValue(int factoryTypeValue, int expectedNativeValue) + { + var factoryType = (FactoryType)factoryTypeValue; + int result = (int)DWriteTypeConverter.Convert(factoryType); + result.Should().Be(expectedNativeValue, + $"FactoryType.{factoryType} should convert to DWRITE_FACTORY_TYPE value {expectedNativeValue}"); + } + + [Fact] + public void DWriteTypeConverter_Convert_FactoryType_Invalid_ShouldThrow() + { + var invalidFactoryType = (FactoryType)99; + var act = () => DWriteTypeConverter.Convert(invalidFactoryType); + act.Should().Throw(); + } + + // DWRITE_FONT_WEIGHT enum values match the numeric weight values (100-950) + [Theory] + [InlineData(100, 100)] // Thin + [InlineData(200, 200)] // ExtraLight + [InlineData(300, 300)] // Light + [InlineData(350, 350)] // SemiLight - passes through as-is + [InlineData(400, 400)] // Normal + [InlineData(500, 500)] // Medium + [InlineData(600, 600)] // SemiBold + [InlineData(700, 700)] // Bold + [InlineData(800, 800)] // ExtraBold + [InlineData(900, 900)] // Black + [InlineData(950, 950)] // ExtraBlack + [InlineData(1, 1)] // Min valid custom weight + [InlineData(999, 999)] // Max valid custom weight + [InlineData(550, 550)] // Custom weight in middle + public void DWriteTypeConverter_Convert_FontWeight_PreservesNumericValue(int weightValue, int expectedNativeValue) + { + var weight = (FontWeight)weightValue; + int result = (int)DWriteTypeConverter.Convert(weight); + result.Should().Be(expectedNativeValue, + $"FontWeight({weightValue}) should convert to DWRITE_FONT_WEIGHT value {expectedNativeValue}"); + } + + [Theory] + [InlineData(0)] // Invalid - too low + [InlineData(1000)] // Invalid - too high + [InlineData(-1)] // Invalid - negative + public void DWriteTypeConverter_Convert_FontWeight_Invalid_ShouldThrow(int weightValue) + { + var weight = (FontWeight)weightValue; + var act = () => DWriteTypeConverter.Convert(weight); + act.Should().Throw(); + } + + // DWRITE_FONT_STRETCH enum values (0-9) + [Theory] + [InlineData(0, 0)] // Undefined + [InlineData(1, 1)] // UltraCondensed + [InlineData(2, 2)] // ExtraCondensed + [InlineData(3, 3)] // Condensed + [InlineData(4, 4)] // SemiCondensed + [InlineData(5, 5)] // Normal + [InlineData(6, 6)] // SemiExpanded + [InlineData(7, 7)] // Expanded + [InlineData(8, 8)] // ExtraExpanded + [InlineData(9, 9)] // UltraExpanded + public void DWriteTypeConverter_Convert_FontStretch_PreservesNumericValue(int stretchValue, int expectedNativeValue) + { + var stretch = (FontStretch)stretchValue; + int result = (int)DWriteTypeConverter.Convert(stretch); + result.Should().Be(expectedNativeValue, + $"FontStretch({stretchValue}) should convert to DWRITE_FONT_STRETCH value {expectedNativeValue}"); + } + + [Fact] + public void DWriteTypeConverter_Convert_FontStretch_Invalid_ShouldThrow() + { + var invalidStretch = (FontStretch)99; + var act = () => DWriteTypeConverter.Convert(invalidStretch); + act.Should().Throw(); + } + + // DWRITE_FONT_STYLE enum values (0-2) + [Theory] + [InlineData(0, 0)] // Normal + [InlineData(1, 1)] // Oblique + [InlineData(2, 2)] // Italic + public void DWriteTypeConverter_Convert_FontStyle_PreservesNumericValue(int styleValue, int expectedNativeValue) + { + var style = (FontStyle)styleValue; + int result = (int)DWriteTypeConverter.Convert(style); + result.Should().Be(expectedNativeValue, + $"FontStyle({styleValue}) should convert to DWRITE_FONT_STYLE value {expectedNativeValue}"); + } + + [Fact] + public void DWriteTypeConverter_Convert_FontStyle_Invalid_ShouldThrow() + { + var invalidStyle = (FontStyle)99; + var act = () => DWriteTypeConverter.Convert(invalidStyle); + act.Should().Throw(); + } + + // DWRITE_FONT_SIMULATIONS enum values (0-3) + [Theory] + [InlineData(0, 0)] // None + [InlineData(1, 1)] // Bold + [InlineData(2, 2)] // Oblique + [InlineData(3, 3)] // Bold | Oblique + public void DWriteTypeConverter_Convert_FontSimulations_PreservesNumericValue(int simValue, int expectedNativeValue) + { + var simulations = (FontSimulations)simValue; + int result = (int)DWriteTypeConverter.Convert(simulations); + result.Should().Be(expectedNativeValue, + $"FontSimulations({simValue}) should convert to DWRITE_FONT_SIMULATIONS value {expectedNativeValue}"); + } + + [Fact] + public void DWriteTypeConverter_Convert_FontSimulations_Invalid_ShouldThrow() + { + var invalidSim = (FontSimulations)99; + var act = () => DWriteTypeConverter.Convert(invalidSim); + act.Should().Throw(); + } + + // DWRITE_FONT_FACE_TYPE enum values - mapping from WPF FontFaceType to native DWRITE values + // WPF: CFF=0, TrueType=1, TrueTypeCollection=2, Type1=3, Vector=4, Bitmap=5, Unknown=6 + // Native: Same ordering as WPF (they match) + [Theory] + [InlineData(0, 0)] // CFF -> DWRITE_FONT_FACE_TYPE_CFF + [InlineData(1, 1)] // TrueType -> DWRITE_FONT_FACE_TYPE_TRUETYPE + [InlineData(2, 2)] // TrueTypeCollection -> DWRITE_FONT_FACE_TYPE_TRUETYPE_COLLECTION + [InlineData(3, 3)] // Type1 -> DWRITE_FONT_FACE_TYPE_TYPE1 + [InlineData(4, 4)] // Vector -> DWRITE_FONT_FACE_TYPE_VECTOR + [InlineData(5, 5)] // Bitmap -> DWRITE_FONT_FACE_TYPE_BITMAP + [InlineData(6, 6)] // Unknown -> DWRITE_FONT_FACE_TYPE_UNKNOWN + public void DWriteTypeConverter_Convert_FontFaceType_MapsToCorrectNativeValue(int faceTypeValue, int expectedNativeValue) + { + var faceType = (FontFaceType)faceTypeValue; + int result = (int)DWriteTypeConverter.Convert(faceType); + result.Should().Be(expectedNativeValue, + $"FontFaceType({faceTypeValue}) should convert to DWRITE_FONT_FACE_TYPE value {expectedNativeValue}"); + } + + [Fact] + public void DWriteTypeConverter_Convert_FontFaceType_Invalid_ShouldThrow() + { + var invalidFaceType = (FontFaceType)99; + var act = () => DWriteTypeConverter.Convert(invalidFaceType); + act.Should().Throw(); + } + + // DWRITE_INFORMATIONAL_STRING_ID enum values (0-15) + [Theory] + [InlineData(0, 0)] // None + [InlineData(1, 1)] // CopyrightNotice + [InlineData(2, 2)] // VersionStrings + [InlineData(3, 3)] // Trademark + [InlineData(4, 4)] // Manufacturer + [InlineData(5, 5)] // Designer + [InlineData(6, 6)] // DesignerURL + [InlineData(7, 7)] // Description + [InlineData(8, 8)] // FontVendorURL + [InlineData(9, 9)] // LicenseDescription + [InlineData(10, 10)] // LicenseInfoURL + [InlineData(11, 11)] // WIN32FamilyNames + [InlineData(12, 12)] // Win32SubFamilyNames + [InlineData(13, 13)] // PreferredFamilyNames + [InlineData(14, 14)] // PreferredSubFamilyNames + [InlineData(15, 15)] // SampleText + public void DWriteTypeConverter_Convert_InformationalStringID_PreservesNumericValue(int stringIdValue, int expectedNativeValue) + { + var stringId = (InformationalStringID)stringIdValue; + int result = (int)DWriteTypeConverter.Convert(stringId); + result.Should().Be(expectedNativeValue, + $"InformationalStringID({stringIdValue}) should convert to DWRITE_INFORMATIONAL_STRING_ID value {expectedNativeValue}"); + } + + [Fact] + public void DWriteTypeConverter_Convert_InformationalStringID_Invalid_ShouldThrow() + { + var invalidStringId = (InformationalStringID)99; + var act = () => DWriteTypeConverter.Convert(invalidStringId); + act.Should().Throw(); + } + + [Fact] + public void DWriteTypeConverter_Convert_FontMetrics_ToNative() + { + // Create a FontMetrics object and convert to native + var fontMetrics = new FontMetrics + { + Ascent = 1000, + Descent = 200, + LineGap = 50, + CapHeight = 700, + XHeight = 500, + UnderlinePosition = -100, + UnderlineThickness = 50, + StrikethroughPosition = 300, + StrikethroughThickness = 50, + DesignUnitsPerEm = 2048 + }; + + _ = DWriteTypeConverter.Convert(fontMetrics); + } + + [Fact] + public void DWriteTypeConverter_Convert_DWriteMatrix_ToNative() + { + // Create a DWriteMatrix and convert to native + var matrix = new DWriteMatrix + { + M11 = 1.0f, + M12 = 0.0f, + M21 = 0.0f, + M22 = 1.0f, + Dx = 10.0f, + Dy = 20.0f + }; + + _ = DWriteTypeConverter.Convert(matrix); + } + + [Fact] + public void DWriteTypeConverter_Convert_DWriteMatrix_WithRotation_ToNative() + { + // Test with a rotation matrix + var matrix = new DWriteMatrix + { + M11 = 0.707f, // cos(45) + M12 = 0.707f, // sin(45) + M21 = -0.707f, // -sin(45) + M22 = 0.707f, // cos(45) + Dx = 0.0f, + Dy = 0.0f + }; + + _ = DWriteTypeConverter.Convert(matrix); + } + + [Theory] + [InlineData(0)] // Ideal + [InlineData(1)] // Display + public void DWriteTypeConverter_Convert_TextFormattingMode_ToNative(int modeValue) + { + var mode = (System.Windows.Media.TextFormattingMode)modeValue; + _ = DWriteTypeConverter.Convert(mode); + } + + [Fact] + public void DWriteTypeConverter_Convert_TextFormattingMode_Invalid_ShouldThrow() + { + var invalidMode = (System.Windows.Media.TextFormattingMode)99; + var act = () => DWriteTypeConverter.Convert(invalidMode); + act.Should().Throw(); + } + + #endregion + + #region Native to Managed conversions via Font properties + + // These tests exercise the DWRITE_* to managed conversions by accessing + // font properties that internally call DWriteTypeConverter.Convert(DWRITE_*) + + [Fact] + public void Convert_FontWeight_FromNative_ViaAllSystemFonts() + { + // Iterate ALL system fonts to maximize coverage of different DWRITE_FONT_WEIGHT values + var fontCollection = DWriteFactory.SystemFontCollection; + var weightsFound = new HashSet(); + + // Iterate all families (not just first 50) to maximize coverage + for (uint i = 0; i < fontCollection.FamilyCount; i++) + { + var family = fontCollection[i]; + foreach (var font in family) + { + var weight = font.Weight; + weightsFound.Add((int)weight); + } + } + + // We should find Normal (400) and Bold (700) in system fonts + weightsFound.Should().Contain(400, "System should have Normal weight fonts"); + weightsFound.Should().Contain(700, "System should have Bold weight fonts"); + + // Additional weights typically found on Windows: Light (300), Medium (500), SemiBold (600) + // We don't assert these as they depend on installed fonts, but we exercise all available values + } + + [Fact] + public void Convert_FontStretch_FromNative_ViaAllSystemFonts() + { + // Iterate ALL system fonts to maximize coverage of different DWRITE_FONT_STRETCH values + var fontCollection = DWriteFactory.SystemFontCollection; + var stretchesFound = new HashSet(); + + // Iterate all families (not just first 50) to maximize coverage + for (uint i = 0; i < fontCollection.FamilyCount; i++) + { + var family = fontCollection[i]; + foreach (var font in family) + { + var stretch = font.Stretch; + stretchesFound.Add((int)stretch); + } + } + + // We should find Normal (5) in system fonts + stretchesFound.Should().Contain(5, "System should have Normal stretch fonts"); + + // Other stretches (Condensed=3, SemiCondensed=4, SemiExpanded=6, etc.) depend on installed fonts + } + + [Fact] + public void Convert_FontStyle_FromNative_ViaAllSystemFonts() + { + // Iterate ALL system fonts to maximize coverage of different DWRITE_FONT_STYLE values + var fontCollection = DWriteFactory.SystemFontCollection; + var stylesFound = new HashSet(); + + // Iterate all families (not just first 50) to maximize coverage + for (uint i = 0; i < fontCollection.FamilyCount; i++) + { + var family = fontCollection[i]; + foreach (var font in family) + { + var style = font.Style; + stylesFound.Add((int)style); + } + } + + // We should find Normal (0) and Italic (2) in system fonts + stylesFound.Should().Contain(0, "System should have Normal style fonts"); + stylesFound.Should().Contain(2, "System should have Italic style fonts"); + + // Oblique (1) is rare in system fonts but may be found depending on installed fonts + } + + [Fact] + public void Convert_FontSimulations_FromNative_ViaFontFace() + { + // Test different font simulations via FontFace + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + FontSimulations[] simulations = [FontSimulations.None, FontSimulations.Bold, FontSimulations.Oblique, FontSimulations.Bold | FontSimulations.Oblique]; + + foreach (var sim in simulations) + { + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0, sim); + try + { + // This calls Convert(DWRITE_FONT_SIMULATIONS) + var resultSim = fontFace.SimulationFlags; + resultSim.Should().Be(sim); + } + finally + { + fontFace.Release(); + } + } + } + + [Fact] + public void Convert_FontFaceType_FromNative_ViaVariousFonts() + { + // Test different font face types + var factory = DWriteFactory.Instance; + var fontsPath = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + var typesFound = new HashSet(); + + // TTF files (TrueType) + string[] ttfFiles = ["arial.ttf", "times.ttf", "verdana.ttf", "cour.ttf"]; + foreach (var file in ttfFiles) + { + var path = Path.Combine(fontsPath, file); + if (!File.Exists(path)) continue; + + var fontFace = factory.CreateFontFace(new Uri(path), 0); + try + { + // This calls Convert(DWRITE_FONT_FACE_TYPE) + var faceType = fontFace.Type; + faceType.Should().Be(FontFaceType.TrueType); + typesFound.Add(faceType); + } + finally + { + fontFace.Release(); + } + } + + // TTC file (TrueTypeCollection) + var ttcPath = Path.Combine(fontsPath, "cambria.ttc"); + if (File.Exists(ttcPath)) + { + var fontFace = factory.CreateFontFace(new Uri(ttcPath), 0); + try + { + var faceType = fontFace.Type; + faceType.Should().Be(FontFaceType.TrueTypeCollection); + typesFound.Add(faceType); + } + finally + { + fontFace.Release(); + } + } + + // OTF files (CFF - OpenType with PostScript outlines) + string[] otfFiles = ["calibri.ttf", "consola.ttf", "segoeui.ttf"]; + foreach (var file in otfFiles) + { + var path = Path.Combine(fontsPath, file); + if (!File.Exists(path)) continue; + + var fontFace = factory.CreateFontFace(new Uri(path), 0); + try + { + // Most Windows fonts are TrueType; CFF fonts are less common + var faceType = fontFace.Type; + typesFound.Add(faceType); + } + finally + { + fontFace.Release(); + } + } + + // We should have found at least TrueType + typesFound.Should().Contain(FontFaceType.TrueType, "Should find at least TrueType fonts"); + } + + [Fact] + public void Convert_FontMetrics_FromNative_ViaAllSystemFonts() + { + // Access FontMetrics from ALL system fonts to cover Convert(DWRITE_FONT_METRICS) + var fontCollection = DWriteFactory.SystemFontCollection; + int fontsChecked = 0; + + for (uint i = 0; i < fontCollection.FamilyCount; i++) + { + var family = fontCollection[i]; + if (family.Count == 0) continue; + + var font = family[0u]; + // This calls Convert(DWRITE_FONT_METRICS) + var metrics = font.Metrics; + + metrics.Should().NotBeNull(); + metrics.DesignUnitsPerEm.Should().BeGreaterThan(0); + metrics.Ascent.Should().BeGreaterThan(0); + + fontsChecked++; + } + + fontsChecked.Should().BeGreaterThan(0, "Should have checked at least one font"); + } + + #endregion +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DirectWriteForwarder.Tests.csproj b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DirectWriteForwarder.Tests.csproj new file mode 100644 index 00000000000..06f7ae075f8 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/DirectWriteForwarder.Tests.csproj @@ -0,0 +1,28 @@ + + + true + enable + x64;x86;arm64 + + $(TargetFramework)-windows + true + true + Exe + $(NoWarn);xUnit1025 + + + + + + + + + + + TargetFramework;TargetFrameworks + + + + + + diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/EdgeCaseTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/EdgeCaseTests.cs new file mode 100644 index 00000000000..0b49ef00eb4 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/EdgeCaseTests.cs @@ -0,0 +1,740 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Edge case and error handling tests for DirectWriteForwarder classes. +/// Tests boundary conditions, invalid inputs, and error scenarios. +/// +public class EdgeCaseTests +{ + #region FontCollection Edge Cases + + [Fact] + public void FontCollection_Indexer_WithOutOfRangeIndex_ShouldThrow() + { + var fontCollection = DWriteFactory.SystemFontCollection; + var invalidIndex = fontCollection.FamilyCount + 100u; + + Action act = () => _ = fontCollection[invalidIndex]; + + // DirectWrite throws COMException for out of range indices + act.Should().Throw(); + } + + [Fact] + public void FontCollection_Indexer_WithMaxUInt_ShouldThrow() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + Action act = () => _ = fontCollection[uint.MaxValue]; + + // DirectWrite throws COMException for out of range indices + act.Should().Throw(); + } + + [Fact] + public void FontCollection_Indexer_WithEmptyString_ShouldReturnNull() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + var result = fontCollection[string.Empty]; + + result.Should().BeNull(); + } + + [Fact] + public void FontCollection_FindFamilyName_WithEmptyString_ShouldReturnFalse() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + bool found = fontCollection.FindFamilyName(string.Empty, out _); + + found.Should().BeFalse(); + } + + [Fact] + public void FontCollection_FindFamilyName_WithWhitespace_ShouldReturnFalse() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + bool found = fontCollection.FindFamilyName(" ", out _); + + found.Should().BeFalse(); + } + + [Fact] + public void FontCollection_FindFamilyName_WithSpecialCharacters_ShouldReturnFalse() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + bool found = fontCollection.FindFamilyName("!@#$%^&*()", out _); + + found.Should().BeFalse(); + } + + [Fact] + public void FontCollection_FindFamilyName_WithVeryLongName_ShouldReturnFalse() + { + var fontCollection = DWriteFactory.SystemFontCollection; + var veryLongName = new string('A', 10000); + + bool found = fontCollection.FindFamilyName(veryLongName, out _); + + found.Should().BeFalse(); + } + + [Fact] + public void FontCollection_FindFamilyName_CaseSensitivity_ShouldBeInsensitive() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + bool foundUpper = fontCollection.FindFamilyName("ARIAL", out uint indexUpper); + bool foundLower = fontCollection.FindFamilyName("arial", out uint indexLower); + bool foundMixed = fontCollection.FindFamilyName("ArIaL", out uint indexMixed); + + // All should find the same font (case-insensitive matching) + if (foundUpper && foundLower && foundMixed) + { + indexUpper.Should().Be(indexLower); + indexLower.Should().Be(indexMixed); + } + } + + #endregion + + #region FontFamily Edge Cases + + [Fact] + public void FontFamily_Indexer_WithOutOfRangeIndex_ShouldThrow() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var invalidIndex = family.Count + 100; + + Action act = () => _ = family[(uint)invalidIndex]; + + // DirectWrite throws COMException for out of range indices + act.Should().Throw(); + } + + [Fact] + public void FontFamily_GetFirstMatchingFont_WithExtremeWeight_ShouldReturnClosestMatch() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + // Request a weight that likely doesn't exist (e.g., 950) + var font = family.GetFirstMatchingFont((FontWeight)950, FontStretch.Normal, FontStyle.Normal); + + font.Should().NotBeNull("Should return closest matching font"); + } + + [Fact] + public void FontFamily_GetFirstMatchingFont_WithMinWeight_ShouldReturnFont() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var font = family.GetFirstMatchingFont((FontWeight)1, FontStretch.Normal, FontStyle.Normal); + + font.Should().NotBeNull("Should return closest matching font for minimum weight"); + } + + [Fact] + public void FontFamily_DisplayMetrics_WithZeroEmSize_ShouldThrowArgumentException() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + // Zero emSize is rejected by DirectWrite + Action act = () => family.DisplayMetrics(emSize: 0.0f, pixelsPerDip: 1.0f); + + act.Should().Throw(); + } + + [Fact] + public void FontFamily_DisplayMetrics_WithNegativeEmSize_ShouldThrowArgumentException() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + // Negative emSize is rejected by DirectWrite + Action act = () => family.DisplayMetrics(emSize: -12.0f, pixelsPerDip: 1.0f); + + act.Should().Throw(); + } + + [Fact] + public void FontFamily_DisplayMetrics_WithVeryLargeEmSize_ShouldReturnMetrics() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var metrics = family.DisplayMetrics(emSize: 10000.0f, pixelsPerDip: 1.0f); + + metrics.Should().NotBeNull(); + } + + [Fact] + public void FontFamily_DisplayMetrics_WithVerySmallPixelsPerDip_ShouldReturnMetrics() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var metrics = family.DisplayMetrics(emSize: 12.0f, pixelsPerDip: 0.001f); + + metrics.Should().NotBeNull(); + } + + #endregion + + #region Factory Edge Cases + + [Fact] + public void Factory_CreateFontFace_WithNonExistentFile_ShouldThrow() + { + var factory = DWriteFactory.Instance; + var nonExistentPath = new Uri("file:///C:/NonExistent/totally_fake_font_12345.ttf"); + + Action act = () => factory.CreateFontFace(nonExistentPath, 0); + + act.Should().Throw(); + } + + [Fact] + public void Factory_CreateFontFace_WithNegativeFaceIndex_ShouldThrow() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + // The API takes uint, so we can't pass negative directly, but we can test boundary + Action act = () => factory.CreateFontFace(new Uri(arialPath), 999); + + act.Should().Throw(); + } + + [Fact] + public void Factory_IsLocalUri_WithFtpUri_ReturnsFalse() + { + var ftpUri = new Uri("ftp://example.com/fonts/myfont.ttf"); + + Factory.IsLocalUri(ftpUri).Should().BeFalse(); + } + + [Fact] + public void Factory_IsLocalUri_WithHttpsUri_ReturnsFalse() + { + var httpsUri = new Uri("https://example.com/fonts/myfont.ttf"); + + Factory.IsLocalUri(httpsUri).Should().BeFalse(); + } + + #endregion + + #region FontFace Edge Cases + + [Fact] + public void FontFace_TryGetFontTable_WithInvalidTableTag_ShouldReturnFalse() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + // Use a table tag that doesn't exist (custom/invalid tag) + bool found = fontFace.TryGetFontTable((OpenTypeTableTag)0x58585858, out byte[]? tableData); // 'XXXX' + + found.Should().BeFalse(); + tableData.Should().BeNull(); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void FontFace_GetArrayOfGlyphIndices_WithZeroCount_ShouldNotThrow() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + uint[] codePoints = [65]; + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + // Zero count should be handled gracefully + fontFace.GetArrayOfGlyphIndices(pCodePoints, 0, pGlyphIndices); + } + + // No exception means success + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void FontFace_GetDesignGlyphMetrics_WithZeroGlyphIndex_ShouldReturnMetrics() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + // Glyph index 0 is typically .notdef + ushort[] glyphIndices = [0]; + GlyphMetrics[] metrics = new GlyphMetrics[1]; + + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + fontFace.GetDesignGlyphMetrics(pGlyphIndices, 1, pMetrics); + } + + // .notdef glyph should still have metrics + metrics[0].Should().NotBeNull(); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void FontFace_GetDisplayGlyphMetrics_WithZeroEmSize_ShouldThrowArgumentException() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + uint[] codePoints = [65]; // 'A' + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + GlyphMetrics[] metrics = new GlyphMetrics[1]; + + // Zero emSize should throw ArgumentException + Action act = () => + { + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + fontFace.GetDisplayGlyphMetrics( + pGlyphIndices, + 1, + pMetrics, + emSize: 0.0f, + useDisplayNatural: false, + isSideways: false, + pixelsPerDip: 1.0f); + } + }; + + act.Should().Throw(); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void FontFace_GetDisplayGlyphMetrics_WithSideways_ShouldReturnMetrics() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + uint[] codePoints = [65]; // 'A' + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + GlyphMetrics[] metrics = new GlyphMetrics[1]; + + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + // isSideways = true for vertical text layout + fontFace.GetDisplayGlyphMetrics( + pGlyphIndices, + 1, + pMetrics, + emSize: 12.0f, + useDisplayNatural: false, + isSideways: true, + pixelsPerDip: 1.0f); + } + + metrics[0].AdvanceWidth.Should().BeGreaterThan(0); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void FontFace_GetDisplayGlyphMetrics_WithUseDisplayNatural_ShouldReturnMetrics() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + uint[] codePoints = [65]; // 'A' + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + GlyphMetrics[] metrics = new GlyphMetrics[1]; + + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + // useDisplayNatural = true + fontFace.GetDisplayGlyphMetrics( + pGlyphIndices, + 1, + pMetrics, + emSize: 12.0f, + useDisplayNatural: true, + isSideways: false, + pixelsPerDip: 1.0f); + } + + metrics[0].AdvanceWidth.Should().BeGreaterThan(0); + } + finally + { + fontFace.Release(); + } + } + + #endregion + + #region Font Edge Cases + + [Fact] + public void Font_HasCharacter_WithZeroCodePoint_ShouldReturnFalse() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // Code point 0 (null character) + font.HasCharacter(0).Should().BeFalse(); + } + + [Fact] + public void Font_HasCharacter_WithMaxCodePoint_ShouldReturnFalse() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // Max Unicode code point + font.HasCharacter(0x10FFFF).Should().BeFalse(); + } + + [Fact] + public void Font_HasCharacter_WithSurrogateCodePoint_ShouldReturnFalse() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // Surrogate code points are invalid + font.HasCharacter(0xD800).Should().BeFalse(); + font.HasCharacter(0xDFFF).Should().BeFalse(); + } + + [Fact] + public void Font_HasCharacter_WithPrivateUseArea_ShouldReturnResult() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // Private Use Area (U+E000 to U+F8FF) + // Result depends on font, but should not throw + _ = font.HasCharacter(0xE000); + } + + [Fact] + public void Font_GetInformationalStrings_AllStringTypes_ShouldNotThrow() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // Iterate through all InformationalStringID values + foreach (InformationalStringID stringId in Enum.GetValues(typeof(InformationalStringID))) + { + // Should not throw for any valid enum value + _ = font.GetInformationalStrings(stringId, out _); + } + } + + [Fact] + public void Font_DisplayMetrics_WithExtremeValues_ShouldNotThrow() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // Very large values + var metrics1 = font.DisplayMetrics(float.MaxValue / 2, 96.0f); + metrics1.Should().NotBeNull(); + + // Very small values + var metrics2 = font.DisplayMetrics(0.001f, 0.001f); + metrics2.Should().NotBeNull(); + } + + #endregion + + #region FontMetrics Struct Edge Cases + + [Fact] + public void FontMetrics_WithZeroDesignUnitsPerEm_Baseline_ReturnsInfinityOrNaN() + { + var metrics = new FontMetrics + { + DesignUnitsPerEm = 0, + Ascent = 1000, + Descent = 500, + LineGap = 100 + }; + + // When DesignUnitsPerEm is 0, Baseline calculation involves division by zero + // Result should be infinity or NaN + var baseline = metrics.Baseline; + (double.IsInfinity(baseline) || double.IsNaN(baseline)).Should().BeTrue(); + } + + [Fact] + public void FontMetrics_LineSpacing_WithTypicalValues_ShouldCalculateCorrectly() + { + var metrics = new FontMetrics + { + DesignUnitsPerEm = 2048, + Ascent = 1854, + Descent = 434, + LineGap = 67 + }; + + // Get line spacing + var lineSpacing = metrics.LineSpacing; + + lineSpacing.Should().BeGreaterThan(0); + } + + [Fact] + public void FontMetrics_Baseline_ShouldBeConsistentWithFormula() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var metrics = family.Metrics; + + // Baseline = (Ascent + LineGap * 0.5) / DesignUnitsPerEm + double expectedBaseline = (metrics.Ascent + metrics.LineGap * 0.5) / metrics.DesignUnitsPerEm; + + metrics.Baseline.Should().BeApproximately(expectedBaseline, 0.0001); + } + + #endregion + + #region GlyphMetrics Struct Edge Cases + + [Fact] + public void GlyphMetrics_DefaultValues_ShouldBeZero() + { + var metrics = new GlyphMetrics(); + + metrics.AdvanceWidth.Should().Be(0); + metrics.AdvanceHeight.Should().Be(0); + metrics.LeftSideBearing.Should().Be(0); + metrics.RightSideBearing.Should().Be(0); + metrics.TopSideBearing.Should().Be(0); + metrics.BottomSideBearing.Should().Be(0); + metrics.VerticalOriginY.Should().Be(0); + } + + [Fact] + public void GlyphMetrics_NegativeSideBearings_AreValid() + { + // Some glyphs have negative side bearings (e.g., italic 'f') + var metrics = new GlyphMetrics + { + AdvanceWidth = 500, + LeftSideBearing = -50, + RightSideBearing = -30 + }; + + metrics.LeftSideBearing.Should().Be(-50); + metrics.RightSideBearing.Should().Be(-30); + } + + #endregion + + #region DWriteMatrix Struct Edge Cases + + [Fact] + public void DWriteMatrix_DefaultValues_ShouldBeZero() + { + var matrix = new DWriteMatrix(); + + matrix.M11.Should().Be(0.0f); + matrix.M12.Should().Be(0.0f); + matrix.M21.Should().Be(0.0f); + matrix.M22.Should().Be(0.0f); + matrix.Dx.Should().Be(0.0f); + matrix.Dy.Should().Be(0.0f); + } + + [Fact] + public void DWriteMatrix_WithExtremeValues_ShouldStore() + { + var matrix = new DWriteMatrix + { + M11 = float.MaxValue, + M12 = float.MinValue, + M21 = float.Epsilon, + M22 = float.NegativeInfinity, + Dx = float.PositiveInfinity, + Dy = float.NaN + }; + + matrix.M11.Should().Be(float.MaxValue); + matrix.M12.Should().Be(float.MinValue); + matrix.M21.Should().Be(float.Epsilon); + matrix.M22.Should().Be(float.NegativeInfinity); + matrix.Dx.Should().Be(float.PositiveInfinity); + float.IsNaN(matrix.Dy).Should().BeTrue(); + } + + #endregion + + #region LocalizedStrings Edge Cases + + [Fact] + public void LocalizedStrings_NonExistentString_GetInformationalStrings_ReturnsFalse() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // Try to get a string that might not exist (SampleText is often not present) + bool exists = font.GetInformationalStrings(InformationalStringID.SampleText, out var localizedStrings); + + // If the string doesn't exist, exists should be false + // Just verify the call doesn't throw + if (exists) + { + localizedStrings.Should().NotBeNull(); + } + } + + [Fact] + public void LocalizedStrings_Enumeration_ShouldNotThrow() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var familyNames = family.FamilyNames; + + // Enumerate should not throw + foreach (var kvp in familyNames) + { + kvp.Key.Should().NotBeNull(); + kvp.Value.Should().NotBeNull(); + } + } + + #endregion + + #region Unicode Boundary Tests + + [Fact] + public void Font_HasCharacter_WithBMPBoundary_ShouldWork() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + + var font = family[0u]; + + // BMP boundary (U+FFFF) + _ = font.HasCharacter(0xFFFF); + + // Just above BMP (U+10000) - Supplementary Multilingual Plane + _ = font.HasCharacter(0x10000); + } + + [Fact] + public unsafe void FontFace_GetArrayOfGlyphIndices_WithSupplementaryPlaneCharacters_ShouldWork() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + try + { + // Supplementary plane characters (emoji, historic scripts, etc.) + uint[] codePoints = [0x1F600, 0x1F4A9, 0x1D11E]; // Emoji and musical symbol + ushort[] glyphIndices = new ushort[codePoints.Length]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, (uint)codePoints.Length, pGlyphIndices); + } + + // These are likely not in Arial, so should be 0 (.notdef) + // But the call should not throw + } + finally + { + fontFace.Release(); + } + } + + #endregion +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/EnumTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/EnumTests.cs new file mode 100644 index 00000000000..98e49c9ddc0 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/EnumTests.cs @@ -0,0 +1,521 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for the FactoryType enum. +/// +public class FactoryTypeTests +{ + [Fact] + public void FactoryType_HasExpectedValues() + { + // FactoryType should have Shared and Isolated values + FactoryType.Shared.Should().BeDefined(); + FactoryType.Isolated.Should().BeDefined(); + } + + [Fact] + public void FactoryType_Shared_IsDefaultValue() + { + // Shared should be 0 (first enum value) + ((int)FactoryType.Shared).Should().Be(0); + } + + [Fact] + public void FactoryType_Isolated_IsOne() + { + ((int)FactoryType.Isolated).Should().Be(1); + } + + [Fact] + public void FactoryType_HasOnlyTwoValues() + { + var values = Enum.GetValues(); + values.Should().HaveCount(2); + } +} + +/// +/// Tests for the FontStyle enum. +/// +public class FontStyleTests +{ + [Fact] + public void FontStyle_Normal_IsZero() + { + ((int)FontStyle.Normal).Should().Be(0); + } + + [Fact] + public void FontStyle_Oblique_IsOne() + { + ((int)FontStyle.Oblique).Should().Be(1); + } + + [Fact] + public void FontStyle_Italic_IsTwo() + { + ((int)FontStyle.Italic).Should().Be(2); + } + + [Fact] + public void FontStyle_HasThreeValues() + { + var values = Enum.GetValues(); + values.Should().HaveCount(3); + } + + [Theory] + [InlineData(FontStyle.Normal)] + [InlineData(FontStyle.Oblique)] + [InlineData(FontStyle.Italic)] + public void FontStyle_AllValues_AreDefined(object style) + { + Enum.IsDefined(typeof(FontStyle), style).Should().BeTrue(); + } +} + +/// +/// Tests for the FontSimulations flags enum. +/// +public class FontSimulationsTests +{ + [Fact] + public void FontSimulations_None_IsZero() + { + ((int)FontSimulations.None).Should().Be(0); + } + + [Fact] + public void FontSimulations_Bold_IsOne() + { + ((int)FontSimulations.Bold).Should().Be(1); + } + + [Fact] + public void FontSimulations_Oblique_IsTwo() + { + ((int)FontSimulations.Oblique).Should().Be(2); + } + + [Fact] + public void FontSimulations_CanCombineBoldAndOblique() + { + // FontSimulations is a flags enum, so Bold | Oblique should be valid + var combined = FontSimulations.Bold | FontSimulations.Oblique; + ((int)combined).Should().Be(3); + } + + [Fact] + public void FontSimulations_HasFlagsAttribute() + { + typeof(FontSimulations).GetCustomAttributes(typeof(FlagsAttribute), false) + .Should().NotBeEmpty(); + } + + [Fact] + public void FontSimulations_CombinedValue_ContainsBothFlags() + { + var combined = FontSimulations.Bold | FontSimulations.Oblique; + + combined.HasFlag(FontSimulations.Bold).Should().BeTrue(); + combined.HasFlag(FontSimulations.Oblique).Should().BeTrue(); + combined.HasFlag(FontSimulations.None).Should().BeTrue(); // None (0) is always contained + } +} + +/// +/// Tests for the FontWeight enum. +/// +public class FontWeightTests +{ + [Theory] + [InlineData(FontWeight.Thin, 100)] + [InlineData(FontWeight.ExtraLight, 200)] + [InlineData(FontWeight.UltraLight, 200)] + [InlineData(FontWeight.Light, 300)] + [InlineData(FontWeight.Normal, 400)] + [InlineData(FontWeight.Regular, 400)] + [InlineData(FontWeight.Medium, 500)] + [InlineData(FontWeight.DemiBold, 600)] + [InlineData(FontWeight.SemiBOLD, 600)] + [InlineData(FontWeight.Bold, 700)] + [InlineData(FontWeight.ExtraBold, 800)] + [InlineData(FontWeight.UltraBold, 800)] + [InlineData(FontWeight.Black, 900)] + [InlineData(FontWeight.Heavy, 900)] + [InlineData(FontWeight.ExtraBlack, 950)] + [InlineData(FontWeight.UltraBlack, 950)] + public void FontWeight_HasExpectedValues(object weight, int expectedValue) + { + ((int)weight).Should().Be(expectedValue); + } + + [Fact] + public void FontWeight_ExtraLightAndUltraLight_AreEqual() + { + // These are aliases with the same value + FontWeight.ExtraLight.Should().Be(FontWeight.UltraLight); + } + + [Fact] + public void FontWeight_NormalAndRegular_AreEqual() + { + FontWeight.Normal.Should().Be(FontWeight.Regular); + } + + [Fact] + public void FontWeight_DemiBoldAndSemiBold_AreEqual() + { + FontWeight.DemiBold.Should().Be(FontWeight.SemiBOLD); + } + + [Fact] + public void FontWeight_BlackAndHeavy_AreEqual() + { + FontWeight.Black.Should().Be(FontWeight.Heavy); + } +} + +/// +/// Tests for the FontStretch enum. +/// +public class FontStretchTests +{ + [Theory] + [InlineData(FontStretch.Undefined, 0)] + [InlineData(FontStretch.UltraCondensed, 1)] + [InlineData(FontStretch.ExtraCondensed, 2)] + [InlineData(FontStretch.Condensed, 3)] + [InlineData(FontStretch.SemiCondensed, 4)] + [InlineData(FontStretch.Normal, 5)] + [InlineData(FontStretch.Medium, 5)] + [InlineData(FontStretch.SemiExpanded, 6)] + [InlineData(FontStretch.Expanded, 7)] + [InlineData(FontStretch.ExtraExpanded, 8)] + [InlineData(FontStretch.UltraExpanded, 9)] + public void FontStretch_HasExpectedValues(object stretch, int expectedValue) + { + ((int)stretch).Should().Be(expectedValue); + } + + [Fact] + public void FontStretch_NormalAndMedium_AreEqual() + { + FontStretch.Normal.Should().Be(FontStretch.Medium); + } + + [Fact] + public void FontStretch_ValuesAreSequential() + { + // Values should go from 0 (Undefined) to 9 (UltraExpanded) + ((int)FontStretch.Undefined).Should().Be(0); + ((int)FontStretch.UltraExpanded).Should().Be(9); + } +} + +/// +/// Tests for the FontFaceType enum. +/// +public class FontFaceTypeTests +{ + [Fact] + public void FontFaceType_HasExpectedValues() + { + FontFaceType.CFF.Should().BeDefined(); + FontFaceType.TrueType.Should().BeDefined(); + FontFaceType.TrueTypeCollection.Should().BeDefined(); + FontFaceType.Type1.Should().BeDefined(); + FontFaceType.Vector.Should().BeDefined(); + FontFaceType.Bitmap.Should().BeDefined(); + FontFaceType.Unknown.Should().BeDefined(); + } + + [Theory] + [InlineData(FontFaceType.CFF, 0)] + [InlineData(FontFaceType.TrueType, 1)] + [InlineData(FontFaceType.TrueTypeCollection, 2)] + [InlineData(FontFaceType.Type1, 3)] + [InlineData(FontFaceType.Vector, 4)] + [InlineData(FontFaceType.Bitmap, 5)] + [InlineData(FontFaceType.Unknown, 6)] + public void FontFaceType_HasSequentialValues(object faceType, int expectedValue) + { + ((int)faceType).Should().Be(expectedValue); + } +} + +/// +/// Tests for the FontFileType enum. +/// +public class FontFileTypeTests +{ + [Fact] + public void FontFileType_HasExpectedValues() + { + FontFileType.Unknown.Should().BeDefined(); + FontFileType.CFF.Should().BeDefined(); + FontFileType.TrueType.Should().BeDefined(); + FontFileType.TrueTypeCollection.Should().BeDefined(); + FontFileType.Type1PFM.Should().BeDefined(); + FontFileType.Type1PFB.Should().BeDefined(); + FontFileType.Vector.Should().BeDefined(); + FontFileType.Bitmap.Should().BeDefined(); + } + + [Theory] + [InlineData(FontFileType.Unknown, 0)] + [InlineData(FontFileType.CFF, 1)] + [InlineData(FontFileType.TrueType, 2)] + [InlineData(FontFileType.TrueTypeCollection, 3)] + [InlineData(FontFileType.Type1PFM, 4)] + [InlineData(FontFileType.Type1PFB, 5)] + [InlineData(FontFileType.Vector, 6)] + [InlineData(FontFileType.Bitmap, 7)] + public void FontFileType_HasSequentialValues(object fileType, int expectedValue) + { + ((int)fileType).Should().Be(expectedValue); + } +} + +/// +/// Tests for the InformationalStringID enum. +/// +public class InformationalStringIDTests +{ + [Fact] + public void InformationalStringID_None_IsZero() + { + ((int)InformationalStringID.None).Should().Be(0); + } + + [Fact] + public void InformationalStringID_HasAllExpectedValues() + { + InformationalStringID.None.Should().BeDefined(); + InformationalStringID.CopyrightNotice.Should().BeDefined(); + InformationalStringID.VersionStrings.Should().BeDefined(); + InformationalStringID.Trademark.Should().BeDefined(); + InformationalStringID.Manufacturer.Should().BeDefined(); + InformationalStringID.Designer.Should().BeDefined(); + InformationalStringID.DesignerURL.Should().BeDefined(); + InformationalStringID.Description.Should().BeDefined(); + InformationalStringID.FontVendorURL.Should().BeDefined(); + InformationalStringID.LicenseDescription.Should().BeDefined(); + InformationalStringID.LicenseInfoURL.Should().BeDefined(); + InformationalStringID.WIN32FamilyNames.Should().BeDefined(); + InformationalStringID.Win32SubFamilyNames.Should().BeDefined(); + InformationalStringID.PreferredFamilyNames.Should().BeDefined(); + InformationalStringID.PreferredSubFamilyNames.Should().BeDefined(); + InformationalStringID.SampleText.Should().BeDefined(); + } + + [Fact] + public void InformationalStringID_ValuesAreSequential() + { + var values = Enum.GetValues(); + + // Should have 16 values (0-15) + values.Should().HaveCount(16); + + // Values should be sequential from 0 to 15 + for (int i = 0; i < values.Length; i++) + { + ((int)values[i]).Should().Be(i); + } + } +} + +/// +/// Tests for the OpenTypeTableTag enum. +/// +public class OpenTypeTableTagTests +{ + [Fact] + public void OpenTypeTableTag_HasExpectedTableTags() + { + // Core tables + OpenTypeTableTag.CharToIndexMap.Should().BeDefined(); // cmap + OpenTypeTableTag.FontHeader.Should().BeDefined(); // head + OpenTypeTableTag.HoriHeader.Should().BeDefined(); // hhea + OpenTypeTableTag.HorizontalMetrics.Should().BeDefined(); // hmtx + OpenTypeTableTag.ControlValue.Should().BeDefined(); // cvt + OpenTypeTableTag.FontProgram.Should().BeDefined(); // fpgm + OpenTypeTableTag.MaxProfile.Should().BeDefined(); // maxp + OpenTypeTableTag.NamingTable.Should().BeDefined(); // name + OpenTypeTableTag.IndexToLoc.Should().BeDefined(); // loca + OpenTypeTableTag.GlyphData.Should().BeDefined(); // glyf + } + + [Fact] + public void OpenTypeTableTag_HasOpenTypeLayoutTables() + { + // OpenType layout tables + OpenTypeTableTag.TTO_GSUB.Should().BeDefined(); + OpenTypeTableTag.TTO_GPOS.Should().BeDefined(); + OpenTypeTableTag.TTO_GDEF.Should().BeDefined(); + } + + [Fact] + public void OpenTypeTableTag_HasOS2Table() + { + // OS/2 table (Windows-specific metrics) + OpenTypeTableTag.OS_2.Should().BeDefined(); + } + + [Fact] + public void OpenTypeTableTag_ValuesAreOpenTypeTags() + { + // OpenType table tags are 4-byte ASCII identifiers + // 'head' = 0x68656164 + var headTag = (uint)OpenTypeTableTag.FontHeader; + var headBytes = BitConverter.GetBytes(headTag); + + // Should decode to 'head' (little-endian on Windows) + var tagString = System.Text.Encoding.ASCII.GetString(headBytes); + tagString.Should().Be("head"); + } + + [Fact] + public void OpenTypeTableTag_CmapDecodesToCorrectString() + { + var tagBytes = BitConverter.GetBytes((uint)OpenTypeTableTag.CharToIndexMap); + var tagString = System.Text.Encoding.ASCII.GetString(tagBytes); + tagString.Should().Be("cmap"); + } + + [Fact] + public void OpenTypeTableTag_GsubDecodesToCorrectString() + { + var tagBytes = BitConverter.GetBytes((uint)OpenTypeTableTag.TTO_GSUB); + var tagString = System.Text.Encoding.ASCII.GetString(tagBytes); + tagString.Should().Be("GSUB"); + } + + [Fact] + public void OpenTypeTableTag_NameDecodesToCorrectString() + { + var tagBytes = BitConverter.GetBytes((uint)OpenTypeTableTag.NamingTable); + var tagString = System.Text.Encoding.ASCII.GetString(tagBytes); + tagString.Should().Be("name"); + } +} + +/// +/// Tests for the DWriteFontFeatureTag enum. +/// +public class DWriteFontFeatureTagTests +{ + [Fact] + public void DWriteFontFeatureTag_HasCommonFeatures() + { + // Common OpenType features + DWriteFontFeatureTag.Kerning.Should().BeDefined(); // kern + DWriteFontFeatureTag.StandardLigatures.Should().BeDefined(); // liga + DWriteFontFeatureTag.SmallCapitals.Should().BeDefined(); // smcp + DWriteFontFeatureTag.Fractions.Should().BeDefined(); // frac + DWriteFontFeatureTag.SlashedZero.Should().BeDefined(); // zero + } + + [Fact] + public void DWriteFontFeatureTag_HasStylisticSets() + { + // Stylistic sets ss01-ss20 + DWriteFontFeatureTag.StylisticSet1.Should().BeDefined(); + DWriteFontFeatureTag.StylisticSet10.Should().BeDefined(); + DWriteFontFeatureTag.StylisticSet20.Should().BeDefined(); + } + + [Fact] + public void DWriteFontFeatureTag_HasPositioningFeatures() + { + DWriteFontFeatureTag.Superscript.Should().BeDefined(); // sups + DWriteFontFeatureTag.Subscript.Should().BeDefined(); // subs + DWriteFontFeatureTag.Ordinals.Should().BeDefined(); // ordn + } + + [Fact] + public void DWriteFontFeatureTag_KerningDecodesToCorrectString() + { + var tagBytes = BitConverter.GetBytes((uint)DWriteFontFeatureTag.Kerning); + var tagString = System.Text.Encoding.ASCII.GetString(tagBytes); + tagString.Should().Be("kern"); + } + + [Fact] + public void DWriteFontFeatureTag_LigaDecodesToCorrectString() + { + var tagBytes = BitConverter.GetBytes((uint)DWriteFontFeatureTag.StandardLigatures); + var tagString = System.Text.Encoding.ASCII.GetString(tagBytes); + tagString.Should().Be("liga"); + } + + [Fact] + public void DWriteFontFeatureTag_SmcpDecodesToCorrectString() + { + var tagBytes = BitConverter.GetBytes((uint)DWriteFontFeatureTag.SmallCapitals); + var tagString = System.Text.Encoding.ASCII.GetString(tagBytes); + tagString.Should().Be("smcp"); + } +} + +/// +/// Tests for the GlyphOffset struct. +/// +public class GlyphOffsetTests +{ + [Fact] + public void GlyphOffset_DefaultValues_AreZero() + { + var offset = new GlyphOffset(); + + offset.du.Should().Be(0); + offset.dv.Should().Be(0); + } + + [Fact] + public void GlyphOffset_CanSetValues() + { + var offset = new GlyphOffset + { + du = 10, + dv = -5 + }; + + offset.du.Should().Be(10); + offset.dv.Should().Be(-5); + } +} + +/// +/// Tests for the DWriteFontFeature struct. +/// +public class DWriteFontFeatureTests +{ + [Fact] + public void DWriteFontFeature_Constructor_SetsProperties() + { + var feature = new DWriteFontFeature(DWriteFontFeatureTag.Kerning, 1); + + feature.nameTag.Should().Be(DWriteFontFeatureTag.Kerning); + feature.parameter.Should().Be(1); + } + + [Fact] + public void DWriteFontFeature_DisabledFeature_HasZeroParameter() + { + var feature = new DWriteFontFeature(DWriteFontFeatureTag.StandardLigatures, 0); + + feature.parameter.Should().Be(0); + } + + [Fact] + public void DWriteFontFeature_EnabledFeature_HasNonZeroParameter() + { + var feature = new DWriteFontFeature(DWriteFontFeatureTag.SmallCapitals, 1); + + feature.parameter.Should().BeGreaterThan(0); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FactoryTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FactoryTests.cs new file mode 100644 index 00000000000..f5964b6a426 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FactoryTests.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for class. +/// +public class FactoryTests +{ + [Fact] + public void Instance_ShouldNotBeNull() + { + var factory = DWriteFactory.Instance; + factory.Should().NotBeNull(); + } + + [Fact] + public void GetSystemFontCollection_ShouldReturnNonEmptyCollection() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + fontCollection.Should().NotBeNull(); + fontCollection.FamilyCount.Should().BeGreaterThan(0); + } + + [Fact] + public void GetSystemFontCollection_CalledMultipleTimes_ReturnsSameInstance() + { + var collection1 = DWriteFactory.SystemFontCollection; + var collection2 = DWriteFactory.SystemFontCollection; + + // Should be cached + collection1.Should().BeSameAs(collection2); + } + + [Fact] + public void IsLocalUri_WithFileUri_ReturnsTrue() + { + var localUri = new Uri("file:///C:/Windows/Fonts/arial.ttf"); + + Factory.IsLocalUri(localUri).Should().BeTrue(); + } + + [Fact] + public void IsLocalUri_WithHttpUri_ReturnsFalse() + { + var httpUri = new Uri("http://example.com/font.ttf"); + + Factory.IsLocalUri(httpUri).Should().BeFalse(); + } + + [Fact] + public void IsLocalUri_WithUncPath_ReturnsFalse() + { + var uncUri = new Uri("file://server/share/font.ttf"); + + Factory.IsLocalUri(uncUri).Should().BeFalse(); + } + + [Fact] + public void CreateTextAnalyzer_ShouldReturnNonNull() + { + var factory = DWriteFactory.Instance; + + var textAnalyzer = factory.CreateTextAnalyzer(); + + textAnalyzer.Should().NotBeNull(); + } + + [Fact] + public void CreateFontFile_WithSystemFont_ShouldSucceed() + { + var factory = DWriteFactory.Instance; + var arialPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf"); + + if (File.Exists(arialPath)) + { + var fontFile = factory.CreateFontFile(new Uri(arialPath)); + fontFile.Should().NotBeNull(); + } + } + + [Fact] + public void CreateFontFace_WithSystemFont_ShouldSucceed() + { + var factory = DWriteFactory.Instance; + var arialPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf"); + + if (File.Exists(arialPath)) + { + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0); + fontFace.Should().NotBeNull(); + } + } + + [Fact] + public void CreateFontFace_WithSimulations_ShouldSucceed() + { + var factory = DWriteFactory.Instance; + var arialPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf"); + + if (File.Exists(arialPath)) + { + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0, FontSimulations.Bold); + fontFace.Should().NotBeNull(); + fontFace.SimulationFlags.Should().Be(FontSimulations.Bold); + } + } + + [Fact] + public void CreateFontFile_WithNonExistentFile_ShouldThrow() + { + var factory = DWriteFactory.Instance; + var nonExistentPath = new Uri("file:///C:/NonExistent/fake_font.ttf"); + + Action act = () => factory.CreateFontFile(nonExistentPath); + + act.Should().Throw(); + } + + [Fact] + public void CreateFontFace_WithInvalidFaceIndex_ShouldThrow() + { + var factory = DWriteFactory.Instance; + var arialPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "arial.ttf"); + + if (File.Exists(arialPath)) + { + // Arial.ttf typically has only one face (index 0), so index 999 should fail + Action act = () => factory.CreateFontFace(new Uri(arialPath), 999); + + act.Should().Throw(); + } + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontCollectionTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontCollectionTests.cs new file mode 100644 index 00000000000..66b87d088da --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontCollectionTests.cs @@ -0,0 +1,265 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for class. +/// +public class FontCollectionTests +{ + [Fact] + public void FamilyCount_ShouldBeGreaterThanZero() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + fontCollection.FamilyCount.Should().BeGreaterThan(0); + } + + [Fact] + public void Indexer_WithValidIndex_ShouldReturnFontFamily() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + var firstFamily = fontCollection[0u]; + + firstFamily.Should().NotBeNull(); + } + + [Fact] + public void Indexer_WithFamilyName_ShouldReturnFontFamily() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + // Arial should exist on all Windows systems + var arialFamily = fontCollection["Arial"]; + + arialFamily.Should().NotBeNull(); + } + + [Fact] + public void Indexer_WithInvalidFamilyName_ShouldReturnNull() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + var nonExistentFamily = fontCollection["NonExistentFontFamily12345"]; + + nonExistentFamily.Should().BeNull(); + } + + [Fact] + public void FindFamilyName_WithExistingFamily_ShouldReturnTrueAndIndex() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + bool found = fontCollection.FindFamilyName("Arial", out uint index); + + found.Should().BeTrue(); + index.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public void FindFamilyName_WithNonExistingFamily_ShouldReturnFalse() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + bool found = fontCollection.FindFamilyName("NonExistentFontFamily12345", out _); + + found.Should().BeFalse(); + } + + [Fact] + public void AllFamilies_ShouldBeAccessible() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + for (uint i = 0; i < Math.Min(fontCollection.FamilyCount, 10u); i++) + { + var family = fontCollection[i]; + family.Should().NotBeNull(); + } + } + + [Fact] + public void CommonSystemFonts_ShouldExist() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + // These fonts should exist on all Windows installations + var commonFonts = new[] { "Arial", "Times New Roman", "Courier New", "Segoe UI" }; + + foreach (var fontName in commonFonts) + { + bool found = fontCollection.FindFamilyName(fontName, out _); + // At least some of these should exist + if (found) + { + var family = fontCollection[fontName]; + family.Should().NotBeNull($"{fontName} should be accessible"); + } + } + } +} + +/// +/// Tests for class. +/// +public class FontFamilyTests +{ + [Fact] + public void FamilyNames_ShouldNotBeEmpty() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var familyNames = family.FamilyNames; + + familyNames.Should().NotBeNull(); + familyNames.Count.Should().BeGreaterThan(0); + } + + [Fact] + public void IsPhysical_ShouldBeTrue() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + family.IsPhysical.Should().BeTrue(); + } + + [Fact] + public void IsComposite_ShouldBeFalse() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + family.IsComposite.Should().BeFalse(); + } + + [Fact] + public void Count_ShouldBeGreaterThanZero() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + family.Count.Should().BeGreaterThan(0); + } + + [Fact] + public void Indexer_ShouldReturnFont() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var firstFont = family[0u]; + + firstFont.Should().NotBeNull(); + } + + [Fact] + public void GetEnumerator_ShouldEnumerateFonts() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var fonts = family.ToList(); + + fonts.Should().NotBeEmpty(); + fonts.Should().AllSatisfy(f => f.Should().NotBeNull()); + } + + [Fact] + public void OrdinalName_ShouldNotBeEmpty() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + family.OrdinalName.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Metrics_ShouldBeValid() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var metrics = family.Metrics; + + metrics.Should().NotBeNull(); + metrics.DesignUnitsPerEm.Should().BeGreaterThan(0); + } + + [Fact] + public void DisplayMetrics_ShouldReturnValidMetrics() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var displayMetrics = family.DisplayMetrics(emSize: 12.0f, pixelsPerDip: 1.0f); + + displayMetrics.Should().NotBeNull(); + displayMetrics.DesignUnitsPerEm.Should().BeGreaterThan(0); + } + + [Fact] + public void GetFirstMatchingFont_WithNormalProperties_ShouldReturnFont() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var font = family.GetFirstMatchingFont(FontWeight.Normal, FontStretch.Normal, FontStyle.Normal); + + font.Should().NotBeNull(); + font.Weight.Should().Be(FontWeight.Normal); + font.Style.Should().Be(FontStyle.Normal); + } + + [Fact] + public void GetFirstMatchingFont_WithBoldWeight_ShouldReturnBoldFont() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var font = family.GetFirstMatchingFont(FontWeight.Bold, FontStretch.Normal, FontStyle.Normal); + + font.Should().NotBeNull(); + font.Weight.Should().Be(FontWeight.Bold); + } + + [Fact] + public void GetFirstMatchingFont_WithItalicStyle_ShouldReturnItalicFont() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var font = family.GetFirstMatchingFont(FontWeight.Normal, FontStretch.Normal, FontStyle.Italic); + + font.Should().NotBeNull(); + font.Style.Should().Be(FontStyle.Italic); + } + + [Fact] + public void GetMatchingFonts_WithNormalProperties_ShouldReturnFontList() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var matchingFonts = family.GetMatchingFonts(FontWeight.Normal, FontStretch.Normal, FontStyle.Normal); + + matchingFonts.Should().NotBeNull(); + matchingFonts.Count.Should().BeGreaterThan(0); + } + + [Fact] + public void GetMatchingFonts_ShouldReturnFontsRankedByMatch() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var matchingFonts = family.GetMatchingFonts(FontWeight.Bold, FontStretch.Normal, FontStyle.Normal); + + matchingFonts.Should().NotBeNull(); + // First font should be the best match (bold) + var firstFont = matchingFonts[0u]; + firstFont.Should().NotBeNull(); + firstFont.Weight.Should().Be(FontWeight.Bold); + } + + [Fact] + public void GetMatchingFonts_ShouldBeEnumerable() + { + var family = TestHelpers.GetArialFamilyOrSkip(); + + var matchingFonts = family.GetMatchingFonts(FontWeight.Normal, FontStretch.Normal, FontStyle.Normal); + + var fontList = matchingFonts.ToList(); + fontList.Should().NotBeEmpty(); + fontList.Should().AllSatisfy(f => f.Should().NotBeNull()); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontFileAndAnalyzerTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontFileAndAnalyzerTests.cs new file mode 100644 index 00000000000..128f307a7d9 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontFileAndAnalyzerTests.cs @@ -0,0 +1,683 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for class. +/// +public class FontFileTests +{ + [Fact] + public void CreateFontFile_WithValidPath_ShouldSucceed() + { + TestHelpers.SkipIfArialNotAvailable(); + + var factory = DWriteFactory.Instance; + var fontFile = factory.CreateFontFile(new Uri(TestHelpers.ArialPath)); + + fontFile.Should().NotBeNull(); + } + + [Fact] + public void GetUriPath_ShouldReturnPath() + { + TestHelpers.SkipIfArialNotAvailable(); + + var factory = DWriteFactory.Instance; + var fontFile = factory.CreateFontFile(new Uri(TestHelpers.ArialPath)); + + var uriPath = fontFile.GetUriPath(); + + uriPath.Should().NotBeNullOrEmpty(); + uriPath.ToLowerInvariant().Should().Contain("arial"); + } + + [Fact] + public void DWriteFontFileNoAddRef_ShouldReturnNonNullPointer() + { + TestHelpers.SkipIfArialNotAvailable(); + + var factory = DWriteFactory.Instance; + var fontFile = factory.CreateFontFile(new Uri(TestHelpers.ArialPath)); + + // This returns the native pointer - just verify it doesn't throw + // The actual pointer is internal to DirectWrite + fontFile.Should().NotBeNull(); + } +} + +/// +/// Tests for and classes. +/// +public class FontSourceTests +{ + [Fact] + public void FontSourceFactory_Create_ShouldReturnFontSource() + { + TestHelpers.SkipIfArialNotAvailable(); + + var factory = new FontSourceFactory(); + var fontSource = factory.Create(TestHelpers.ArialPath); + + fontSource.Should().NotBeNull(); + } + + [Fact] + public void FontSource_Uri_ShouldReturnUri() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSource = new FontSource(new Uri(TestHelpers.ArialPath)); + + fontSource.Uri.Should().NotBeNull(); + fontSource.Uri.LocalPath.ToLowerInvariant().Should().Contain("arial"); + } + + [Fact] + public void FontSource_IsComposite_ShouldBeFalseForTtf() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSource = new FontSource(new Uri(TestHelpers.ArialPath)); + + fontSource.IsComposite.Should().BeFalse(); + } + + [Fact] + public void FontSource_GetLastWriteTimeUtc_ShouldReturnValidTime() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSource = new FontSource(new Uri(TestHelpers.ArialPath)); + var lastWriteTime = fontSource.GetLastWriteTimeUtc(); + + lastWriteTime.Should().BeBefore(DateTime.UtcNow); + lastWriteTime.Year.Should().BeGreaterThan(1990); + } + + [Fact] + public void FontSource_GetUnmanagedStream_ShouldReturnStream() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSource = new FontSource(new Uri(TestHelpers.ArialPath)); + + using var stream = fontSource.GetUnmanagedStream(); + + stream.Should().NotBeNull(); + stream.Length.Should().BeGreaterThan(0); + } + + [Fact] + public void FontSource_TestFileOpenable_ShouldNotThrowForValidFile() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSource = new FontSource(new Uri(TestHelpers.ArialPath)); + + // Should not throw for valid file + Action act = () => fontSource.TestFileOpenable(); + act.Should().NotThrow(); + } +} + +/// +/// Tests for class. +/// +public class FontFileLoaderTests +{ + [Fact] + public void FontFileLoader_CanBeConstructedWithFactory() + { + var factory = new FontSourceFactory(); + var loader = new FontFileLoader(factory); + + loader.Should().NotBeNull(); + } +} + +/// +/// Tests for class. +/// +public class FontFileStreamTests +{ + [Fact] + public void FontFileStream_CanBeConstructedWithFontSource() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSource = new FontSource(new Uri(TestHelpers.ArialPath)); + var stream = new FontFileStream(fontSource); + + stream.Should().NotBeNull(); + } +} + +/// +/// Tests for class. +/// FontFileEnumerator is a C++/CLI class in DirectWriteForwarder that implements +/// IDWriteFontFileEnumeratorMirror for enumerating font files in a collection. +/// +public class FontFileEnumeratorTests +{ + [Fact] + public unsafe void FontFileEnumerator_CanBeConstructed() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = new IFontSource[] { new FontSource(new Uri(TestHelpers.ArialPath)) }; + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + enumerator.Should().NotBeNull(); + } + + [Fact] + public unsafe void FontFileEnumerator_MoveNext_ReturnsTrueForFirstFile() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = new IFontSource[] { new FontSource(new Uri(TestHelpers.ArialPath)) }; + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + bool hasCurrentFile = false; + var hr = enumerator.MoveNext(ref hasCurrentFile); + + hr.Should().Be(0); // S_OK + hasCurrentFile.Should().BeTrue(); + } + + [Fact] + public unsafe void FontFileEnumerator_MoveNext_ReturnsFalseAfterLastFile() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = new IFontSource[] { new FontSource(new Uri(TestHelpers.ArialPath)) }; + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + bool hasCurrentFile = false; + enumerator.MoveNext(ref hasCurrentFile); // Move to first + hasCurrentFile.Should().BeTrue(); + + enumerator.MoveNext(ref hasCurrentFile); // Move past last + hasCurrentFile.Should().BeFalse(); + } + + [Fact] + public unsafe void FontFileEnumerator_MultipleFiles_EnumeratesAll() + { + // Get a few font files + var fontFiles = Directory.GetFiles(TestHelpers.FontsDirectory, "*.ttf").Take(3).ToArray(); + Assert.SkipUnless(fontFiles.Length >= 2, "Need at least 2 fonts for this test"); + + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = fontFiles.Select(f => (IFontSource)new FontSource(new Uri(f))).ToArray(); + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + int count = 0; + bool hasCurrentFile = false; + while (enumerator.MoveNext(ref hasCurrentFile) == 0 && hasCurrentFile) + { + count++; + } + + count.Should().Be(fontSources.Length); + } + + [Fact] + public unsafe void FontFileEnumerator_EmptyCollection_MoveNextReturnsFalse() + { + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = Array.Empty(); + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + bool hasCurrentFile = false; + var hr = enumerator.MoveNext(ref hasCurrentFile); + + hr.Should().Be(0); // S_OK + hasCurrentFile.Should().BeFalse(); + } + + [Fact] + public unsafe void FontFileEnumerator_GetCurrentFontFile_ReturnsValidPointer() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = new IFontSource[] { new FontSource(new Uri(TestHelpers.ArialPath)) }; + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + bool hasCurrentFile = false; + enumerator.MoveNext(ref hasCurrentFile); + hasCurrentFile.Should().BeTrue(); + + Native.IDWriteFontFile* fontFile = null; + var hr = enumerator.GetCurrentFontFile(&fontFile); + + hr.Should().Be(0); // S_OK + ((IntPtr)fontFile).Should().NotBe(IntPtr.Zero); + + // Release the font file via COM Release (method index 2 in IUnknown vtable) + if (fontFile != null) + { + Marshal.Release((IntPtr)fontFile); + } + } + + [Fact] + public unsafe void FontFileEnumerator_GetCurrentFontFile_WithNullPointer_ReturnsInvalidArg() + { + TestHelpers.SkipIfArialNotAvailable(); + + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = new IFontSource[] { new FontSource(new Uri(TestHelpers.ArialPath)) }; + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + bool hasCurrentFile = false; + enumerator.MoveNext(ref hasCurrentFile); + + var hr = enumerator.GetCurrentFontFile(null); + + // E_INVALIDARG = 0x80070057 + hr.Should().Be(unchecked((int)0x80070057)); + } + + [Fact] + public unsafe void FontFileEnumerator_MultipleFontTypes_EnumeratesAll() + { + // Get different font types + var ttfFiles = Directory.GetFiles(TestHelpers.FontsDirectory, "*.ttf").Take(2); + var ttcFiles = Directory.GetFiles(TestHelpers.FontsDirectory, "*.ttc").Take(1); + var allFiles = ttfFiles.Concat(ttcFiles).ToArray(); + + Assert.SkipUnless(allFiles.Length >= 2, "Need at least 2 fonts for this test"); + + var fontSourceFactory = new FontSourceFactory(); + var fontFileLoader = new FontFileLoader(fontSourceFactory); + var fontSources = allFiles.Select(f => (IFontSource)new FontSource(new Uri(f))).ToArray(); + + var factory = DWriteFactory.Instance; + var enumerator = new FontFileEnumerator( + fontSources, + fontFileLoader, + (Native.IDWriteFactory*)factory.DWriteFactory + ); + + int count = 0; + bool hasCurrentFile = false; + while (enumerator.MoveNext(ref hasCurrentFile) == 0 && hasCurrentFile) + { + count++; + } + + count.Should().Be(fontSources.Length); + } +} + +/// +/// Tests for class. +/// +public class TextAnalyzerTests +{ + [Fact] + public void CreateTextAnalyzer_ShouldReturnNonNull() + { + var factory = DWriteFactory.Instance; + + var textAnalyzer = factory.CreateTextAnalyzer(); + + textAnalyzer.Should().NotBeNull(); + } + + [Fact] + public void TextAnalyzer_CanBeCreatedMultipleTimes() + { + var factory = DWriteFactory.Instance; + + var analyzer1 = factory.CreateTextAnalyzer(); + var analyzer2 = factory.CreateTextAnalyzer(); + + analyzer1.Should().NotBeNull(); + analyzer2.Should().NotBeNull(); + analyzer1.Should().NotBeSameAs(analyzer2); + } +} + +/// +/// Tests for class. +/// +public class LocalizedErrorMsgsTests +{ + [Fact] + public void EnumeratorNotStarted_ShouldBeSet() + { + // DWriteFactory static constructor sets these + var _ = DWriteFactory.Instance; + + LocalizedErrorMsgs.EnumeratorNotStarted.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void EnumeratorReachedEnd_ShouldBeSet() + { + // DWriteFactory static constructor sets these + var _ = DWriteFactory.Instance; + + LocalizedErrorMsgs.EnumeratorReachedEnd.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void ErrorMessages_CanBeSetAndRetrieved() + { + var originalNotStarted = LocalizedErrorMsgs.EnumeratorNotStarted; + var originalReachedEnd = LocalizedErrorMsgs.EnumeratorReachedEnd; + + try + { + LocalizedErrorMsgs.EnumeratorNotStarted = "Custom not started message"; + LocalizedErrorMsgs.EnumeratorReachedEnd = "Custom reached end message"; + + LocalizedErrorMsgs.EnumeratorNotStarted.Should().Be("Custom not started message"); + LocalizedErrorMsgs.EnumeratorReachedEnd.Should().Be("Custom reached end message"); + } + finally + { + // Restore original values + LocalizedErrorMsgs.EnumeratorNotStarted = originalNotStarted; + LocalizedErrorMsgs.EnumeratorReachedEnd = originalReachedEnd; + } + } +} + +/// +/// Tests for struct. +/// +public class ItemPropsTests +{ + [Fact] + public void ItemProps_DefaultConstructor_ShouldCreateWithDefaults() + { + var itemProps = new ItemProps(); + + itemProps.Should().NotBeNull(); + itemProps.HasCombiningMark.Should().BeFalse(); + itemProps.HasExtendedCharacter.Should().BeFalse(); + itemProps.NeedsCaretInfo.Should().BeFalse(); + itemProps.IsIndic.Should().BeFalse(); + itemProps.IsLatin.Should().BeFalse(); + itemProps.DigitCulture.Should().BeNull(); + } + + [Fact] + public unsafe void ItemProps_Create_WithNullPointers_ShouldSucceed() + { + var itemProps = ItemProps.Create( + scriptAnalysis: null, + numberSubstitution: null, + digitCulture: null, + hasCombiningMark: true, + needsCaretInfo: true, + hasExtendedCharacter: true, + isIndic: false, + isLatin: true + ); + + itemProps.Should().NotBeNull(); + itemProps.HasCombiningMark.Should().BeTrue(); + itemProps.NeedsCaretInfo.Should().BeTrue(); + itemProps.HasExtendedCharacter.Should().BeTrue(); + itemProps.IsIndic.Should().BeFalse(); + itemProps.IsLatin.Should().BeTrue(); + } + + [Fact] + public unsafe void ItemProps_Create_WithDigitCulture_ShouldSetCulture() + { + var culture = System.Globalization.CultureInfo.GetCultureInfo("ar-SA"); + + var itemProps = ItemProps.Create( + scriptAnalysis: null, + numberSubstitution: null, + digitCulture: culture, + hasCombiningMark: false, + needsCaretInfo: false, + hasExtendedCharacter: false, + isIndic: false, + isLatin: false + ); + + itemProps.DigitCulture.Should().Be(culture); + } + + [Fact] + public unsafe void ItemProps_CanShapeTogether_WithSameNullPointers_ShouldReturnTrue() + { + var props1 = ItemProps.Create(null, null, null, false, false, false, false, true); + var props2 = ItemProps.Create(null, null, null, true, true, true, true, false); + + // Both have null script analysis and null number substitution, so they can shape together + props1.CanShapeTogether(props2).Should().BeTrue(); + } + + [Fact] + public unsafe void ItemProps_ScriptAnalysis_WithNullPointer_ShouldReturnNull() + { + var itemProps = ItemProps.Create(null, null, null, false, false, false, false, false); + + // ScriptAnalysis returns void*, so cast to check for null + void* ptr = itemProps.ScriptAnalysis; + ((IntPtr)ptr).Should().Be(IntPtr.Zero); + } +} + +/// +/// Tests for struct. +/// +public class SpanTests +{ + [Fact] + public void Span_Constructor_ShouldSetElementAndLength() + { + var element = "test element"; + var span = new MS.Internal.Span(element, 10); + + span.element.Should().Be(element); + span.length.Should().Be(10); + } + + [Fact] + public void Span_Constructor_WithNullElement_ShouldSucceed() + { + var span = new MS.Internal.Span(null, 5); + + span.element.Should().BeNull(); + span.length.Should().Be(5); + } + + [Fact] + public void Span_Constructor_WithZeroLength_ShouldSucceed() + { + var span = new MS.Internal.Span("element", 0); + + span.length.Should().Be(0); + } + + [Fact] + public void Span_CanHoldItemProps() + { + var itemProps = new ItemProps(); + var span = new MS.Internal.Span(itemProps, 100); + + span.element.Should().Be(itemProps); + span.length.Should().Be(100); + } +} + +/// +/// Tests for class. +/// +public class ClassificationUtilityTests +{ + [Fact] + public void ClassificationUtility_Instance_ShouldNotBeNull() + { + var instance = ClassificationUtility.Instance; + + instance.Should().NotBeNull(); + } + + [Fact] + public void GetCharAttribute_ForLatinA_ShouldReturnLatinAndNotDigit() + { + var instance = ClassificationUtility.Instance; + + instance.GetCharAttribute( + unicodeScalar: 'A', // Latin capital A + out bool isCombining, + out bool needsCaretInfo, + out bool isIndic, + out bool isDigit, + out bool isLatin, + out bool isStrong + ); + + isCombining.Should().BeFalse(); + needsCaretInfo.Should().BeFalse(); + isDigit.Should().BeFalse(); + isLatin.Should().BeTrue(); + isIndic.Should().BeFalse(); + isStrong.Should().BeTrue(); // Latin A is a strong character + } + + [Fact] + public void GetCharAttribute_ForDigit_ShouldReturnIsDigit() + { + var instance = ClassificationUtility.Instance; + + instance.GetCharAttribute( + unicodeScalar: '0', // Digit zero + out bool isCombining, + out bool needsCaretInfo, + out bool isIndic, + out bool isDigit, + out bool isLatin, + out bool isStrong + ); + + isCombining.Should().BeFalse(); + needsCaretInfo.Should().BeFalse(); + isDigit.Should().BeTrue(); + isLatin.Should().BeFalse(); + isIndic.Should().BeFalse(); + // Digits may or may not be "strong" depending on classification - don't assert + _ = isStrong; // suppress IDE0059 + } + + [Fact] + public void GetCharAttribute_ForDevanagari_ShouldReturnIndicAndNeedsCaretInfo() + { + var instance = ClassificationUtility.Instance; + + // Devanagari letter A (U+0905) + instance.GetCharAttribute( + unicodeScalar: 0x0905, + out bool isCombining, + out bool needsCaretInfo, + out bool isIndic, + out bool isDigit, + out bool isLatin, + out bool isStrong + ); + + isCombining.Should().BeFalse(); + isIndic.Should().BeTrue(); + needsCaretInfo.Should().BeTrue(); + isLatin.Should().BeFalse(); + isDigit.Should().BeFalse(); + isStrong.Should().BeTrue(); + } + + [Fact] + public void GetCharAttribute_ForCombiningMark_ShouldReturnIsCombining() + { + var instance = ClassificationUtility.Instance; + + // Combining Acute Accent (U+0301) is in the Latin Extended block + instance.GetCharAttribute( + unicodeScalar: 0x0301, + out bool isCombining, + out bool needsCaretInfo, + out bool isIndic, + out bool isDigit, + out bool isLatin, + out bool isStrong + ); + + isCombining.Should().BeTrue(); + needsCaretInfo.Should().BeFalse(); + isIndic.Should().BeFalse(); + isDigit.Should().BeFalse(); + // isLatin may be true since U+0301 is in the Latin Extended block + _ = isLatin; + isStrong.Should().BeFalse(); // Combining marks are not strong + } + + [Fact] + public void ScriptCaretInfo_ShouldHaveExpectedLength() + { + ClassificationUtility.ScriptCaretInfo.Should().NotBeEmpty(); + ClassificationUtility.ScriptCaretInfo.Length.Should().BeGreaterThan(50); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontMetricsTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontMetricsTests.cs new file mode 100644 index 00000000000..15085177476 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontMetricsTests.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for value struct. +/// +public class FontMetricsTests +{ + [Fact] + public void DefaultValues_ShouldBeZero() + { + var metrics = new FontMetrics(); + + metrics.DesignUnitsPerEm.Should().Be(0); + metrics.Ascent.Should().Be(0); + metrics.Descent.Should().Be(0); + metrics.LineGap.Should().Be(0); + metrics.CapHeight.Should().Be(0); + metrics.XHeight.Should().Be(0); + metrics.UnderlinePosition.Should().Be(0); + metrics.UnderlineThickness.Should().Be(0); + metrics.StrikethroughPosition.Should().Be(0); + metrics.StrikethroughThickness.Should().Be(0); + } + + [Fact] + public void Baseline_ShouldCalculateCorrectly() + { + // Baseline = (Ascent + LineGap * 0.5) / DesignUnitsPerEm + var metrics = new FontMetrics + { + DesignUnitsPerEm = 1000, + Ascent = 800, + LineGap = 100 + }; + + // Expected: (800 + 100 * 0.5) / 1000 = 850 / 1000 = 0.85 + metrics.Baseline.Should().BeApproximately(0.85, 0.0001); + } + + [Fact] + public void Baseline_WithNegativeLineGap_ShouldCalculateCorrectly() + { + var metrics = new FontMetrics + { + DesignUnitsPerEm = 2048, + Ascent = 1900, + LineGap = -100 + }; + + // Expected: (1900 + (-100) * 0.5) / 2048 = 1850 / 2048 + double expected = 1850.0 / 2048.0; + metrics.Baseline.Should().BeApproximately(expected, 0.0001); + } + + [Fact] + public void LineSpacing_ShouldCalculateCorrectly() + { + // LineSpacing = (Ascent + Descent + LineGap) / DesignUnitsPerEm + var metrics = new FontMetrics + { + DesignUnitsPerEm = 1000, + Ascent = 800, + Descent = 200, + LineGap = 100 + }; + + // Expected: (800 + 200 + 100) / 1000 = 1100 / 1000 = 1.1 + metrics.LineSpacing.Should().BeApproximately(1.1, 0.0001); + } + + [Fact] + public void LineSpacing_WithZeroLineGap_ShouldCalculateCorrectly() + { + var metrics = new FontMetrics + { + DesignUnitsPerEm = 2048, + Ascent = 1900, + Descent = 500, + LineGap = 0 + }; + + // Expected: (1900 + 500 + 0) / 2048 = 2400 / 2048 + double expected = 2400.0 / 2048.0; + metrics.LineSpacing.Should().BeApproximately(expected, 0.0001); + } + + [Fact] + public void LineSpacing_WithNegativeLineGap_ShouldCalculateCorrectly() + { + var metrics = new FontMetrics + { + DesignUnitsPerEm = 1000, + Ascent = 800, + Descent = 200, + LineGap = -50 + }; + + // Expected: (800 + 200 + (-50)) / 1000 = 950 / 1000 = 0.95 + metrics.LineSpacing.Should().BeApproximately(0.95, 0.0001); + } + + [Theory] + [InlineData((ushort)1000, (ushort)750, (ushort)250, (short)0)] + [InlineData((ushort)2048, (ushort)1900, (ushort)500, (short)100)] + [InlineData((ushort)1024, (ushort)800, (ushort)200, (short)-50)] + public void FontMetrics_VariousValues_CalculationsAreConsistent( + ushort designUnitsPerEm, + ushort ascent, + ushort descent, + short lineGap) + { + var metrics = new FontMetrics + { + DesignUnitsPerEm = designUnitsPerEm, + Ascent = ascent, + Descent = descent, + LineGap = lineGap + }; + + double expectedBaseline = (ascent + lineGap * 0.5) / designUnitsPerEm; + double expectedLineSpacing = (double)(ascent + descent + lineGap) / designUnitsPerEm; + + metrics.Baseline.Should().BeApproximately(expectedBaseline, 0.0001); + metrics.LineSpacing.Should().BeApproximately(expectedLineSpacing, 0.0001); + } + + [Fact] + public void AllFields_CanBeSetAndRetrieved() + { + var metrics = new FontMetrics + { + DesignUnitsPerEm = 2048, + Ascent = 1900, + Descent = 500, + LineGap = 100, + CapHeight = 1400, + XHeight = 1000, + UnderlinePosition = -200, + UnderlineThickness = 100, + StrikethroughPosition = 600, + StrikethroughThickness = 80 + }; + + metrics.DesignUnitsPerEm.Should().Be(2048); + metrics.Ascent.Should().Be(1900); + metrics.Descent.Should().Be(500); + metrics.LineGap.Should().Be(100); + metrics.CapHeight.Should().Be(1400); + metrics.XHeight.Should().Be(1000); + metrics.UnderlinePosition.Should().Be(-200); + metrics.UnderlineThickness.Should().Be(100); + metrics.StrikethroughPosition.Should().Be(600); + metrics.StrikethroughThickness.Should().Be(80); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontTests.cs new file mode 100644 index 00000000000..4947b80e25e --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/FontTests.cs @@ -0,0 +1,724 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for class. +/// Arial Regular is used as a known reference font with documented properties. +/// +public class FontTests +{ + // Known Arial Regular properties (standard Windows font) + // These values are consistent across Windows versions + private const int ArialRegularWeight = 400; // Normal + private const int ArialRegularStretch = 5; // Normal + private const int ArialRegularStyle = 0; // Normal + private const ushort ArialDesignUnitsPerEm = 2048; + + [Fact] + public void Weight_ArialRegular_ShouldBeNormal() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // Arial Regular should have weight 400 (Normal) + font.Weight.Should().Be(FontWeight.Normal); + ((int)font.Weight).Should().Be(ArialRegularWeight); + } + + [Fact] + public void Stretch_ArialRegular_ShouldBeNormal() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // Arial Regular should have stretch 5 (Normal) + font.Stretch.Should().Be(FontStretch.Normal); + ((int)font.Stretch).Should().Be(ArialRegularStretch); + } + + [Fact] + public void Style_ArialRegular_ShouldBeNormal() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // Arial Regular should have style 0 (Normal) + font.Style.Should().Be(FontStyle.Normal); + ((int)font.Style).Should().Be(ArialRegularStyle); + } + + [Fact] + public void Family_ShouldNotBeNull() + { + var font = TestHelpers.GetArialFontOrSkip(); + + font.Family.Should().NotBeNull(); + } + + [Fact] + public void FaceNames_ShouldContainRegular() + { + var font = TestHelpers.GetArialFontOrSkip(); + + var faceNames = font.FaceNames; + + faceNames.Should().NotBeNull(); + faceNames.Count.Should().BeGreaterThan(0); + + // Arial Regular should have "Regular" as a face name + faceNames.Values.Should().Contain("Regular"); + } + + [Fact] + public void Metrics_ArialDesignUnitsPerEm_ShouldBe2048() + { + var font = TestHelpers.GetArialFontOrSkip(); + + var metrics = font.Metrics; + + metrics.Should().NotBeNull(); + // Arial has 2048 design units per em (standard for TrueType fonts) + metrics.DesignUnitsPerEm.Should().Be(ArialDesignUnitsPerEm); + metrics.Ascent.Should().BeGreaterThan(0); + metrics.Descent.Should().BeGreaterThan(0); + } + + [Fact] + public void Metrics_AscentPlusDescent_ShouldBeLessThanDesignUnits() + { + var font = TestHelpers.GetArialFontOrSkip(); + + var metrics = font.Metrics; + + // Ascent + Descent should not exceed design units per em (basic sanity check) + (metrics.Ascent + metrics.Descent).Should().BeLessThanOrEqualTo(metrics.DesignUnitsPerEm * 2); + } + + [Fact] + public void DisplayMetrics_ShouldReturnValidGdiCompatibleMetrics() + { + var font = TestHelpers.GetArialFontOrSkip(); + + var displayMetrics = font.DisplayMetrics(12.0f, 96.0f); + + displayMetrics.Should().NotBeNull(); + // GDI compatible metrics are still in design units, but snapped to pixel grid + // They should be comparable to the regular design metrics + var designMetrics = font.Metrics; + displayMetrics.DesignUnitsPerEm.Should().Be(designMetrics.DesignUnitsPerEm); + displayMetrics.Ascent.Should().BeGreaterThan(0); + displayMetrics.Descent.Should().BeGreaterThan(0); + } + + [Fact] + public void GetFontFace_ShouldReturnValidFontFace() + { + var font = TestHelpers.GetArialFontOrSkip(); + + var fontFace = font.GetFontFace(); + + fontFace.Should().NotBeNull(); + fontFace.Release(); + } + + [Fact] + public void SimulationFlags_ShouldBeNoneForRegularFont() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // A regular font without simulations should have None + font.SimulationFlags.Should().Be(FontSimulations.None); + } + + [Fact] + public void Version_ShouldBePositive() + { + var font = TestHelpers.GetArialFontOrSkip(); + + font.Version.Should().BeGreaterThan(0); + } + + [Fact] + public void IsSymbolFont_ForArial_ShouldBeFalse() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // Arial is not a symbol font + font.IsSymbolFont.Should().BeFalse(); + } + + [Fact] + public void SymbolFont_ShouldBeIdentifiedCorrectly() + { + var fontCollection = DWriteFactory.SystemFontCollection; + + // Try to find a symbol font like Wingdings or Symbol + var symbolFamilyNames = new[] { "Wingdings", "Symbol", "Webdings" }; + + foreach (var familyName in symbolFamilyNames) + { + var family = fontCollection[familyName]; + if (family != null && family.Count > 0) + { + var font = family[0u]; + font.IsSymbolFont.Should().BeTrue($"{familyName} should be identified as a symbol font"); + return; + } + } + + Assert.Skip("No symbol fonts found on system"); + } + + [Fact] + public void HasCharacter_WithAsciiLetters_ShouldReturnTrue() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // All ASCII letters should be in Arial + for (uint codePoint = 'A'; codePoint <= 'Z'; codePoint++) + { + font.HasCharacter(codePoint).Should().BeTrue($"Arial should have character U+{codePoint:X4} ('{(char)codePoint}')"); + } + for (uint codePoint = 'a'; codePoint <= 'z'; codePoint++) + { + font.HasCharacter(codePoint).Should().BeTrue($"Arial should have character U+{codePoint:X4} ('{(char)codePoint}')"); + } + } + + [Fact] + public void HasCharacter_WithDigits_ShouldReturnTrue() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // All ASCII digits should be in Arial + for (uint codePoint = '0'; codePoint <= '9'; codePoint++) + { + font.HasCharacter(codePoint).Should().BeTrue($"Arial should have character U+{codePoint:X4} ('{(char)codePoint}')"); + } + } + + [Fact] + public void HasCharacter_WithRareCharacter_ShouldReturnFalse() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // Very high code point that's unlikely to be in Arial + font.HasCharacter(0x1F999).Should().BeFalse(); // Emoji unicorn + } + + [Fact] + public void GetInformationalStrings_FamilyName_ShouldContainArial() + { + var font = TestHelpers.GetArialFontOrSkip(); + + bool exists = font.GetInformationalStrings(InformationalStringID.WIN32FamilyNames, out var familyNames); + + exists.Should().BeTrue(); + familyNames.Should().NotBeNull(); + // Should contain "Arial" in the localized names + familyNames!.Values.Should().Contain("Arial"); + } + + [Fact] + public void GetInformationalStrings_DesignerName_ShouldNotThrow() + { + var font = TestHelpers.GetArialFontOrSkip(); + + // May or may not have designer info, but shouldn't throw + _ = font.GetInformationalStrings(InformationalStringID.Designer, out _); + // Result may be null if this info string is not present + } + + [Fact] + public void GetInformationalStrings_CopyrightNotice_ShouldExistAndContainMicrosoft() + { + var font = TestHelpers.GetArialFontOrSkip(); + + bool exists = font.GetInformationalStrings(InformationalStringID.CopyrightNotice, out var copyright); + + exists.Should().BeTrue("Arial should have copyright information"); + copyright.Should().NotBeNull(); + + // Arial is a Microsoft/Monotype font + var copyrightText = copyright!.Values.FirstOrDefault() ?? ""; + copyrightText.Should().NotBeEmpty(); + } +} + +/// +/// Tests for class. +/// Arial is used as a known reference font with documented properties. +/// +public class FontFaceTests +{ + // Known Arial metrics + private const ushort ArialDesignUnitsPerEm = 2048; + private const ushort ArialGlyphCountMinimum = 1000; // Arial has 1000+ glyphs + + private FontFace GetArialFontFace() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + return factory.CreateFontFace(new Uri(arialPath), 0); + } + + [Fact] + public void Type_ArialTtf_ShouldBeTrueType() + { + var fontFace = GetArialFontFace(); + + try + { + // arial.ttf is a TrueType font (not TrueTypeCollection) + fontFace.Type.Should().Be(FontFaceType.TrueType); + // WPF FontFaceType enum: CFF=0, TrueType=1, TrueTypeCollection=2, etc. + ((int)fontFace.Type).Should().Be(1, "FontFaceType.TrueType has value 1 in WPF enum"); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void Index_ForSingleFontFile_ShouldBeZero() + { + var fontFace = GetArialFontFace(); + + try + { + // Single font file (not TTC) should have index 0 + fontFace.Index.Should().Be(0u); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void SimulationFlags_WithNoSimulations_ShouldBeNone() + { + var fontFace = GetArialFontFace(); + + try + { + fontFace.SimulationFlags.Should().Be(FontSimulations.None); + ((int)fontFace.SimulationFlags).Should().Be(0); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void Metrics_Arial_ShouldHave2048DesignUnits() + { + var fontFace = GetArialFontFace(); + + try + { + var metrics = fontFace.Metrics; + + metrics.Should().NotBeNull(); + metrics.DesignUnitsPerEm.Should().Be(ArialDesignUnitsPerEm); + metrics.Ascent.Should().BeGreaterThan(0); + metrics.Descent.Should().BeGreaterThan(0); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void IsSymbolFont_ForArial_ShouldBeFalse() + { + var fontFace = GetArialFontFace(); + + try + { + fontFace.IsSymbolFont.Should().BeFalse(); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void GlyphCount_Arial_ShouldBeOver1000() + { + var fontFace = GetArialFontFace(); + + try + { + // Arial has a large character set with 1000+ glyphs + fontFace.GlyphCount.Should().BeGreaterThanOrEqualTo(ArialGlyphCountMinimum); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void FontFace_WithBoldSimulation_ShouldHaveBoldFlag() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0, FontSimulations.Bold); + try + { + fontFace.SimulationFlags.Should().Be(FontSimulations.Bold); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void FontFace_WithObliqueSimulation_ShouldHaveObliqueFlag() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0, FontSimulations.Oblique); + try + { + fontFace.SimulationFlags.Should().Be(FontSimulations.Oblique); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void FontFace_WithCombinedSimulations_ShouldHaveBothFlags() + { + var factory = DWriteFactory.Instance; + TestHelpers.SkipIfArialNotAvailable(); + var arialPath = TestHelpers.ArialPath; + + var fontFace = factory.CreateFontFace(new Uri(arialPath), 0, FontSimulations.Bold | FontSimulations.Oblique); + try + { + fontFace.SimulationFlags.Should().HaveFlag(FontSimulations.Bold); + fontFace.SimulationFlags.Should().HaveFlag(FontSimulations.Oblique); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void GetFileZero_ShouldReturnFontFile() + { + var fontFace = GetArialFontFace(); + + try + { + var fontFile = fontFace.GetFileZero(); + fontFile.Should().NotBeNull(); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void TryGetFontTable_WithHeadTable_ShouldReturnValidHeader() + { + var fontFace = GetArialFontFace(); + + try + { + // 'head' table should exist in all TrueType fonts + bool found = fontFace.TryGetFontTable(OpenTypeTableTag.FontHeader, out byte[]? tableData); + + found.Should().BeTrue(); + tableData.Should().NotBeNull(); + + // head table is exactly 54 bytes + tableData!.Length.Should().Be(54, "head table should be exactly 54 bytes"); + + // Verify magic number at offset 12 (should be 0x5F0F3CF5) + uint magicNumber = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(tableData.AsSpan(12, 4)); + magicNumber.Should().Be(0x5F0F3CF5, "head table should contain magic number 0x5F0F3CF5"); + + // Verify unitsPerEm at offset 18 matches what we expect + ushort unitsPerEm = System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(tableData.AsSpan(18, 2)); + unitsPerEm.Should().Be(ArialDesignUnitsPerEm, "unitsPerEm in head table should match font metrics"); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void TryGetFontTable_WithCmapTable_ShouldReturnValidStructure() + { + var fontFace = GetArialFontFace(); + + try + { + // 'cmap' table (character to glyph mapping) should exist + bool found = fontFace.TryGetFontTable(OpenTypeTableTag.CharToIndexMap, out byte[]? tableData); + + found.Should().BeTrue(); + tableData.Should().NotBeNull(); + tableData!.Length.Should().BeGreaterThan(4); + + // cmap table version should be 0 + ushort version = System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(tableData.AsSpan(0, 2)); + version.Should().Be(0, "cmap table version should be 0"); + + // Number of encoding tables should be at least 1 + ushort numTables = System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(tableData.AsSpan(2, 2)); + numTables.Should().BeGreaterThan(0, "cmap should have at least one encoding table"); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void TryGetFontTable_WithNonExistentTable_ShouldReturnFalse() + { + var fontFace = GetArialFontFace(); + + try + { + // 'JSTF' (Justification) table typically doesn't exist in Arial + bool found = fontFace.TryGetFontTable(OpenTypeTableTag.TTO_JSTF, out byte[]? tableData); + + // May or may not exist, but should not throw + if (!found) + { + tableData.Should().BeNull(); + } + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public void ReadFontEmbeddingRights_Arial_ShouldReturnInstallableValue() + { + var fontFace = GetArialFontFace(); + + try + { + bool success = fontFace.ReadFontEmbeddingRights(out ushort fsType); + + // Arial should have OS/2 table with embedding rights + success.Should().BeTrue(); + // fsType bits 0-3 indicate embedding licensing rights + // Arial typically allows embedding (fsType & 0x000F should not be 0x0002 which is "restricted") + (fsType & 0x0002).Should().Be(0, "Arial should not have restricted embedding"); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void GetArrayOfGlyphIndices_WithValidCodePoints_ShouldReturnConsistentGlyphIndices() + { + var fontFace = GetArialFontFace(); + + try + { + // Get glyph indices for 'A', 'B', 'C' (code points 65, 66, 67) + uint[] codePoints = [65, 66, 67]; + ushort[] glyphIndices = new ushort[codePoints.Length]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, (uint)codePoints.Length, pGlyphIndices); + } + + // All glyphs should be non-zero (0 is typically .notdef) + glyphIndices[0].Should().BeGreaterThan((ushort)0, "Glyph for 'A' should exist"); + glyphIndices[1].Should().BeGreaterThan((ushort)0, "Glyph for 'B' should exist"); + glyphIndices[2].Should().BeGreaterThan((ushort)0, "Glyph for 'C' should exist"); + + // Adjacent characters should have different glyph indices + glyphIndices[0].Should().NotBe(glyphIndices[1], "'A' and 'B' should have different glyph indices"); + glyphIndices[1].Should().NotBe(glyphIndices[2], "'B' and 'C' should have different glyph indices"); + + // Glyph indices should be consistent when called again + ushort[] glyphIndices2 = new ushort[codePoints.Length]; + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices2) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, (uint)codePoints.Length, pGlyphIndices); + } + glyphIndices.Should().Equal(glyphIndices2, "Glyph indices should be consistent across calls"); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void GetArrayOfGlyphIndices_WithUnsupportedCodePoint_ShouldReturnZero() + { + var fontFace = GetArialFontFace(); + + try + { + // Very high code point unlikely to be in Arial + uint[] codePoints = [0x1F999]; // Unicorn emoji + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + // Should return 0 (.notdef) for unsupported character + glyphIndices[0].Should().Be(0); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void GetDesignGlyphMetrics_ShouldReturnValidMetricsInDesignUnits() + { + var fontFace = GetArialFontFace(); + + try + { + // First get glyph indices for 'A' + uint[] codePoints = [65]; // 'A' + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + // Now get design metrics for that glyph + GlyphMetrics[] metrics = new GlyphMetrics[1]; + + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + fontFace.GetDesignGlyphMetrics(pGlyphIndices, 1, pMetrics); + } + + // 'A' should have positive advance width + metrics[0].AdvanceWidth.Should().BeGreaterThan(0); + + // Advance width should be reasonable (less than design units per em for a single glyph) + metrics[0].AdvanceWidth.Should().BeLessThan(ArialDesignUnitsPerEm, + "Advance width should be less than design units per em"); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void GetDisplayGlyphMetrics_ShouldReturnMetrics() + { + var fontFace = GetArialFontFace(); + + try + { + // First get glyph indices for 'A' + uint[] codePoints = [65]; // 'A' + ushort[] glyphIndices = new ushort[1]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, 1, pGlyphIndices); + } + + // Get display metrics at 12pt, 96 DPI + GlyphMetrics[] metrics = new GlyphMetrics[1]; + + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + fontFace.GetDisplayGlyphMetrics( + pGlyphIndices, + 1, + pMetrics, + emSize: 12.0f, + useDisplayNatural: false, + isSideways: false, + pixelsPerDip: 1.0f); + } + + // Display metrics should have positive advance width + metrics[0].AdvanceWidth.Should().BeGreaterThan(0); + } + finally + { + fontFace.Release(); + } + } + + [Fact] + public unsafe void GetDesignGlyphMetrics_MultipleGlyphs_ShouldReturnAllMetrics() + { + var fontFace = GetArialFontFace(); + + try + { + // Get glyph indices for 'Hello' + uint[] codePoints = [72, 101, 108, 108, 111]; // H, e, l, l, o + ushort[] glyphIndices = new ushort[codePoints.Length]; + + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, (uint)codePoints.Length, pGlyphIndices); + } + + // Get metrics for all glyphs + GlyphMetrics[] metrics = new GlyphMetrics[codePoints.Length]; + + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (GlyphMetrics* pMetrics = metrics) + { + fontFace.GetDesignGlyphMetrics(pGlyphIndices, (uint)codePoints.Length, pMetrics); + } + + // All visible characters should have positive advance width + for (int i = 0; i < metrics.Length; i++) + { + metrics[i].AdvanceWidth.Should().BeGreaterThan(0, $"Glyph at index {i} should have positive advance width"); + } + } + finally + { + fontFace.Release(); + } + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/GlobalUsings.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..ce1409e264b --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/GlobalUsings.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using Xunit; +global using FluentAssertions; + +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Runtime.InteropServices; + +global using MS.Internal.FontCache; diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/GlyphMetricsTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/GlyphMetricsTests.cs new file mode 100644 index 00000000000..0c692589298 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/GlyphMetricsTests.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for value struct. +/// +public class GlyphMetricsTests +{ + [Fact] + public void DefaultValues_ShouldBeZero() + { + var metrics = new GlyphMetrics(); + + metrics.LeftSideBearing.Should().Be(0); + metrics.AdvanceWidth.Should().Be(0); + metrics.RightSideBearing.Should().Be(0); + metrics.TopSideBearing.Should().Be(0); + metrics.AdvanceHeight.Should().Be(0); + metrics.BottomSideBearing.Should().Be(0); + metrics.VerticalOriginY.Should().Be(0); + } + + [Fact] + public void AllFields_CanBeSetAndRetrieved() + { + var metrics = new GlyphMetrics + { + LeftSideBearing = -10, + AdvanceWidth = 500, + RightSideBearing = 20, + TopSideBearing = 50, + AdvanceHeight = 1000, + BottomSideBearing = -30, + VerticalOriginY = 800 + }; + + metrics.LeftSideBearing.Should().Be(-10); + metrics.AdvanceWidth.Should().Be(500); + metrics.RightSideBearing.Should().Be(20); + metrics.TopSideBearing.Should().Be(50); + metrics.AdvanceHeight.Should().Be(1000); + metrics.BottomSideBearing.Should().Be(-30); + metrics.VerticalOriginY.Should().Be(800); + } + + [Fact] + public void NegativeLeftSideBearing_IndicatesOverhangToLeft() + { + // A negative LeftSideBearing means the black box extends to the left of the origin + // (often true for lowercase italic 'f') + var metrics = new GlyphMetrics + { + LeftSideBearing = -50, + AdvanceWidth = 400 + }; + + metrics.LeftSideBearing.Should().BeNegative(); + } + + [Fact] + public void NegativeRightSideBearing_IndicatesOverhangToRight() + { + // A negative RightSideBearing means the right edge of the black box + // overhangs the layout box + var metrics = new GlyphMetrics + { + RightSideBearing = -25, + AdvanceWidth = 500 + }; + + metrics.RightSideBearing.Should().BeNegative(); + } + + [Theory] + [InlineData(-100, 600, 50)] + [InlineData(0, 500, 0)] + [InlineData(25, 450, -20)] + public void HorizontalMetrics_VariousValues(int leftBearing, uint advanceWidth, int rightBearing) + { + var metrics = new GlyphMetrics + { + LeftSideBearing = leftBearing, + AdvanceWidth = advanceWidth, + RightSideBearing = rightBearing + }; + + metrics.LeftSideBearing.Should().Be(leftBearing); + metrics.AdvanceWidth.Should().Be(advanceWidth); + metrics.RightSideBearing.Should().Be(rightBearing); + } + + [Theory] + [InlineData(100, 1200, -50, 900)] + [InlineData(0, 1000, 0, 800)] + [InlineData(-20, 1100, 30, 850)] + public void VerticalMetrics_VariousValues(int topBearing, uint advanceHeight, int bottomBearing, int verticalOriginY) + { + var metrics = new GlyphMetrics + { + TopSideBearing = topBearing, + AdvanceHeight = advanceHeight, + BottomSideBearing = bottomBearing, + VerticalOriginY = verticalOriginY + }; + + metrics.TopSideBearing.Should().Be(topBearing); + metrics.AdvanceHeight.Should().Be(advanceHeight); + metrics.BottomSideBearing.Should().Be(bottomBearing); + metrics.VerticalOriginY.Should().Be(verticalOriginY); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/LocalizedStringsTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/LocalizedStringsTests.cs new file mode 100644 index 00000000000..fb3f8b23fa5 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/LocalizedStringsTests.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for class. +/// +public class LocalizedStringsTests +{ + [Fact] + public void Count_ShouldBeGreaterThanZero() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + localizedStrings.Count.Should().BeGreaterThan(0); + } + + [Fact] + public void Keys_ShouldContainCultureInfo() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var keys = localizedStrings.Keys; + + keys.Should().NotBeEmpty(); + keys.Should().AllBeOfType(); + } + + [Fact] + public void Values_ShouldContainStrings() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var values = localizedStrings.Values; + + values.Should().NotBeEmpty(); + values.Should().AllBeOfType(); + } + + [Fact] + public void Indexer_WithValidCulture_ShouldReturnName() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + // Get the first available culture + var firstCulture = TestHelpers.GetFirstCultureOrSkip(localizedStrings); + + var name = localizedStrings[firstCulture]; + + name.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void ContainsKey_WithExistingCulture_ShouldReturnTrue() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var firstCulture = TestHelpers.GetFirstCultureOrSkip(localizedStrings); + + localizedStrings.ContainsKey(firstCulture).Should().BeTrue(); + } + + [Fact] + public void TryGetValue_WithExistingCulture_ShouldReturnTrueAndValue() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var firstCulture = TestHelpers.GetFirstCultureOrSkip(localizedStrings); + + bool result = localizedStrings.TryGetValue(firstCulture, out string value); + + result.Should().BeTrue(); + value.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void GetEnumerator_ShouldEnumerateAllPairs() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var pairs = localizedStrings.ToList(); + + pairs.Should().NotBeEmpty(); + pairs.Should().AllSatisfy(kvp => + { + kvp.Key.Should().NotBeNull(); + kvp.Value.Should().NotBeNullOrEmpty(); + }); + } + + [Fact] + public void EnglishName_ShouldExist() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + // Most fonts should have an English (US) name + var englishCulture = new CultureInfo("en-US"); + + if (localizedStrings.ContainsKey(englishCulture)) + { + var englishName = localizedStrings[englishCulture]; + englishName.Should().Contain("Arial"); + } + } + + [Fact] + public void IsReadOnly_ShouldBeTrue() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + localizedStrings.IsReadOnly.Should().BeTrue(); + } + + [Fact] + public void Add_ShouldThrowNotSupportedException() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + Action act = () => localizedStrings.Add(CultureInfo.InvariantCulture, "Test"); + + act.Should().Throw(); + } + + [Fact] + public void Remove_ShouldThrowNotSupportedException() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var firstCulture = TestHelpers.GetFirstCultureOrSkip(localizedStrings); + + Action act = () => localizedStrings.Remove(firstCulture); + + act.Should().Throw(); + } + + [Fact] + public void Clear_ShouldThrowNotSupportedException() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + Action act = () => localizedStrings.Clear(); + + act.Should().Throw(); + } + + [Fact] + public void StringsCount_ShouldMatchCount() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + localizedStrings.StringsCount.Should().Be((uint)localizedStrings.Count); + } + + [Fact] + public void FindLocaleName_WithEnUS_ShouldReturnTrueAndIndex() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + bool found = localizedStrings.FindLocaleName("en-US", out uint index); + + // en-US should typically exist for Arial + if (found) + { + index.Should().BeGreaterThanOrEqualTo(0); + } + } + + [Fact] + public void GetLocaleName_WithValidIndex_ShouldReturnLocaleName() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var localeName = localizedStrings.GetLocaleName(0); + + localeName.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void GetString_WithValidIndex_ShouldReturnString() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var str = localizedStrings.GetString(0); + + str.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Contains_ShouldThrowNotImplementedException() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var firstCulture = TestHelpers.GetFirstCultureOrSkip(localizedStrings); + + var pair = new KeyValuePair(firstCulture, localizedStrings[firstCulture]); + + Action act = () => localizedStrings.Contains(pair); + act.Should().Throw(); + } + + [Fact] + public void CopyTo_ShouldCopyPairsToArray() + { + var localizedStrings = TestHelpers.GetArialLocalizedStringsOrSkip(); + + var array = new KeyValuePair[localizedStrings.Count]; + localizedStrings.CopyTo(array, 0); + + array.Should().AllSatisfy(kvp => kvp.Key.Should().NotBeNull()); + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TestHelpers.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TestHelpers.cs new file mode 100644 index 00000000000..959114f2c45 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TestHelpers.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Helper methods for DirectWriteForwarder tests. +/// +internal static class TestHelpers +{ + /// + /// Path to the Windows Fonts directory. + /// + public static string FontsDirectory => Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + + /// + /// Path to Arial font file. + /// + public static string ArialPath => Path.Combine(FontsDirectory, "arial.ttf"); + + /// + /// Path to Cambria font file. + /// + public static string CambriaPath => Path.Combine(FontsDirectory, "cambria.ttc"); + + /// + /// Skips the test if Arial font is not available. + /// + public static void SkipIfArialNotAvailable() + { + Assert.SkipUnless(File.Exists(ArialPath), "Arial font not found"); + } + + /// + /// Skips the test if the specified font file is not available. + /// + public static void SkipIfFontNotAvailable(string fontPath, string fontName) + { + Assert.SkipUnless(File.Exists(fontPath), $"{fontName} font not found"); + } + + /// + /// Skips the test if Arial font family cannot be retrieved. + /// + public static FontFamily GetArialFamilyOrSkip() + { + var factory = DWriteFactory.Instance; + var fontCollection = factory.GetSystemFontCollection(); + var arialFamily = fontCollection["Arial"]; + Assert.SkipUnless(arialFamily != null, "Arial font family not found"); + return arialFamily!; + } + + /// + /// Skips the test if no font with the specified name can be found. + /// + public static FontFamily GetFontFamilyOrSkip(string familyName) + { + var factory = DWriteFactory.Instance; + var fontCollection = factory.GetSystemFontCollection(); + var family = fontCollection[familyName]; + Assert.SkipUnless(family != null, $"{familyName} font family not found"); + return family!; + } + + /// + /// Gets Arial font or skips the test. + /// + public static Font GetArialFontOrSkip() + { + var family = GetArialFamilyOrSkip(); + Assert.SkipUnless(family.Count > 0, "Arial font family has no fonts"); + return family[0]; + } + + /// + /// Gets a localized strings object from Arial or skips the test. + /// + public static LocalizedStrings GetArialLocalizedStringsOrSkip() + { + var family = GetArialFamilyOrSkip(); + var localizedStrings = family.FamilyNames; + Assert.SkipUnless(localizedStrings != null, "Arial font family has no localized names"); + return localizedStrings!; + } + + /// + /// Gets the first culture from localized strings or skips the test. + /// + public static CultureInfo GetFirstCultureOrSkip(LocalizedStrings localizedStrings) + { + Assert.SkipUnless(localizedStrings.Count > 0, "Localized strings is empty"); + var firstCulture = localizedStrings.Keys.FirstOrDefault(); + Assert.SkipUnless(firstCulture != null, "No culture found in localized strings"); + return firstCulture!; + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TextAnalyzerControlCharacterTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TextAnalyzerControlCharacterTests.cs new file mode 100644 index 00000000000..5794d6b591e --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TextAnalyzerControlCharacterTests.cs @@ -0,0 +1,980 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Windows.Media; + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Additional tests for to improve code coverage. +/// Focuses on control character paths and edge cases not covered by existing tests. +/// +public class TextAnalyzerControlCharacterTests +{ + private static Font GetTestFont() + { + return TestHelpers.GetArialFontOrSkip(); + } + + private static TextAnalyzer GetTextAnalyzer() + { + return DWriteFactory.Instance.CreateTextAnalyzer(); + } + + private static unsafe ushort GetBlankGlyphIndex(Font font) + { + var fontFace = font.GetFontFace(); + try + { + uint spaceCodePoint = ' '; + ushort glyphIndex = 0; + fontFace.GetArrayOfGlyphIndices(&spaceCodePoint, 1, &glyphIndex); + return glyphIndex; + } + finally + { + fontFace.Release(); + } + } + + private static unsafe IList? Itemize(string text, CultureInfo? culture = null) + { + if (string.IsNullOrEmpty(text)) + return null; + + culture ??= CultureInfo.InvariantCulture; + + fixed (char* pText = text) + { + return TextAnalyzer.Itemize( + pText, + (uint)text.Length, + culture, + (Native.IDWriteFactory*)DWriteFactory.Instance.DWriteFactory, + false, + null, + false, + 0, + ClassificationUtility.Instance, + MS.Internal.TextFormatting.UnsafeNativeMethods.CreateTextAnalysisSink, + MS.Internal.TextFormatting.UnsafeNativeMethods.GetScriptAnalysisList, + MS.Internal.TextFormatting.UnsafeNativeMethods.GetNumberSubstitutionList, + MS.Internal.TextFormatting.UnsafeNativeMethods.CreateTextAnalysisSource + ); + } + } + + #region Control Character Glyph Tests + + [Fact] + public unsafe void GetGlyphsAndPlacements_SoftHyphen_ShouldSucceed() + { + // Soft hyphen (U+00AD) is replaced with visible hyphen (U+002D) during line breaking + // This tests the CharHyphen handling in GetBlankGlyphsForControlCharacters + var font = GetTestFont(); + // The soft hyphen character + string text = "word\u00ADbreak"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, // isSideways + false, // isRightToLeft + CultureInfo.InvariantCulture, + null, // features + null, // featureRangeLengths + 12.0, // fontSize + 1.0, // scalingFactor + 1.0f, // pixelsPerDip + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + glyphAdvances.Should().NotBeNull(); + glyphOffsets.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_TabCharacter_ShouldSucceed() + { + // Tab character (U+0009) is a control character + var font = GetTestFont(); + string text = "Hello\tWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_NewlineCharacter_ShouldSucceed() + { + // Newline characters are control characters + var font = GetTestFont(); + string text = "Line1\nLine2"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_CarriageReturn_ShouldSucceed() + { + // Carriage return (U+000D) is a control character + var font = GetTestFont(); + string text = "Line1\rLine2"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_CRLFSequence_ShouldSucceed() + { + // Windows-style line ending + var font = GetTestFont(); + string text = "Line1\r\nLine2"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_VerticalTab_ShouldSucceed() + { + // Vertical tab (U+000B) is a control character + var font = GetTestFont(); + string text = "Hello\vWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_FormFeed_ShouldSucceed() + { + // Form feed (U+000C) is a control character + var font = GetTestFont(); + string text = "Page1\fPage2"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion + + #region Hyphen Character Tests (exercises GetBlankGlyphsForControlCharacters hyphen path) + + [Fact] + public unsafe void GetGlyphsAndPlacements_HyphenCharacter_ShouldSucceed() + { + // The actual hyphen character (U+002D) which is CharHyphen in the code + var font = GetTestFont(); + string text = "word-break"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + // Hyphen should have a non-zero advance + glyphAdvances.Should().Contain(a => a > 0); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_MultipleHyphens_ShouldSucceed() + { + var font = GetTestFont(); + string text = "one-two-three-four"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion + + #region TextFormattingMode Coverage (exercises different measuring mode paths) + + [Fact] + public unsafe void GetGlyphsAndPlacements_DisplayMode_ShouldSucceed() + { + var font = GetTestFont(); + string text = "Display Mode Test"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Display, // Use Display mode + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + glyphAdvances.Should().NotBeNull(); + glyphOffsets.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_IdealMode_ShouldSucceed() + { + var font = GetTestFont(); + string text = "Ideal Mode Test"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_DisplayMode_WithControlChars_ShouldSucceed() + { + // This exercises GetGlyphPlacementsForControlCharacters with Display mode + var font = GetTestFont(); + string text = "Hello\tWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Display, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion + + #region Sideways Text Coverage + + [Fact] + public unsafe void GetGlyphsAndPlacements_Sideways_ShouldSucceed() + { + var font = GetTestFont(); + string text = "Sideways"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + true, // isSideways = true + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion + + #region Different Font Sizes + + [Theory] + [InlineData(8.0)] + [InlineData(12.0)] + [InlineData(24.0)] + [InlineData(48.0)] + [InlineData(72.0)] + public unsafe void GetGlyphsAndPlacements_VariousFontSizes_ShouldSucceed(double fontSize) + { + var font = GetTestFont(); + string text = "Size Test"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + fontSize, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + // Larger font sizes should produce larger advances + glyphAdvances.Where(a => a > 0).Should().NotBeEmpty(); + } + } + + #endregion + + #region Scaling Factor Coverage + + [Theory] + [InlineData(0.5)] + [InlineData(1.0)] + [InlineData(2.0)] + [InlineData(3.0)] + public unsafe void GetGlyphsAndPlacements_VariousScalingFactors_ShouldSucceed(double scalingFactor) + { + var font = GetTestFont(); + string text = "Scale Test"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + scalingFactor, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion + + #region PixelsPerDip Coverage + + [Theory] + [InlineData(1.0f)] + [InlineData(1.25f)] + [InlineData(1.5f)] + [InlineData(2.0f)] + public unsafe void GetGlyphsAndPlacements_VariousPixelsPerDip_ShouldSucceed(float pixelsPerDip) + { + var font = GetTestFont(); + string text = "DPI Test"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + pixelsPerDip, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion + + #region Zero-Width Characters + + [Fact] + public unsafe void GetGlyphsAndPlacements_ZeroWidthSpace_ShouldSucceed() + { + var font = GetTestFont(); + // Zero-width space (U+200B) + string text = "Hello\u200BWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_ZeroWidthNonJoiner_ShouldSucceed() + { + var font = GetTestFont(); + // Zero-width non-joiner (U+200C) + string text = "Hello\u200CWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_ZeroWidthJoiner_ShouldSucceed() + { + var font = GetTestFont(); + // Zero-width joiner (U+200D) + string text = "Hello\u200DWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion + + #region Bidi Control Characters + + [Fact] + public unsafe void GetGlyphsAndPlacements_LeftToRightMark_ShouldSucceed() + { + var font = GetTestFont(); + // Left-to-right mark (U+200E) + string text = "Hello\u200EWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + [Fact] + public unsafe void GetGlyphsAndPlacements_RightToLeftMark_ShouldSucceed() + { + var font = GetTestFont(); + // Right-to-left mark (U+200F) + string text = "Hello\u200FWorld"; + + var spans = Itemize(text); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + false, + false, + CultureInfo.InvariantCulture, + null, + null, + 12.0, + 1.0, + 1.0f, + TextFormattingMode.Ideal, + (ItemProps)spans[0].element, + out ushort[] clusterMap, + out ushort[] glyphIndices, + out int[] glyphAdvances, + out GlyphOffset[] glyphOffsets + ); + + clusterMap.Should().NotBeNull(); + glyphIndices.Should().NotBeNull(); + } + } + + #endregion +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TextAnalyzerIntegrationTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TextAnalyzerIntegrationTests.cs new file mode 100644 index 00000000000..277f9e27b0f --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TextAnalyzerIntegrationTests.cs @@ -0,0 +1,1341 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Integration tests for class. +/// These tests call the real TextAnalyzer.Itemize method with actual PresentationNative delegates. +/// +public class TextAnalyzerIntegrationTests +{ + /// + /// Helper class to encapsulate the complexity of calling TextAnalyzer.Itemize + /// with the required native delegates from PresentationNative.dll. + /// + private static class ItemizeHelper + { + /// + /// Calls TextAnalyzer.Itemize with the provided text and returns the resulting spans. + /// This mirrors how TypefaceMap.GetShapeableText calls Itemize in WPF. + /// + public static unsafe IList? Itemize( + string text, + CultureInfo? culture = null, + bool isRightToLeft = false, + CultureInfo? numberCulture = null, + bool ignoreUserOverride = false, + uint numberSubstitutionMethod = 0) + { + if (string.IsNullOrEmpty(text)) + return null; + + culture ??= CultureInfo.InvariantCulture; + + fixed (char* pText = text) + { + return TextAnalyzer.Itemize( + pText, + (uint)text.Length, + culture, + (Native.IDWriteFactory*)DWriteFactory.Instance.DWriteFactory, + isRightToLeft, + numberCulture, + ignoreUserOverride, + numberSubstitutionMethod, + ClassificationUtility.Instance, + MS.Internal.TextFormatting.UnsafeNativeMethods.CreateTextAnalysisSink, + MS.Internal.TextFormatting.UnsafeNativeMethods.GetScriptAnalysisList, + MS.Internal.TextFormatting.UnsafeNativeMethods.GetNumberSubstitutionList, + MS.Internal.TextFormatting.UnsafeNativeMethods.CreateTextAnalysisSource + ); + } + } + } + + /// + /// Gets a Font instance for testing. Uses Arial which should be available on all Windows systems. + /// + private static Font GetTestFont() + { + return TestHelpers.GetArialFontOrSkip(); + } + + /// + /// Gets a TextAnalyzer instance for testing. + /// + private static TextAnalyzer GetTextAnalyzer() + { + return DWriteFactory.Instance.CreateTextAnalyzer(); + } + + /// + /// Gets the blank glyph index for a font. This is typically glyph 3 for space character. + /// For most fonts, the space character maps to a low glyph index. + /// + private static unsafe ushort GetBlankGlyphIndex(Font font) + { + // Get a FontFace and look up the space character glyph index + var fontFace = font.GetFontFace(); + try + { + uint spaceCodePoint = ' '; + ushort glyphIndex = 0; + fontFace.GetArrayOfGlyphIndices(&spaceCodePoint, 1, &glyphIndex); + return glyphIndex; + } + finally + { + fontFace.Release(); + } + } + + /// + /// Helper to get glyphs and placements for simple text. + /// Returns the output arrays from GetGlyphsAndTheirPlacements. + /// + private static unsafe (ushort[] clusterMap, ushort[] glyphIndices, int[] glyphAdvances, GlyphOffset[] glyphOffsets)? + GetGlyphsAndPlacements( + string text, + Font font, + ItemProps itemProps, + double fontSize = 12.0, + bool isSideways = false, + bool isRightToLeft = false) + { + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + ushort[] clusterMap; + ushort[] glyphIndices; + int[] glyphAdvances; + GlyphOffset[] glyphOffsets; + + fixed (char* pText = text) + { + analyzer.GetGlyphsAndTheirPlacements( + pText, + (uint)text.Length, + font, + blankGlyphIndex, + isSideways, + isRightToLeft, + CultureInfo.InvariantCulture, + null, // features + null, // featureRangeLengths + fontSize, + 1.0, // scalingFactor + 1.0f, // pixelsPerDip + System.Windows.Media.TextFormattingMode.Ideal, + itemProps, + out clusterMap, + out glyphIndices, + out glyphAdvances, + out glyphOffsets + ); + } + + return (clusterMap, glyphIndices, glyphAdvances, glyphOffsets); + } + + #region Infrastructure Verification Tests + + [Fact] + public void TextAnalyzer_CanBeCreated() + { + var analyzer = GetTextAnalyzer(); + analyzer.Should().NotBeNull(); + } + + [Fact] + public void ClassificationUtility_IsAvailable() + { + var classification = ClassificationUtility.Instance; + classification.Should().NotBeNull(); + } + + [Fact] + public void DWriteFactory_HasValidFactoryPointer() + { + var factory = DWriteFactory.Instance; + factory.Should().NotBeNull(); + + unsafe + { + var ptr = factory.DWriteFactory; + ((IntPtr)ptr).Should().NotBe(IntPtr.Zero); + } + } + + [Fact] + public void TestFont_IsAvailable() + { + var font = GetTestFont(); + // Skip test if Arial is not available + font.Weight.Should().Be(FontWeight.Normal); + } + + #endregion + + #region Basic Itemization Tests + + [Fact] + public void Itemize_EmptyString_ReturnsNull() + { + var result = ItemizeHelper.Itemize(""); + result.Should().BeNull(); + } + + [Fact] + public void Itemize_NullString_ReturnsNull() + { + var result = ItemizeHelper.Itemize(null!); + result.Should().BeNull(); + } + + [Fact] + public void Itemize_SingleLatinCharacter_ReturnsSingleSpan() + { + var result = ItemizeHelper.Itemize("A"); + + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result![0].length.Should().Be(1); + } + + [Fact] + public void Itemize_SimpleLatinText_ReturnsSingleSpan() + { + var result = ItemizeHelper.Itemize("Hello World"); + + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result![0].length.Should().Be(11); + + // The element should be an ItemProps + result[0].element.Should().BeOfType(); + var props = (ItemProps)result[0].element; + props.IsLatin.Should().BeTrue(); + } + + [Fact] + public void Itemize_LatinText_ItemPropsHasValidProperties() + { + var result = ItemizeHelper.Itemize("Test"); + + result.Should().NotBeNull(); + var props = (ItemProps)result![0].element; + + props.IsLatin.Should().BeTrue(); + props.IsIndic.Should().BeFalse(); + props.HasCombiningMark.Should().BeFalse(); + } + + [Fact] + public void Itemize_WithEnglishCulture_Succeeds() + { + var result = ItemizeHelper.Itemize("Hello", CultureInfo.GetCultureInfo("en-US")); + + result.Should().NotBeNull(); + result.Should().HaveCount(1); + } + + #endregion + + #region Mixed Script Tests + + [Fact] + public void Itemize_MixedLatinAndArabic_ReturnsMultipleSpans() + { + // "Hello" + Arabic "مرحبا" (Marhaba) + var result = ItemizeHelper.Itemize("Hello مرحبا"); + + result.Should().NotBeNull(); + // Should have at least 2 spans: Latin and Arabic (possibly more for space) + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + // Total length should match input + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(11); // "Hello " (6) + "مرحبا" (5) + } + + [Fact] + public void Itemize_MixedLatinAndChinese_ReturnsMultipleSpans() + { + // "Hello" + Chinese "你好" (Nǐ hǎo) + var result = ItemizeHelper.Itemize("Hello你好"); + + result.Should().NotBeNull(); + // Should have at least 2 spans: Latin and Chinese + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + // First span should be Latin + var firstProps = (ItemProps)result[0].element; + firstProps.IsLatin.Should().BeTrue(); + + // Total length should match + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(7); // "Hello" (5) + "你好" (2) + } + + [Fact] + public void Itemize_MixedLatinAndCyrillic_ReturnsMultipleSpans() + { + // "Hello" + Russian "Привет" (Privet) + var result = ItemizeHelper.Itemize("Hello Привет"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(12); // "Hello " (6) + "Привет" (6) + } + + [Fact] + public void Itemize_MixedLatinAndGreek_ReturnsMultipleSpans() + { + // "Hello" + Greek "Γειά" (Geia) + var result = ItemizeHelper.Itemize("Hello Γειά"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(10); + } + + [Fact] + public void Itemize_MixedLatinAndHebrew_ReturnsMultipleSpans() + { + // "Hello" + Hebrew "שלום" (Shalom) + var result = ItemizeHelper.Itemize("Hello שלום"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(10); + } + + [Fact] + public void Itemize_MixedLatinAndJapanese_ReturnsMultipleSpans() + { + // "Hello" + Japanese "こんにちは" (Konnichiwa) + var result = ItemizeHelper.Itemize("Helloこんにちは"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + var firstProps = (ItemProps)result[0].element; + firstProps.IsLatin.Should().BeTrue(); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(10); // "Hello" (5) + "こんにちは" (5) + } + + [Fact] + public void Itemize_MixedLatinAndKorean_ReturnsMultipleSpans() + { + // "Hello" + Korean "안녕하세요" (Annyeonghaseyo) + var result = ItemizeHelper.Itemize("Hello안녕하세요"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(10); // "Hello" (5) + "안녕하세요" (5) + } + + [Fact] + public void Itemize_ThreeScripts_ReturnsMultipleSpans() + { + // Latin + Chinese + Arabic + var result = ItemizeHelper.Itemize("Hi你好مرحبا"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(3); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(9); // "Hi" (2) + "你好" (2) + "مرحبا" (5) + } + + #endregion + + #region RTL (Right-to-Left) Tests + + [Fact] + public void Itemize_ArabicText_Succeeds() + { + // Arabic "مرحبا بالعالم" (Hello World) + var result = ItemizeHelper.Itemize("مرحبا بالعالم"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(13); + } + + [Fact] + public void Itemize_HebrewText_Succeeds() + { + // Hebrew "שלום עולם" (Hello World) + var result = ItemizeHelper.Itemize("שלום עולם"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + + int totalLength = result.Sum(s => s.length); + totalLength.Should().Be(9); + } + + [Fact] + public void Itemize_WithRtlParagraphDirection_Succeeds() + { + var result = ItemizeHelper.Itemize("Hello", isRightToLeft: true); + + result.Should().NotBeNull(); + result.Should().HaveCount(1); + } + + [Fact] + public void Itemize_ArabicWithRtlParagraph_Succeeds() + { + var result = ItemizeHelper.Itemize("مرحبا", isRightToLeft: true); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + } + + #endregion + + #region Indic Script Tests + + [Fact] + public void Itemize_DevanagariText_FlaggedAsIndic() + { + // Hindi "नमस्ते" (Namaste) + var result = ItemizeHelper.Itemize("नमस्ते"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + + // At least one span should be marked as Indic + var hasIndicSpan = result.Any(s => ((ItemProps)s.element).IsIndic); + hasIndicSpan.Should().BeTrue(); + } + + [Fact] + public void Itemize_BengaliText_FlaggedAsIndic() + { + // Bengali "নমস্কার" (Nomoshkar) + var result = ItemizeHelper.Itemize("নমস্কার"); + + result.Should().NotBeNull(); + var hasIndicSpan = result!.Any(s => ((ItemProps)s.element).IsIndic); + hasIndicSpan.Should().BeTrue(); + } + + [Fact] + public void Itemize_TamilText_FlaggedAsIndic() + { + // Tamil "வணக்கம்" (Vanakkam) + var result = ItemizeHelper.Itemize("வணக்கம்"); + + result.Should().NotBeNull(); + var hasIndicSpan = result!.Any(s => ((ItemProps)s.element).IsIndic); + hasIndicSpan.Should().BeTrue(); + } + + [Fact] + public void Itemize_TeluguText_FlaggedAsIndic() + { + // Telugu "నమస్కారం" (Namaskaram) + var result = ItemizeHelper.Itemize("నమస్కారం"); + + result.Should().NotBeNull(); + var hasIndicSpan = result!.Any(s => ((ItemProps)s.element).IsIndic); + hasIndicSpan.Should().BeTrue(); + } + + [Fact] + public void Itemize_ThaiText_Succeeds() + { + // Thai "สวัสดี" (Sawasdee) + var result = ItemizeHelper.Itemize("สวัสดี"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public void Itemize_MixedLatinAndDevanagari_ReturnsMultipleSpans() + { + var result = ItemizeHelper.Itemize("Hello नमस्ते"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(2); + + // First span should be Latin + var firstProps = (ItemProps)result[0].element; + firstProps.IsLatin.Should().BeTrue(); + firstProps.IsIndic.Should().BeFalse(); + + // Should have an Indic span + var hasIndicSpan = result.Any(s => ((ItemProps)s.element).IsIndic); + hasIndicSpan.Should().BeTrue(); + } + + #endregion + + #region Combining Mark Tests + + [Fact] + public void Itemize_LatinWithCombiningAccent_FlaggedAsCombining() + { + // 'e' followed by combining acute accent (U+0301) = é + var result = ItemizeHelper.Itemize("e\u0301"); + + result.Should().NotBeNull(); + + // Should have a span with combining mark flag + var hasCombiningSpan = result!.Any(s => ((ItemProps)s.element).HasCombiningMark); + hasCombiningSpan.Should().BeTrue(); + } + + [Fact] + public void Itemize_PrecomposedAccent_NoCombiningFlag() + { + // Precomposed é (U+00E9) - no combining mark + var result = ItemizeHelper.Itemize("é"); + + result.Should().NotBeNull(); + + // Precomposed characters should not have combining mark flag + var props = (ItemProps)result![0].element; + props.HasCombiningMark.Should().BeFalse(); + } + + [Fact] + public void Itemize_MultipleCombiningMarks_FlaggedAsCombining() + { + // 'a' + combining grave (U+0300) + combining acute (U+0301) + var result = ItemizeHelper.Itemize("a\u0300\u0301"); + + result.Should().NotBeNull(); + + var hasCombiningSpan = result!.Any(s => ((ItemProps)s.element).HasCombiningMark); + hasCombiningSpan.Should().BeTrue(); + } + + [Fact] + public void Itemize_CombiningDiaeresis_FlaggedAsCombining() + { + // 'u' + combining diaeresis (U+0308) = ü + var result = ItemizeHelper.Itemize("u\u0308"); + + result.Should().NotBeNull(); + + var hasCombiningSpan = result!.Any(s => ((ItemProps)s.element).HasCombiningMark); + hasCombiningSpan.Should().BeTrue(); + } + + #endregion + + #region Number Substitution Tests + + [Fact] + public void Itemize_DigitsWithNoCulture_Succeeds() + { + var result = ItemizeHelper.Itemize("123456"); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public void Itemize_DigitsWithEnglishCulture_Succeeds() + { + var result = ItemizeHelper.Itemize("123", numberCulture: CultureInfo.GetCultureInfo("en-US")); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public void Itemize_DigitsWithArabicCulture_Succeeds() + { + var result = ItemizeHelper.Itemize("123", numberCulture: CultureInfo.GetCultureInfo("ar-SA")); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public void Itemize_MixedTextAndDigits_Succeeds() + { + var result = ItemizeHelper.Itemize("Price: $123.45"); + + result.Should().NotBeNull(); + + int totalLength = result!.Sum(s => s.length); + totalLength.Should().Be(14); + } + + [Fact] + public void Itemize_DigitsWithNumberSubstitutionMethod_Succeeds() + { + // NumberSubstitutionMethod: 0=FromCulture, 1=None, 2=Context, 3=NativeNational + var result = ItemizeHelper.Itemize( + "123", + numberCulture: CultureInfo.GetCultureInfo("ar-SA"), + numberSubstitutionMethod: 3); // NativeNational + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + } + + #endregion + + #region ItemProps Tests + + [Fact] + public unsafe void ItemProps_FromItemize_HasValidScriptAnalysis() + { + var result = ItemizeHelper.Itemize("Hello"); + + result.Should().NotBeNull(); + var props = (ItemProps)result![0].element; + + // ScriptAnalysis should be a valid pointer (not null for real text) + void* scriptPtr = props.ScriptAnalysis; + ((IntPtr)scriptPtr).Should().NotBe(IntPtr.Zero); + } + + [Fact] + public void ItemProps_LatinAndArabic_HaveDifferentScriptAnalysis() + { + var latinResult = ItemizeHelper.Itemize("Hello"); + var arabicResult = ItemizeHelper.Itemize("مرحبا"); + + latinResult.Should().NotBeNull(); + arabicResult.Should().NotBeNull(); + + var latinProps = (ItemProps)latinResult![0].element; + var arabicProps = (ItemProps)arabicResult![0].element; + + // They should have different properties + latinProps.IsLatin.Should().BeTrue(); + arabicProps.IsLatin.Should().BeFalse(); + } + + [Fact] + public void ItemProps_CanShapeTogether_SameScript_ReturnsTrue() + { + var result = ItemizeHelper.Itemize("Hello World"); + + result.Should().NotBeNull(); + // If there's only one span, it can shape with itself + if (result!.Count == 1) + { + var props = (ItemProps)result[0].element; + props.CanShapeTogether(props).Should().BeTrue(); + } + } + + [Fact] + public void ItemProps_NeedsCaretInfo_TrueForIndic() + { + // Devanagari text should need caret info + var result = ItemizeHelper.Itemize("नमस्ते"); + + result.Should().NotBeNull(); + + // At least one span should need caret info (Indic scripts do) + var needsCaretInfo = result!.Any(s => ((ItemProps)s.element).NeedsCaretInfo); + needsCaretInfo.Should().BeTrue(); + } + + [Fact] + public void ItemProps_NeedsCaretInfo_FalseForLatin() + { + var result = ItemizeHelper.Itemize("Hello"); + + result.Should().NotBeNull(); + var props = (ItemProps)result![0].element; + + // Latin text should not need special caret info + props.NeedsCaretInfo.Should().BeFalse(); + } + + #endregion + + #region Edge Cases + + [Fact] + public void Itemize_SingleSpace_Succeeds() + { + var result = ItemizeHelper.Itemize(" "); + + result.Should().NotBeNull(); + result!.Count.Should().BeGreaterThanOrEqualTo(1); + result.Sum(s => s.length).Should().Be(1); + } + + [Fact] + public void Itemize_OnlySpaces_Succeeds() + { + var result = ItemizeHelper.Itemize(" "); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(5); + } + + [Fact] + public void Itemize_Newlines_Succeeds() + { + var result = ItemizeHelper.Itemize("Hello\nWorld"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(11); + } + + [Fact] + public void Itemize_Tabs_Succeeds() + { + var result = ItemizeHelper.Itemize("Hello\tWorld"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(11); + } + + [Fact] + public void Itemize_ControlCharacters_Succeeds() + { + // Control characters may be filtered by DirectWrite + // The important thing is that it doesn't throw + var result = ItemizeHelper.Itemize("A\x01\x02\x03B"); + + result.Should().NotBeNull(); + // Just verify we get some result - control char handling is implementation-defined + result!.Sum(s => s.length).Should().BeGreaterThan(0); + } + + [Fact] + public void Itemize_SurrogatePair_Succeeds() + { + // Emoji: 😀 (U+1F600) = surrogate pair + var result = ItemizeHelper.Itemize("😀"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(2); // Surrogate pair = 2 chars + } + + [Fact] + public void Itemize_MultipleSurrogatePairs_Succeeds() + { + // Multiple emoji + var result = ItemizeHelper.Itemize("😀😁😂"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(6); // 3 emoji * 2 chars each + } + + [Fact] + public void Itemize_MixedTextAndEmoji_Succeeds() + { + var result = ItemizeHelper.Itemize("Hello 😀 World"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(14); // "Hello " (6) + 😀 (2) + " World" (6) + } + + [Fact] + public void Itemize_LongText_Succeeds() + { + var longText = new string('A', 10000); + var result = ItemizeHelper.Itemize(longText); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(10000); + } + + [Fact] + public void Itemize_UnicodePrivateUseArea_Succeeds() + { + // Private Use Area character + var result = ItemizeHelper.Itemize("\uE000"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(1); + } + + [Fact] + public void Itemize_ZeroWidthChars_Succeeds() + { + // Zero-width space (U+200B) and zero-width joiner (U+200D) + var result = ItemizeHelper.Itemize("A\u200B\u200DB"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(4); + } + + [Fact] + public void Itemize_BidiControlChars_Succeeds() + { + // LRM (U+200E), RLM (U+200F) + var result = ItemizeHelper.Itemize("A\u200E\u200FB"); + + result.Should().NotBeNull(); + result!.Sum(s => s.length).Should().Be(4); + } + + [Fact] + public void Itemize_AllCultures_DoNotThrow() + { + var cultures = new[] + { + "en-US", "fr-FR", "de-DE", "ja-JP", "zh-CN", "ar-SA", "he-IL", "hi-IN", "ru-RU", "ko-KR" + }; + + foreach (var cultureName in cultures) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + var result = ItemizeHelper.Itemize("Test 123", culture); + + result.Should().NotBeNull($"Culture {cultureName} should not return null"); + } + } + + #endregion + + #region Phase 3: Glyph Shaping Tests + + [Fact] + public void GetGlyphsAndTheirPlacements_SimpleAscii_ReturnsGlyphData() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Hello"); + spans.Should().NotBeNull(); + + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements("Hello", font, itemProps); + + result.Should().NotBeNull(); + var (clusterMap, glyphIndices, glyphAdvances, glyphOffsets) = result!.Value; + + // Should have cluster map entries for each character + clusterMap.Should().HaveCount(5); + + // Should have glyph indices (at least one per character for simple text) + glyphIndices.Should().NotBeEmpty(); + glyphIndices.Length.Should().BeGreaterThanOrEqualTo(5); + + // All glyph indices should be non-zero (valid glyphs) + glyphIndices.Take(5).Should().AllSatisfy(g => g.Should().BeGreaterThan((ushort)0)); + + // Advances should be positive + glyphAdvances.Should().NotBeEmpty(); + glyphAdvances.Take(5).Should().AllSatisfy(a => a.Should().BeGreaterThan(0)); + + // Offsets should exist + glyphOffsets.Should().NotBeEmpty(); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_SingleCharacter_ReturnsOneGlyph() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("A"); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements("A", font, itemProps); + + result.Should().NotBeNull(); + var (clusterMap, glyphIndices, glyphAdvances, _) = result!.Value; + + clusterMap.Should().HaveCount(1); + glyphIndices.Length.Should().BeGreaterThanOrEqualTo(1); + glyphAdvances.Length.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_WithSpaces_HandlesSpacesCorrectly() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("A B"); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements("A B", font, itemProps); + + result.Should().NotBeNull(); + var (clusterMap, glyphIndices, glyphAdvances, glyphOffsets) = result!.Value; + + clusterMap.Should().HaveCount(3); + // All characters should map to valid glyphs + glyphIndices.Take(3).Should().AllSatisfy(g => g.Should().BeGreaterThan((ushort)0)); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_DifferentFontSizes_ScalesAdvances() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("A"); + var itemProps = (ItemProps)spans![0].element; + + var result12 = GetGlyphsAndPlacements("A", font, itemProps, fontSize: 12.0); + var result24 = GetGlyphsAndPlacements("A", font, itemProps, fontSize: 24.0); + + result12.Should().NotBeNull(); + result24.Should().NotBeNull(); + + // Larger font size should produce larger advances + var advance12 = result12!.Value.glyphAdvances[0]; + var advance24 = result24!.Value.glyphAdvances[0]; + + advance24.Should().BeGreaterThan(advance12); + // Should be approximately 2x (not exact due to rounding) + ((double)advance24 / advance12).Should().BeApproximately(2.0, 0.1); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_ClusterMap_MapsCharactersToGlyphs() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("ABC"); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements("ABC", font, itemProps); + + result.Should().NotBeNull(); + var clusterMap = result!.Value.clusterMap; + + // Cluster map should be monotonically non-decreasing + for (int i = 1; i < clusterMap.Length; i++) + { + clusterMap[i].Should().BeGreaterThanOrEqualTo(clusterMap[i - 1]); + } + } + + [Fact] + public void GetGlyphsAndTheirPlacements_RightToLeft_Succeeds() + { + var font = GetTestFont(); + // Use Arabic text for RTL + var spans = ItemizeHelper.Itemize("مرحبا"); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var itemProps = (ItemProps)spans[0].element; + var result = GetGlyphsAndPlacements("مرحبا", font, itemProps, isRightToLeft: true); + + result.Should().NotBeNull(); + result!.Value.glyphIndices.Should().NotBeEmpty(); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_LongText_Succeeds() + { + var font = GetTestFont(); + var longText = new string('A', 1000); + var spans = ItemizeHelper.Itemize(longText); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements(longText, font, itemProps); + + result.Should().NotBeNull(); + var (clusterMap, glyphIndices, _, _) = result!.Value; + + clusterMap.Should().HaveCount(1000); + glyphIndices.Length.Should().BeGreaterThanOrEqualTo(1000); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_MixedCase_ReturnsDistinctGlyphs() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Aa"); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements("Aa", font, itemProps); + + result.Should().NotBeNull(); + var glyphIndices = result!.Value.glyphIndices; + + // 'A' and 'a' should have different glyph indices + glyphIndices[0].Should().NotBe(glyphIndices[1]); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_Numbers_ReturnsValidGlyphs() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("0123456789"); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements("0123456789", font, itemProps); + + result.Should().NotBeNull(); + var glyphIndices = result!.Value.glyphIndices; + + // All digits should have valid glyph indices + glyphIndices.Take(10).Should().AllSatisfy(g => g.Should().BeGreaterThan((ushort)0)); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_Punctuation_ReturnsValidGlyphs() + { + var font = GetTestFont(); + var text = ".,!?;:"; + var spans = ItemizeHelper.Itemize(text); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements(text, font, itemProps); + + result.Should().NotBeNull(); + var glyphIndices = result!.Value.glyphIndices; + + // All punctuation should have valid glyph indices + glyphIndices.Take(text.Length).Should().AllSatisfy(g => g.Should().BeGreaterThan((ushort)0)); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_GlyphOffsets_AreReasonable() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Hello"); + var itemProps = (ItemProps)spans![0].element; + var result = GetGlyphsAndPlacements("Hello", font, itemProps); + + result.Should().NotBeNull(); + var glyphOffsets = result!.Value.glyphOffsets; + + // For simple Latin text, offsets should generally be zero or small + foreach (var offset in glyphOffsets.Take(5)) + { + // du is horizontal offset, dv is vertical offset + Math.Abs(offset.du).Should().BeLessThan(1000); + Math.Abs(offset.dv).Should().BeLessThan(1000); + } + } + + [Fact] + public void GetGlyphsAndTheirPlacements_ConsistentResults_ForSameInput() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Test"); + var itemProps = (ItemProps)spans![0].element; + + var result1 = GetGlyphsAndPlacements("Test", font, itemProps); + var result2 = GetGlyphsAndPlacements("Test", font, itemProps); + + result1.Should().NotBeNull(); + result2.Should().NotBeNull(); + + // Results should be identical for the same input + result1!.Value.glyphIndices.Should().BeEquivalentTo(result2!.Value.glyphIndices); + result1!.Value.glyphAdvances.Should().BeEquivalentTo(result2!.Value.glyphAdvances); + } + + [Fact] + public void GetGlyphsAndTheirPlacements_DisplayMode_DiffersFromIdeal() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Hello"); + var itemProps = (ItemProps)spans![0].element; + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + ushort[] clusterMapIdeal, clusterMapDisplay; + ushort[] glyphIndicesIdeal, glyphIndicesDisplay; + int[] glyphAdvancesIdeal, glyphAdvancesDisplay; + GlyphOffset[] glyphOffsetsIdeal, glyphOffsetsDisplay; + + unsafe + { + fixed (char* pText = "Hello") + { + analyzer.GetGlyphsAndTheirPlacements( + pText, 5, font, blankGlyphIndex, false, false, + CultureInfo.InvariantCulture, null, null, + 12.0, 1.0, 1.0f, + System.Windows.Media.TextFormattingMode.Ideal, + itemProps, + out clusterMapIdeal, out glyphIndicesIdeal, out glyphAdvancesIdeal, out glyphOffsetsIdeal); + + analyzer.GetGlyphsAndTheirPlacements( + pText, 5, font, blankGlyphIndex, false, false, + CultureInfo.InvariantCulture, null, null, + 12.0, 1.0, 1.0f, + System.Windows.Media.TextFormattingMode.Display, + itemProps, + out clusterMapDisplay, out glyphIndicesDisplay, out glyphAdvancesDisplay, out glyphOffsetsDisplay); + } + } + + // Glyph indices should be the same + glyphIndicesIdeal.Should().BeEquivalentTo(glyphIndicesDisplay); + + // Advances may differ between Ideal and Display modes due to rounding + // Just verify both return valid data + glyphAdvancesIdeal.Should().NotBeEmpty(); + glyphAdvancesDisplay.Should().NotBeEmpty(); + } + + [Fact] + public void GetBlankGlyphIndex_ReturnsValidIndex() + { + var font = GetTestFont(); + var blankIndex = GetBlankGlyphIndex(font); + + // Blank glyph index should be valid (could be 0 for .notdef or space glyph index) + // Just verify it doesn't throw and returns a value + blankIndex.Should().BeGreaterThanOrEqualTo((ushort)0); + } + + [Fact] + public void TextAnalyzer_GetGlyphsAndTheirPlacements_WithFeatures_Succeeds() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("fi"); // potential ligature + var itemProps = (ItemProps)spans![0].element; + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + + // Create a simple feature set (liga = ligatures) + var features = new DWriteFontFeature[][] + { + new DWriteFontFeature[] + { + new DWriteFontFeature(DWriteFontFeatureTag.StandardLigatures, 1) + } + }; + var featureRanges = new uint[] { 2 }; // applies to 2 characters + + ushort[] clusterMap; + ushort[] glyphIndices; + int[] glyphAdvances; + GlyphOffset[] glyphOffsets; + + unsafe + { + fixed (char* pText = "fi") + { + analyzer.GetGlyphsAndTheirPlacements( + pText, 2, font, blankGlyphIndex, false, false, + CultureInfo.InvariantCulture, features, featureRanges, + 12.0, 1.0, 1.0f, + System.Windows.Media.TextFormattingMode.Ideal, + itemProps, + out clusterMap, out glyphIndices, out glyphAdvances, out glyphOffsets); + } + } + + // Should succeed regardless of whether ligature is applied + clusterMap.Should().HaveCount(2); + glyphIndices.Should().NotBeEmpty(); + } + + #endregion + + #region Phase 4: GetGlyphPlacements Direct Tests + + /// + /// Helper to call GetGlyphs and then GetGlyphPlacements separately. + /// This tests the lower-level API that GetGlyphsAndTheirPlacements combines. + /// + private static unsafe (int[] glyphAdvances, GlyphOffset[] glyphOffsets)? + GetGlyphPlacementsDirect( + string text, + Font font, + ItemProps itemProps, + double fontSize = 12.0, + bool isSideways = false, + bool isRightToLeft = false) + { + var analyzer = GetTextAnalyzer(); + ushort blankGlyphIndex = GetBlankGlyphIndex(font); + uint textLength = (uint)text.Length; + uint maxGlyphCount = textLength * 3 + 16; // Standard DWrite buffer size formula + + // Allocate buffers for GetGlyphs output + ushort[] clusterMap = new ushort[textLength]; + ushort[] textProps = new ushort[textLength]; + ushort[] glyphIndices = new ushort[maxGlyphCount]; + uint[] glyphProps = new uint[maxGlyphCount]; + int[] canGlyphAlone = new int[textLength]; + uint actualGlyphCount = 0; + + fixed (char* pText = text) + fixed (ushort* pClusterMap = clusterMap) + fixed (ushort* pTextProps = textProps) + fixed (ushort* pGlyphIndices = glyphIndices) + fixed (uint* pGlyphProps = glyphProps) + fixed (int* pCanGlyphAlone = canGlyphAlone) + { + // Step 1: Call GetGlyphs to get glyph indices + analyzer.GetGlyphs( + pText, + textLength, + font, + blankGlyphIndex, + isSideways, + isRightToLeft, + CultureInfo.InvariantCulture, + null, // features + null, // featureRangeLengths + maxGlyphCount, + System.Windows.Media.TextFormattingMode.Ideal, + itemProps, + pClusterMap, + pTextProps, + pGlyphIndices, + pGlyphProps, + pCanGlyphAlone, + out actualGlyphCount + ); + + // Step 2: Call GetGlyphPlacements with the glyph data + int[] glyphAdvances = new int[actualGlyphCount]; + GlyphOffset[] glyphOffsets; + + fixed (int* pGlyphAdvances = glyphAdvances) + { + analyzer.GetGlyphPlacements( + pText, + pClusterMap, + pTextProps, + textLength, + pGlyphIndices, + pGlyphProps, + actualGlyphCount, + font, + fontSize, + 1.0, // scalingFactor + isSideways, + isRightToLeft, + CultureInfo.InvariantCulture, + null, // features + null, // featureRangeLengths + System.Windows.Media.TextFormattingMode.Ideal, + itemProps, + 1.0f, // pixelsPerDip + pGlyphAdvances, + out glyphOffsets + ); + } + + return (glyphAdvances, glyphOffsets); + } + } + + [Fact] + public void GetGlyphPlacements_SimpleText_ReturnsAdvances() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Hello"); + var itemProps = (ItemProps)spans![0].element; + + var result = GetGlyphPlacementsDirect("Hello", font, itemProps); + + result.Should().NotBeNull(); + var (glyphAdvances, glyphOffsets) = result!.Value; + + // Should have advances for each glyph + glyphAdvances.Should().NotBeEmpty(); + glyphAdvances.Length.Should().BeGreaterThanOrEqualTo(5); + + // All advances should be positive + glyphAdvances.Should().AllSatisfy(a => a.Should().BeGreaterThan(0)); + + // Offsets should exist + glyphOffsets.Should().NotBeEmpty(); + } + + [Fact] + public void GetGlyphPlacements_DifferentFontSizes_ScalesAdvances() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("A"); + var itemProps = (ItemProps)spans![0].element; + + var result12 = GetGlyphPlacementsDirect("A", font, itemProps, fontSize: 12.0); + var result24 = GetGlyphPlacementsDirect("A", font, itemProps, fontSize: 24.0); + + result12.Should().NotBeNull(); + result24.Should().NotBeNull(); + + var advance12 = result12!.Value.glyphAdvances[0]; + var advance24 = result24!.Value.glyphAdvances[0]; + + // Larger font = larger advances + advance24.Should().BeGreaterThan(advance12); + ((double)advance24 / advance12).Should().BeApproximately(2.0, 0.1); + } + + [Fact] + public void GetGlyphPlacements_MultipleCharacters_ReturnsCorrectCount() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("ABCDE"); + var itemProps = (ItemProps)spans![0].element; + + var result = GetGlyphPlacementsDirect("ABCDE", font, itemProps); + + result.Should().NotBeNull(); + var (glyphAdvances, glyphOffsets) = result!.Value; + + // For simple Latin, should have 5 glyphs + glyphAdvances.Length.Should().Be(5); + glyphOffsets.Length.Should().Be(5); + } + + [Fact] + public void GetGlyphPlacements_Offsets_AreReasonable() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Test"); + var itemProps = (ItemProps)spans![0].element; + + var result = GetGlyphPlacementsDirect("Test", font, itemProps); + + result.Should().NotBeNull(); + var glyphOffsets = result!.Value.glyphOffsets; + + // For simple Latin text, offsets should be small/zero + foreach (var offset in glyphOffsets) + { + Math.Abs(offset.du).Should().BeLessThan(1000); + Math.Abs(offset.dv).Should().BeLessThan(1000); + } + } + + [Fact] + public void GetGlyphPlacements_RightToLeft_Succeeds() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("مرحبا"); + Assert.SkipUnless(spans != null && spans.Count > 0, "Itemization failed"); + + var itemProps = (ItemProps)spans[0].element; + + var result = GetGlyphPlacementsDirect("مرحبا", font, itemProps, isRightToLeft: true); + + result.Should().NotBeNull(); + result!.Value.glyphAdvances.Should().NotBeEmpty(); + } + + [Fact] + public void GetGlyphPlacements_ConsistentWithGetGlyphsAndTheirPlacements() + { + var font = GetTestFont(); + var spans = ItemizeHelper.Itemize("Hello"); + var itemProps = (ItemProps)spans![0].element; + + // Get results from both methods + var directResult = GetGlyphPlacementsDirect("Hello", font, itemProps); + var combinedResult = GetGlyphsAndPlacements("Hello", font, itemProps); + + directResult.Should().NotBeNull(); + combinedResult.Should().NotBeNull(); + + // Advances should match + directResult!.Value.glyphAdvances.Should().BeEquivalentTo(combinedResult!.Value.glyphAdvances); + } + + #endregion +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TrueTypeSubsetterTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TrueTypeSubsetterTests.cs new file mode 100644 index 00000000000..bbf64c98105 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TrueTypeSubsetterTests.cs @@ -0,0 +1,341 @@ +// 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; + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for class. +/// +public class TrueTypeSubsetterTests +{ + // TrueType font structure constants + private const uint TrueTypeSignature = 0x00010000; + private const int OffsetTableSize = 12; // sfntVersion(4) + numTables(2) + searchRange(2) + entrySelector(2) + rangeShift(2) + private const int TableRecordSize = 16; // tag(4) + checksum(4) + offset(4) + length(4) + + // Required TrueType tables for a valid subset + private static readonly string[] s_requiredTables = ["head", "hhea", "hmtx", "maxp", "cmap", "loca", "glyf"]; + + private byte[] LoadArialFontData() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial font not found"); + return File.ReadAllBytes(TestHelpers.ArialPath); + } + + /// + /// Gets glyph indices for the given code points using the specified font face. + /// + private static unsafe ushort[] GetGlyphIndices(FontFace fontFace, uint[] codePoints) + { + ushort[] glyphIndices = new ushort[codePoints.Length]; + fixed (uint* pCodePoints = codePoints) + fixed (ushort* pGlyphIndices = glyphIndices) + { + fontFace.GetArrayOfGlyphIndices(pCodePoints, (uint)codePoints.Length, pGlyphIndices); + } + return glyphIndices; + } + + /// + /// Reads a 4-character table tag from a byte span. + /// + private static string ReadTableTag(ReadOnlySpan data) + { + return new string([(char)data[0], (char)data[1], (char)data[2], (char)data[3]]); + } + + /// + /// Finds a table in a TrueType font and returns its offset and length. + /// Returns (0, 0) if not found. + /// + private static (uint offset, uint length) FindTable(byte[] fontData, string tableTag) + { + ushort numTables = BinaryPrimitives.ReadUInt16BigEndian(fontData.AsSpan(4)); + + for (int i = 0; i < numTables; i++) + { + int recordOffset = OffsetTableSize + (i * TableRecordSize); + string tag = ReadTableTag(fontData.AsSpan(recordOffset, 4)); + if (tag == tableTag) + { + uint offset = BinaryPrimitives.ReadUInt32BigEndian(fontData.AsSpan(recordOffset + 8, 4)); + uint length = BinaryPrimitives.ReadUInt32BigEndian(fontData.AsSpan(recordOffset + 12, 4)); + return (offset, length); + } + } + + return (0, 0); + } + + /// + /// Reads numGlyphs from the maxp table. + /// maxp table structure: version (4 bytes), numGlyphs (2 bytes), ... + /// + private static ushort ReadMaxpNumGlyphs(byte[] fontData) + { + var (offset, length) = FindTable(fontData, "maxp"); + if (offset == 0 || length < 6) + return 0; + + return BinaryPrimitives.ReadUInt16BigEndian(fontData.AsSpan((int)offset + 4, 2)); + } + + /// + /// Parses the table directory from a TrueType font and returns table names. + /// + private static HashSet GetTableNames(byte[] fontData) + { + var tables = new HashSet(); + ushort numTables = BinaryPrimitives.ReadUInt16BigEndian(fontData.AsSpan(4)); + + for (int i = 0; i < numTables; i++) + { + int recordOffset = OffsetTableSize + (i * TableRecordSize); + string tag = ReadTableTag(fontData.AsSpan(recordOffset, 4)); + tables.Add(tag); + } + + return tables; + } + + [Fact] + public void ComputeSubset_WithValidFont_ShouldReturnValidTrueTypeStructure() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + ushort[] glyphArray = [0, 1, 2, 3]; // Basic glyph indices + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, glyphArray); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + + // Verify TrueType signature (sfntVersion = 0x00010000) + uint signature = BinaryPrimitives.ReadUInt32BigEndian(result.AsSpan(0, 4)); + signature.Should().Be(TrueTypeSignature, "Subset should have valid TrueType signature"); + + // Verify numTables is reasonable (at least the required tables) + ushort numTables = BinaryPrimitives.ReadUInt16BigEndian(result.AsSpan(4, 2)); + numTables.Should().BeGreaterThanOrEqualTo((ushort)s_requiredTables.Length, + $"Subset should contain at least {s_requiredTables.Length} tables"); + + // Verify offset table fields are consistent + ushort searchRange = BinaryPrimitives.ReadUInt16BigEndian(result.AsSpan(6, 2)); + ushort rangeShift = BinaryPrimitives.ReadUInt16BigEndian(result.AsSpan(10, 2)); + + // searchRange = (maximum power of 2 <= numTables) * 16 + int maxPow2 = 1; + while (maxPow2 * 2 <= numTables) maxPow2 *= 2; + searchRange.Should().Be((ushort)(maxPow2 * 16), "searchRange should be correctly calculated"); + + // rangeShift = numTables * 16 - searchRange + rangeShift.Should().Be((ushort)(numTables * 16 - searchRange), "rangeShift should be correctly calculated"); + } + } + } + + [Fact] + public void ComputeSubset_ShouldContainRequiredTables() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + ushort[] glyphArray = [0, 1, 2, 3]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, glyphArray); + + var subsetTables = GetTableNames(result); + + // Verify all required tables are present + foreach (var requiredTable in s_requiredTables) + { + subsetTables.Should().Contain(requiredTable, + $"Subset should contain required table '{requiredTable}'"); + } + } + } + } + + [Fact] + public void ComputeSubset_TableOffsetsAndLengths_ShouldBeValid() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + ushort[] glyphArray = [0, 1, 2, 3]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, glyphArray); + + ushort numTables = BinaryPrimitives.ReadUInt16BigEndian(result.AsSpan(4, 2)); + + for (int i = 0; i < numTables; i++) + { + int recordOffset = OffsetTableSize + (i * TableRecordSize); + + // Read table record fields + string tag = ReadTableTag(result.AsSpan(recordOffset, 4)); + uint tableOffset = BinaryPrimitives.ReadUInt32BigEndian(result.AsSpan(recordOffset + 8, 4)); + uint tableLength = BinaryPrimitives.ReadUInt32BigEndian(result.AsSpan(recordOffset + 12, 4)); + + // Verify offset and length are within bounds + tableOffset.Should().BeLessThan((uint)result.Length, + $"Table '{tag}' offset should be within file bounds"); + (tableOffset + tableLength).Should().BeLessThanOrEqualTo((uint)result.Length, + $"Table '{tag}' should not extend beyond file end"); + tableLength.Should().BeGreaterThan(0, + $"Table '{tag}' should have non-zero length"); + } + } + } + } + + [Fact] + public void ComputeSubset_WithNullGlyphArray_ShouldThrow() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + + Assert.Throws(() => + { + unsafe + { + fixed (byte* pFontData = fontData) + { + TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, null!); + } + } + }); + } + + [Fact] + public void ComputeSubset_WithEmptyGlyphArray_ShouldThrow() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + ushort[] glyphArray = []; + + Assert.Throws(() => + { + unsafe + { + fixed (byte* pFontData = fontData) + { + TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, glyphArray); + } + } + }); + } + + [Fact] + public void ComputeSubset_SubsetShouldBeSmallerThanOriginal() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + + // Get actual glyph indices for characters 'A', 'B', 'C' plus .notdef (0) + var factory = DWriteFactory.Instance; + var fontFace = factory.CreateFontFace(new Uri(TestHelpers.ArialPath), 0); + ushort[] glyphIndices; + try + { + uint[] codePoints = ['A', 'B', 'C']; + var charGlyphs = GetGlyphIndices(fontFace, codePoints); + glyphIndices = [0, charGlyphs[0], charGlyphs[1], charGlyphs[2]]; // .notdef + A, B, C + } + finally + { + fontFace.Release(); + } + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, glyphIndices); + + result.Should().NotBeNull(); + + // Subset with few glyphs should be smaller than original + // Note: The subsetter keeps the full glyph table structure (maxp.numGlyphs unchanged) + // but only embeds outlines for the requested glyphs, reducing overall file size + result.Length.Should().BeLessThan(fontData.Length, + "Subset should be smaller than original font"); + } + } + } + + [Fact] + public void ComputeSubset_OutputShouldBeValidTrueType() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + ushort[] glyphArray = [0, 1, 2, 3]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, glyphArray); + + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(OffsetTableSize + TableRecordSize, + "Output should be large enough to contain header and at least one table record"); + + // Verify TrueType signature using BinaryPrimitives + uint signature = BinaryPrimitives.ReadUInt32BigEndian(result.AsSpan(0, 4)); + + // Valid TrueType signature is 0x00010000 + bool isValidTrueType = signature is 0x00010000 or 0x74727565 or 0x4F54544F; // 0x00010000, 'true', 'OTTO' + + isValidTrueType.Should().BeTrue($"Output should have valid TrueType signature, got 0x{signature:X8}"); + } + } + } + + [Fact] + public void ComputeSubset_WithDifferentGlyphCounts_ShouldProduceDifferentSizes() + { + var fontData = LoadArialFontData(); + + var arialUri = new Uri(TestHelpers.ArialPath); + + unsafe + { + fixed (byte* pFontData = fontData) + { + // Small subset + ushort[] smallGlyphArray = [0, 1, 2]; + var smallResult = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, smallGlyphArray); + + // Larger subset + ushort[] largeGlyphArray = Enumerable.Range(0, 100).Select(i => (ushort)i).ToArray(); + var largeResult = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, arialUri, 0, largeGlyphArray); + + smallResult.Should().NotBeNull(); + largeResult.Should().NotBeNull(); + + // More glyphs should generally result in larger subset + largeResult.Length.Should().BeGreaterThanOrEqualTo(smallResult.Length); + } + } + } +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TtfDeltaCoverageTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TtfDeltaCoverageTests.cs new file mode 100644 index 00000000000..b3282e4adf0 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TtfDeltaCoverageTests.cs @@ -0,0 +1,808 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests designed to improve code coverage for TtfDelta font subsetting. +/// These tests exercise different font types and glyph ranges to trigger +/// various code paths in the TtfDelta subsetter. +/// +public class TtfDeltaCoverageTests +{ + // Font paths for different test scenarios + private static readonly string s_simSunPath = Path.Combine(TestHelpers.FontsDirectory, "simsun.ttc"); + private static readonly string s_msGothicPath = Path.Combine(TestHelpers.FontsDirectory, "msgothic.ttc"); + private static readonly string s_segoeUIPath = Path.Combine(TestHelpers.FontsDirectory, "segoeui.ttf"); + private static readonly string s_segoeUIEmojiPath = Path.Combine(TestHelpers.FontsDirectory, "seguiemj.ttf"); + private static readonly string s_malgunPath = Path.Combine(TestHelpers.FontsDirectory, "malgun.ttf"); + private static readonly string s_gabriolaPath = Path.Combine(TestHelpers.FontsDirectory, "Gabriola.ttf"); + private static readonly string s_timesPath = Path.Combine(TestHelpers.FontsDirectory, "times.ttf"); + private static readonly string s_courierPath = Path.Combine(TestHelpers.FontsDirectory, "cour.ttf"); + private static readonly string s_tahomaPath = Path.Combine(TestHelpers.FontsDirectory, "tahoma.ttf"); + private static readonly string s_verdanaPath = Path.Combine(TestHelpers.FontsDirectory, "verdana.ttf"); + private static readonly string s_georgiaPath = Path.Combine(TestHelpers.FontsDirectory, "georgia.ttf"); + private static readonly string s_consolasPath = Path.Combine(TestHelpers.FontsDirectory, "consola.ttf"); + private static readonly string s_calibriPath = Path.Combine(TestHelpers.FontsDirectory, "calibri.ttf"); + private static readonly string s_comicSansPath = Path.Combine(TestHelpers.FontsDirectory, "comic.ttf"); + private static readonly string s_impactPath = Path.Combine(TestHelpers.FontsDirectory, "impact.ttf"); + private static readonly string s_symbolPath = Path.Combine(TestHelpers.FontsDirectory, "symbol.ttf"); + private static readonly string s_wingdingsPath = Path.Combine(TestHelpers.FontsDirectory, "wingding.ttf"); + private static readonly string s_webdingsPath = Path.Combine(TestHelpers.FontsDirectory, "webdings.ttf"); + private static readonly string s_segoeSymbolPath = Path.Combine(TestHelpers.FontsDirectory, "seguisym.ttf"); + + #region TTC (TrueType Collection) Tests - Exercises directory offset handling + + /// + /// Reads the TTC header to get the offset for a specific font index. + /// TTC format: 'ttcf' tag (4 bytes), version (4 bytes), numFonts (4 bytes), offset[numFonts] (4 bytes each) + /// + private static int GetTTCFontOffset(byte[] fontData, int fontIndex) + { + // Check for 'ttcf' signature + if (fontData.Length < 12) + return 0; + + uint tag = (uint)((fontData[0] << 24) | (fontData[1] << 16) | (fontData[2] << 8) | fontData[3]); + if (tag != 0x74746366) // 'ttcf' + return 0; + + uint numFonts = (uint)((fontData[8] << 24) | (fontData[9] << 16) | (fontData[10] << 8) | fontData[11]); + if (fontIndex >= numFonts) + return 0; + + // Offset table starts at byte 12 + int offsetPos = 12 + (fontIndex * 4); + if (offsetPos + 4 > fontData.Length) + return 0; + + return (int)((fontData[offsetPos] << 24) | (fontData[offsetPos + 1] << 16) | + (fontData[offsetPos + 2] << 8) | fontData[offsetPos + 3]); + } + + [Fact] + public void ComputeSubset_CambriaTTC_FirstFont_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.CambriaPath), "Cambria TTC not found"); + var fontData = File.ReadAllBytes(TestHelpers.CambriaPath); + var uri = new Uri(TestHelpers.CambriaPath); + + // Get the proper offset for the first font in the TTC + int fontOffset = GetTTCFontOffset(fontData, 0); + ushort[] glyphArray = Enumerable.Range(0, 50).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, fontOffset, glyphArray); + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + } + } + + [Fact] + public void ComputeSubset_SimSunTTC_WithCJKGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_simSunPath), "SimSun TTC not found"); + var fontData = File.ReadAllBytes(s_simSunPath); + var uri = new Uri(s_simSunPath); + + // Get the proper offset for the first font in the TTC + int fontOffset = GetTTCFontOffset(fontData, 0); + // CJK fonts have many glyphs - request a range that exercises CMAP handling + ushort[] glyphArray = Enumerable.Range(0, 500).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, fontOffset, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_MsGothicTTC_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_msGothicPath), "MS Gothic TTC not found"); + var fontData = File.ReadAllBytes(s_msGothicPath); + var uri = new Uri(s_msGothicPath); + + // Get the proper offset for the first font in the TTC + int fontOffset = GetTTCFontOffset(fontData, 0); + ushort[] glyphArray = Enumerable.Range(0, 200).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, fontOffset, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_CambriaTTC_SecondFont_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.CambriaPath), "Cambria TTC not found"); + var fontData = File.ReadAllBytes(TestHelpers.CambriaPath); + var uri = new Uri(TestHelpers.CambriaPath); + + // Get the proper offset for the second font in the TTC (Cambria Math) + int fontOffset = GetTTCFontOffset(fontData, 1); + Assert.SkipUnless(fontOffset > 0, "Second font not found in TTC"); + ushort[] glyphArray = Enumerable.Range(0, 100).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, fontOffset, glyphArray); + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + } + } + + #endregion + + #region High Glyph Index Tests - Exercises extended glyph handling + + [Fact] + public void ComputeSubset_WithHighGlyphIndices_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Request high glyph indices - exercises different code paths + ushort[] glyphArray = [0, 100, 500, 1000, 1500, 2000]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_WithSparseGlyphIndices_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Sparse glyph indices - exercises CMAP lookup + ushort[] glyphArray = [0, 10, 50, 100, 200, 500, 1000, 2000, 3000]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + #endregion + + #region Large Glyph Count Tests - Exercises table compression and optimization + + [Fact] + public void ComputeSubset_WithManyGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Request many glyphs to exercise table processing + ushort[] glyphArray = Enumerable.Range(0, 1000).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_WithVeryManyGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Even more glyphs - may trigger ERR_WOULD_GROW path + ushort[] glyphArray = Enumerable.Range(0, 2500).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_CJKFont_WithManyGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_malgunPath), "Malgun Gothic not found"); + var fontData = File.ReadAllBytes(s_malgunPath); + var uri = new Uri(s_malgunPath); + + // Korean font with many glyphs + ushort[] glyphArray = Enumerable.Range(0, 2000).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + #endregion + + #region Different Font Type Tests - Exercises various table handling code + + [Fact] + public void ComputeSubset_SegoeUI_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_segoeUIPath), "Segoe UI not found"); + var fontData = File.ReadAllBytes(s_segoeUIPath); + var uri = new Uri(s_segoeUIPath); + + ushort[] glyphArray = Enumerable.Range(0, 300).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Gabriola_OpenTypeFeatures_ShouldWork() + { + // Gabriola has many OpenType features (stylistic sets, swashes) + Assert.SkipUnless(File.Exists(s_gabriolaPath), "Gabriola not found"); + var fontData = File.ReadAllBytes(s_gabriolaPath); + var uri = new Uri(s_gabriolaPath); + + ushort[] glyphArray = Enumerable.Range(0, 500).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_TimesNewRoman_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_timesPath), "Times New Roman not found"); + var fontData = File.ReadAllBytes(s_timesPath); + var uri = new Uri(s_timesPath); + + ushort[] glyphArray = Enumerable.Range(0, 200).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_CourierNew_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_courierPath), "Courier New not found"); + var fontData = File.ReadAllBytes(s_courierPath); + var uri = new Uri(s_courierPath); + + ushort[] glyphArray = Enumerable.Range(0, 200).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Tahoma_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_tahomaPath), "Tahoma not found"); + var fontData = File.ReadAllBytes(s_tahomaPath); + var uri = new Uri(s_tahomaPath); + + ushort[] glyphArray = Enumerable.Range(0, 300).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Verdana_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_verdanaPath), "Verdana not found"); + var fontData = File.ReadAllBytes(s_verdanaPath); + var uri = new Uri(s_verdanaPath); + + ushort[] glyphArray = Enumerable.Range(0, 300).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Georgia_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_georgiaPath), "Georgia not found"); + var fontData = File.ReadAllBytes(s_georgiaPath); + var uri = new Uri(s_georgiaPath); + + ushort[] glyphArray = Enumerable.Range(0, 300).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Consolas_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_consolasPath), "Consolas not found"); + var fontData = File.ReadAllBytes(s_consolasPath); + var uri = new Uri(s_consolasPath); + + ushort[] glyphArray = Enumerable.Range(0, 300).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Calibri_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_calibriPath), "Calibri not found"); + var fontData = File.ReadAllBytes(s_calibriPath); + var uri = new Uri(s_calibriPath); + + ushort[] glyphArray = Enumerable.Range(0, 500).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_ComicSans_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_comicSansPath), "Comic Sans not found"); + var fontData = File.ReadAllBytes(s_comicSansPath); + var uri = new Uri(s_comicSansPath); + + ushort[] glyphArray = Enumerable.Range(0, 200).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Impact_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_impactPath), "Impact not found"); + var fontData = File.ReadAllBytes(s_impactPath); + var uri = new Uri(s_impactPath); + + ushort[] glyphArray = Enumerable.Range(0, 200).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + #endregion + + #region Symbol Font Tests - Exercises symbol CMAP encoding + + [Fact] + public void ComputeSubset_Symbol_ShouldWork() + { + // Symbol font uses a different CMAP encoding + Assert.SkipUnless(File.Exists(s_symbolPath), "Symbol not found"); + var fontData = File.ReadAllBytes(s_symbolPath); + var uri = new Uri(s_symbolPath); + + ushort[] glyphArray = Enumerable.Range(0, 100).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Wingdings_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_wingdingsPath), "Wingdings not found"); + var fontData = File.ReadAllBytes(s_wingdingsPath); + var uri = new Uri(s_wingdingsPath); + + ushort[] glyphArray = Enumerable.Range(0, 150).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_Webdings_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_webdingsPath), "Webdings not found"); + var fontData = File.ReadAllBytes(s_webdingsPath); + var uri = new Uri(s_webdingsPath); + + ushort[] glyphArray = Enumerable.Range(0, 150).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_SegoeSymbol_ShouldWork() + { + // Segoe UI Symbol has extended Unicode ranges + Assert.SkipUnless(File.Exists(s_segoeSymbolPath), "Segoe UI Symbol not found"); + var fontData = File.ReadAllBytes(s_segoeSymbolPath); + var uri = new Uri(s_segoeSymbolPath); + + ushort[] glyphArray = Enumerable.Range(0, 500).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + #endregion + + #region Emoji Font Tests - May exercise Format 12 CMAP + + [Fact] + public void ComputeSubset_SegoeUIEmoji_ShouldWork() + { + // Emoji font may have Format 12 CMAP for supplementary plane + Assert.SkipUnless(File.Exists(s_segoeUIEmojiPath), "Segoe UI Emoji not found"); + var fontData = File.ReadAllBytes(s_segoeUIEmojiPath); + var uri = new Uri(s_segoeUIEmojiPath); + + ushort[] glyphArray = Enumerable.Range(0, 500).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_SegoeUIEmoji_WithManyGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(s_segoeUIEmojiPath), "Segoe UI Emoji not found"); + var fontData = File.ReadAllBytes(s_segoeUIEmojiPath); + var uri = new Uri(s_segoeUIEmojiPath); + + // Emoji font has thousands of glyphs + ushort[] glyphArray = Enumerable.Range(0, 2000).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + #endregion + + #region Edge Case Glyph Patterns + + [Fact] + public void ComputeSubset_SingleGlyph_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Single glyph - minimum subset + ushort[] glyphArray = [0]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_ConsecutiveGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Consecutive glyphs - exercises sequential optimization + ushort[] glyphArray = Enumerable.Range(100, 200).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_RandomGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Random-ish pattern + ushort[] glyphArray = [0, 5, 17, 42, 88, 123, 256, 512, 777, 1024, 1111, 1500]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_BoundaryGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Boundary values + ushort[] glyphArray = [0, 1, 255, 256, 65534]; + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_AllLowGlyphs_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // All glyphs in low range (0-255) - exercises different table formats + ushort[] glyphArray = Enumerable.Range(0, 256).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + #endregion + + #region Glyph Range Pattern Tests + + [Fact] + public void ComputeSubset_LatinExtended_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Latin Extended range (glyphs for accented characters) + ushort[] glyphArray = Enumerable.Range(0, 400).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + [Fact] + public void ComputeSubset_GreekAndCyrillic_ShouldWork() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + // Greek and Cyrillic ranges + ushort[] glyphArray = Enumerable.Range(300, 400).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + result.Should().NotBeNull(); + } + } + } + + #endregion + + #region Output Validation Tests + + [Fact] + public void ComputeSubset_OutputHasValidStructure() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + var fontData = File.ReadAllBytes(TestHelpers.ArialPath); + var uri = new Uri(TestHelpers.ArialPath); + + ushort[] glyphArray = Enumerable.Range(0, 100).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pFontData = fontData) + { + var result = TrueTypeSubsetter.ComputeSubset(pFontData, fontData.Length, uri, 0, glyphArray); + + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(12); // Minimum TrueType header size + + // Verify offset table structure + // numTables is at offset 4-5 + ushort numTables = (ushort)((result[4] << 8) | result[5]); + numTables.Should().BeGreaterThan(0).And.BeLessThan(100); + } + } + } + + [Fact] + public void ComputeSubset_DifferentFonts_ProduceDifferentOutput() + { + Assert.SkipUnless(File.Exists(TestHelpers.ArialPath), "Arial not found"); + Assert.SkipUnless(File.Exists(s_timesPath), "Times not found"); + + var arialData = File.ReadAllBytes(TestHelpers.ArialPath); + var timesData = File.ReadAllBytes(s_timesPath); + var arialUri = new Uri(TestHelpers.ArialPath); + var timesUri = new Uri(s_timesPath); + + ushort[] glyphArray = Enumerable.Range(0, 50).Select(i => (ushort)i).ToArray(); + + unsafe + { + fixed (byte* pArial = arialData) + fixed (byte* pTimes = timesData) + { + var arialResult = TrueTypeSubsetter.ComputeSubset(pArial, arialData.Length, arialUri, 0, glyphArray); + var timesResult = TrueTypeSubsetter.ComputeSubset(pTimes, timesData.Length, timesUri, 0, glyphArray); + + arialResult.Should().NotBeNull(); + timesResult.Should().NotBeNull(); + + // Results should be different + arialResult.SequenceEqual(timesResult).Should().BeFalse(); + } + } + } + + #endregion +} diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TtfDeltaTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TtfDeltaTests.cs new file mode 100644 index 00000000000..d3eebb8c30b --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/DirectWriteForwarder.Tests/TtfDeltaTests.cs @@ -0,0 +1,176 @@ +// 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.Concurrent; +using System.Reflection; +using System.Threading.Tasks; + +namespace MS.Internal.Text.TextInterface.Tests; + +/// +/// Tests for TtfDelta font subsetting internals. +/// These tests exercise the MS.Internal.TtfDelta namespace which contains +/// TrueType font subsetting functionality used by WPF. +/// +public class TtfDeltaTests +{ + /// + /// Gets the GlobalInit type from the TtfDelta namespace. + /// + private static Type GetGlobalInitType() + { + var asm = typeof(MS.Internal.TrueTypeSubsetter).Assembly; + return asm.GetTypes().First(t => t.Name == "GlobalInit" && t.Namespace == "MS.Internal.TtfDelta"); + } + + /// + /// Gets the ControlTableInit type from the TtfDelta namespace. + /// + private static Type GetControlTableInitType() + { + var asm = typeof(MS.Internal.TrueTypeSubsetter).Assembly; + return asm.GetTypes().First(t => t.Name == "ControlTableInit" && t.Namespace == "MS.Internal.TtfDelta"); + } + + [Fact] + public void GlobalInit_Init_CanBeInvoked() + { + // Arrange + var globalInitType = GetGlobalInitType(); + var initMethod = globalInitType.GetMethod("Init", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + + // Act & Assert + initMethod.Should().NotBeNull("GlobalInit.Init should be accessible"); + + // Invoke should not throw + var exception = Record.Exception(() => initMethod!.Invoke(null, null)); + exception.Should().BeNull("GlobalInit.Init() should complete without throwing"); + } + + [Fact] + public void GlobalInit_Init_CanBeCalledMultipleTimes() + { + // The Init method uses a lock and _isInitialized flag, so multiple calls should be safe + var globalInitType = GetGlobalInitType(); + var initMethod = globalInitType.GetMethod("Init", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + + // Call multiple times - should not throw + for (int i = 0; i < 5; i++) + { + var exception = Record.Exception(() => initMethod.Invoke(null, null)); + exception.Should().BeNull($"GlobalInit.Init() call {i + 1} should not throw"); + } + } + + [Fact] + public void ControlTableInit_Init_CanBeInvoked() + { + // Arrange + var controlTableInitType = GetControlTableInitType(); + var initMethod = controlTableInitType.GetMethod("Init", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + + // Act & Assert + initMethod.Should().NotBeNull("ControlTableInit.Init should be accessible"); + + // Invoke should not throw + var exception = Record.Exception(() => initMethod!.Invoke(null, null)); + exception.Should().BeNull("ControlTableInit.Init() should complete without throwing"); + } + + [Fact] + public void ControlTableInit_Init_CanBeCalledMultipleTimes() + { + // The Init method uses a lock and _isInitialized flag, so multiple calls should be safe + var controlTableInitType = GetControlTableInitType(); + var initMethod = controlTableInitType.GetMethod("Init", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + + // Call multiple times - should not throw + for (int i = 0; i < 5; i++) + { + var exception = Record.Exception(() => initMethod.Invoke(null, null)); + exception.Should().BeNull($"ControlTableInit.Init() call {i + 1} should not throw"); + } + } + + [Fact] + public void TtfDelta_Namespace_ContainsExpectedTypes() + { + // Verify that the expected TtfDelta types are present and accessible + var asm = typeof(MS.Internal.TrueTypeSubsetter).Assembly; + var ttfDeltaTypes = asm.GetTypes() + .Where(t => t.Namespace == "MS.Internal.TtfDelta") + .ToList(); + + ttfDeltaTypes.Should().NotBeEmpty("TtfDelta namespace should contain types"); + + // Verify key types exist + var typeNames = ttfDeltaTypes.Select(t => t.Name).ToHashSet(); + typeNames.Should().Contain("GlobalInit"); + typeNames.Should().Contain("ControlTableInit"); + typeNames.Should().Contain("TTFACC_FILEBUFFERINFO"); + typeNames.Should().Contain("CONST_TTFACC_FILEBUFFERINFO"); + } + + [Fact] + public void TtfDelta_StructTypes_AreValueTypes() + { + // Verify that struct types in TtfDelta are correctly defined as value types + var asm = typeof(MS.Internal.TrueTypeSubsetter).Assembly; + var structTypes = new[] { "TTFACC_FILEBUFFERINFO", "CONST_TTFACC_FILEBUFFERINFO", "DIRECTORY", "HEAD", "MAXP" }; + + foreach (var typeName in structTypes) + { + var type = asm.GetTypes().FirstOrDefault(t => t.Name == typeName && t.Namespace == "MS.Internal.TtfDelta"); + type?.IsValueType.Should().BeTrue($"{typeName} should be a value type (struct)"); + } + } + + [Fact] + public async Task TtfDelta_InitMethods_AreThreadSafe() + { + // Both GlobalInit.Init and ControlTableInit.Init use Monitor.Enter for thread safety + // Verify this by invoking from multiple threads simultaneously + var globalInitType = GetGlobalInitType(); + var controlTableInitType = GetControlTableInitType(); + var globalInit = globalInitType.GetMethod("Init", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + var controlTableInit = controlTableInitType.GetMethod("Init", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + + var tasks = new List(); + var exceptions = new ConcurrentBag(); + + // Run 10 parallel tasks that call both Init methods + for (int i = 0; i < 10; i++) + { + tasks.Add(Task.Run(() => + { + try + { + globalInit.Invoke(null, null); + controlTableInit.Invoke(null, null); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + }, TestContext.Current.CancellationToken)); + } + + await Task.WhenAll(tasks); + + exceptions.Should().BeEmpty("Init methods should be thread-safe and not throw exceptions"); + } + + [Fact] + public void TrueTypeSubsetter_Assembly_HasInternalsVisibleToTestProject() + { + // Verify that the InternalsVisibleTo attribute is set correctly + // by checking that we can access internal types + var globalInitType = GetGlobalInitType(); + + // GlobalInit is a "private ref class" in C++/CLI, which maps to internal in C# + // We should be able to access it due to InternalsVisibleTo + globalInitType.Should().NotBeNull("GlobalInit should be accessible to test project"); + globalInitType.IsNotPublic.Should().BeTrue("GlobalInit should be internal (not public)"); + } +} +