csharpc-sharpdotnetxamlavaloniauicross-platformcross-platform-xamlavaloniaguimulti-platformuser-interfacedotnetcore
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
210 lines
8.0 KiB
210 lines
8.0 KiB
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using Avalonia.Media;
|
|
using Avalonia.Media.Fonts;
|
|
using Avalonia.Platform;
|
|
using Avalonia.UnitTests;
|
|
using Xunit;
|
|
|
|
namespace Avalonia.Skia.UnitTests.Media
|
|
{
|
|
/// <summary>
|
|
/// Verifies that <see cref="FontCollectionBase.TryMatchCharacter(int, FontStyle, FontWeight, FontStretch, string?, CultureInfo?, out Typeface)"/> resolves the same family
|
|
/// regardless of the order in which fonts were added to the collection, and is stable
|
|
/// across repeated invocations.
|
|
/// </summary>
|
|
public class FontCollectionDeterminismTests
|
|
{
|
|
private const string AssetsNamespace = "Avalonia.Skia.UnitTests.Assets";
|
|
|
|
// A set of Latin-covering test fonts. All cover ASCII 'A'.
|
|
private static readonly string[] s_latinFontAssets =
|
|
{
|
|
$"{AssetsNamespace}.Inter-Regular.ttf",
|
|
$"{AssetsNamespace}.Inter-Bold.ttf",
|
|
$"{AssetsNamespace}.Manrope-Light.ttf",
|
|
$"{AssetsNamespace}.NotoMono-Regular.ttf",
|
|
$"{AssetsNamespace}.NotoSans-Italic.ttf",
|
|
$"{AssetsNamespace}.SourceSerif4_36pt-Italic.ttf",
|
|
};
|
|
|
|
[Fact]
|
|
public void TryMatchCharacter_Returns_Same_Family_Regardless_Of_Add_Order()
|
|
{
|
|
using var app = UnitTestApplication.Start(
|
|
TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl()));
|
|
|
|
var orderings = new[]
|
|
{
|
|
s_latinFontAssets,
|
|
s_latinFontAssets.Reverse().ToArray(),
|
|
Shuffle(s_latinFontAssets, seed: 1),
|
|
Shuffle(s_latinFontAssets, seed: 17),
|
|
Shuffle(s_latinFontAssets, seed: 42),
|
|
Shuffle(s_latinFontAssets, seed: 1337),
|
|
};
|
|
|
|
string? expected = null;
|
|
|
|
foreach (var ordering in orderings)
|
|
{
|
|
var collection = BuildCollection(ordering);
|
|
|
|
Assert.True(collection.TryMatchCharacter(
|
|
'A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal,
|
|
familyName: null, culture: null, out var match));
|
|
|
|
var familyName = ExtractFamilyName(match);
|
|
|
|
if (expected is null)
|
|
{
|
|
expected = familyName;
|
|
}
|
|
else
|
|
{
|
|
Assert.Equal(expected, familyName);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TryMatchCharacter_Is_Stable_Across_Repeated_Invocations()
|
|
{
|
|
using var app = UnitTestApplication.Start(
|
|
TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl()));
|
|
|
|
var collection = BuildCollection(s_latinFontAssets);
|
|
|
|
Assert.True(collection.TryMatchCharacter(
|
|
'A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal,
|
|
familyName: null, culture: null, out var firstMatch));
|
|
|
|
var expected = ExtractFamilyName(firstMatch);
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
{
|
|
Assert.True(collection.TryMatchCharacter(
|
|
'A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal,
|
|
familyName: null, culture: null, out var match));
|
|
|
|
Assert.Equal(expected, ExtractFamilyName(match));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TryMatchCharacter_Result_Is_Independent_Of_Concurrent_Cache_Population()
|
|
{
|
|
using var app = UnitTestApplication.Start(
|
|
TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl()));
|
|
|
|
// Touch a variety of typefaces in different orders before the fallback call,
|
|
// so each collection's _glyphTypefaceCache has different ConcurrentDictionary
|
|
// insertion / hash-bucket order.
|
|
var resultsA = new List<string>();
|
|
var resultsB = new List<string>();
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
var a = BuildCollection(s_latinFontAssets);
|
|
var b = BuildCollection(s_latinFontAssets);
|
|
|
|
// Different warm-up order on purpose.
|
|
Warmup(a, new[] { "Inter", "Manrope Light", "Noto Mono", "Noto Sans", "Source Serif 4 36pt" });
|
|
Warmup(b, new[] { "Source Serif 4 36pt", "Noto Sans", "Noto Mono", "Manrope Light", "Inter" });
|
|
|
|
Assert.True(a.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var ma));
|
|
Assert.True(b.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var mb));
|
|
|
|
resultsA.Add(ExtractFamilyName(ma));
|
|
resultsB.Add(ExtractFamilyName(mb));
|
|
}
|
|
|
|
// Every iteration of A produces the same family.
|
|
Assert.Single(resultsA.Distinct());
|
|
|
|
// Every iteration of B produces the same family.
|
|
Assert.Single(resultsB.Distinct());
|
|
|
|
// And the chosen family is the same for both warm-up orders.
|
|
Assert.Equal(resultsA[0], resultsB[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryMatchCharacter_Cached_ScriptFallback_Lookup_Returns_Same_Family()
|
|
{
|
|
using var app = UnitTestApplication.Start(
|
|
TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl()));
|
|
|
|
var collection = BuildCollection(s_latinFontAssets);
|
|
|
|
// First call populates the script/culture fallback cache; subsequent calls must hit
|
|
// it and still return the same family.
|
|
Assert.True(collection.TryMatchCharacter(
|
|
'A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal,
|
|
familyName: null, culture: null, out var first));
|
|
|
|
Assert.True(collection.TryMatchCharacter(
|
|
'A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal,
|
|
familyName: null, culture: null, out var second));
|
|
|
|
Assert.Equal(ExtractFamilyName(first), ExtractFamilyName(second));
|
|
}
|
|
|
|
private static CustomFontCollection BuildCollection(IEnumerable<string> assetPaths)
|
|
{
|
|
var assetLoader = AvaloniaLocator.Current.GetRequiredService<IAssetLoader>();
|
|
var collection = new CustomFontCollection(new Uri("fonts:determinism", UriKind.Absolute));
|
|
|
|
foreach (var path in assetPaths)
|
|
{
|
|
var uri = new Uri($"resm:{path}?assembly=Avalonia.Skia.UnitTests", UriKind.Absolute);
|
|
|
|
using var stream = assetLoader.Open(uri);
|
|
|
|
Assert.True(collection.TryAddGlyphTypeface(stream, out _));
|
|
}
|
|
|
|
return collection;
|
|
}
|
|
|
|
private static void Warmup(CustomFontCollection collection, IEnumerable<string> familyNames)
|
|
{
|
|
foreach (var name in familyNames)
|
|
{
|
|
collection.TryGetGlyphTypeface(name, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, out _);
|
|
}
|
|
}
|
|
|
|
private static string[] Shuffle(string[] source, int seed)
|
|
{
|
|
var rng = new Random(seed);
|
|
var copy = (string[])source.Clone();
|
|
|
|
for (var i = copy.Length - 1; i > 0; i--)
|
|
{
|
|
var j = rng.Next(i + 1);
|
|
(copy[i], copy[j]) = (copy[j], copy[i]);
|
|
}
|
|
|
|
return copy;
|
|
}
|
|
|
|
private static string ExtractFamilyName(Typeface typeface)
|
|
{
|
|
// Fallback Typefaces are built with a FontFamily of the form "<collection-key>#<familyName>".
|
|
// The plain family name is what we want to compare across orderings.
|
|
var name = typeface.FontFamily.Name;
|
|
var hashIndex = name.LastIndexOf('#');
|
|
return hashIndex >= 0 ? name[(hashIndex + 1)..] : name;
|
|
}
|
|
|
|
private sealed class CustomFontCollection(Uri key) : FontCollectionBase
|
|
{
|
|
public override Uri Key { get; } = key;
|
|
}
|
|
}
|
|
}
|
|
|