using System.Threading; using UnityEngine; using UnityEditor; using NUnit.Framework; using UnityEngine.TestTools; using UnityEngine.TextCore; using UnityEngine.TextCore.LowLevel; namespace TMPro { [Category("Text Parsing & Layout")] [Ignore("Unstable tests // UUM-133195")] internal class FontEngineTests { [OneTimeSetUp] public void Setup() { // Check if "TextMesh Pro" folder is present in project string folderPath = AssetDatabase.GUIDToAssetPath("f54d1bd14bd3ca042bd867b519fee8cc"); if (string.IsNullOrEmpty(folderPath)) { // Import TMP Essential Resources and TMP Examples & Extras TMP_PackageResourceImporter.ImportResources(true, true, false); } } [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans", "Regular")] [TestCase("4beb055f07aaff244873dec698d0363e", "Roboto", "Bold")] [TestCase("997a43b767814dd0a7642ec9b78cba41", "Anton", "Regular")] public void CreateFontAsset_from_FilePath(string fontFileGUID, string familyName, string styleName) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); TMP_FontAsset fontAsset = TMP_FontAsset.CreateFontAsset(filePath, 0, 90, 9, GlyphRenderMode.SDFAA, 512, 512); Assert.NotNull(fontAsset); Assert.AreEqual(fontAsset.faceInfo.familyName, familyName); Assert.AreEqual(fontAsset.faceInfo.styleName, styleName); } [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans", "Regular")] [TestCase("4beb055f07aaff244873dec698d0363e", "Roboto", "Bold")] [TestCase("997a43b767814dd0a7642ec9b78cba41", "Anton", "Regular")] public void CreateFontAsset_from_FontObject(string fontFileGUID, string familyName, string styleName) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); Font font = AssetDatabase.LoadAssetAtPath(filePath); TMP_FontAsset fontAsset = TMP_FontAsset.CreateFontAsset(font, 90, 9, GlyphRenderMode.SDFAA, 512, 512); Assert.NotNull(fontAsset); Assert.AreEqual(fontAsset.faceInfo.familyName, familyName); Assert.AreEqual(fontAsset.faceInfo.styleName, styleName); } [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans", "Regular")] public void TryAddCharacters_SanityCheck(string fontFileGUID, string familyName, string styleName) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); TMP_FontAsset fontAsset = TMP_FontAsset.CreateFontAsset(filePath, 0, 90, 9, GlyphRenderMode.SDFAA, 512, 512); Assert.NotNull(fontAsset); fontAsset.TryAddCharacters("abc"); Assert.IsTrue(fontAsset.HasCharacters("abc")); } // ============================================= // FONT ENGINE - OPENTYPE TESTS // ============================================= #if TEXTCORE_FONT_ENGINE_1_5_OR_NEWER [TestCase("e3265ab4bf004d28a9537516768c1c75", 1)] [TestCase("4beb055f07aaff244873dec698d0363e", 623)] [TestCase("24007ea0bd4d6b2418f4caf1b06e2cb4", 43)] public void GetSingleSubstitutionRecords(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); if (FontEngine.LoadFontFace(filePath, 0, 0, out FontFaceHandle faceHandle) != FontEngineError.Success) return; UnityEngine.TextCore.LowLevel.SingleSubstitutionRecord[] records = FontEngine.GetAllSingleSubstitutionRecords(faceHandle); if (records == null) return; Assert.AreEqual(recordCount, records.Length); } [TestCase("47a9b34e6f77bbd4d94f512d266bcd0c", 77)] // Inter - Regular public void GetAlternateSubstitutionRecords(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); if (FontEngine.LoadFontFace(filePath, 0, 0, out FontFaceHandle faceHandle) != FontEngineError.Success) return; UnityEngine.TextCore.LowLevel.AlternateSubstitutionRecord[] records = FontEngine.GetAllAlternateSubstitutionRecords(faceHandle); if (records == null) return; Assert.AreEqual(recordCount, records.Length); } [TestCase("e3265ab4bf004d28a9537516768c1c75", 184)] [TestCase("4beb055f07aaff244873dec698d0363e", 177)] [TestCase("c9f6d0e7bc8541498c9a4799ba184ede", 5)] public void GetLigatures(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); if (FontEngine.LoadFontFace(filePath, 0, 0, out FontFaceHandle faceHandle) != FontEngineError.Success) return; UnityEngine.TextCore.LowLevel.LigatureSubstitutionRecord[] records = FontEngine.GetAllLigatureSubstitutionRecords(faceHandle); if (records == null) return; Assert.AreEqual(recordCount, records.Length); } [TestCase("e3265ab4bf004d28a9537516768c1c75", 2016)] [TestCase("4beb055f07aaff244873dec698d0363e", 70027)] [TestCase("c9f6d0e7bc8541498c9a4799ba184ede", 1940)] public void GetPairAdjustmentRecords(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); if (FontEngine.LoadFontFace(filePath, 0, 0, out FontFaceHandle faceHandle) != FontEngineError.Success) return; GlyphPairAdjustmentRecord[] records = FontEngine.GetAllPairAdjustmentRecords(faceHandle); if (records == null) return; Assert.AreEqual(recordCount, records.Length); } [TestCase("e3265ab4bf004d28a9537516768c1c75", 8911)] [TestCase("4beb055f07aaff244873dec698d0363e", 0)] [TestCase("24007ea0bd4d6b2418f4caf1b06e2cb4", 432)] public void GetMarkToBaseAdjustmentRecords(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); if (FontEngine.LoadFontFace(filePath, 0, 0, out FontFaceHandle faceHandle) != FontEngineError.Success) return; UnityEngine.TextCore.LowLevel.MarkToBaseAdjustmentRecord[] records = FontEngine.GetAllMarkToBaseAdjustmentRecords(faceHandle); if (records == null) return; Assert.AreEqual(recordCount, records.Length); } [TestCase("e3265ab4bf004d28a9537516768c1c75", 0)] [TestCase("4beb055f07aaff244873dec698d0363e", 0)] [TestCase("24007ea0bd4d6b2418f4caf1b06e2cb4", 324)] public void GetMarkToMarkAdjustmentRecords(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); if (FontEngine.LoadFontFace(filePath, 0, 0, out FontFaceHandle faceHandle) != FontEngineError.Success) return; UnityEngine.TextCore.LowLevel.MarkToMarkAdjustmentRecord[] records = FontEngine.GetAllMarkToMarkAdjustmentRecords(faceHandle); if (records == null) return; Assert.AreEqual(recordCount, records.Length); } // GetOpenTypeFontFeatureList throws NotImplementedException with new FontEngine changes to support FontFeature (TEXTCORE_FONT_ENGINE_1_5_OR_NEWER) /* [TestCase("e3265ab4bf004d28a9537516768c1c75", 0)] [TestCase("4beb055f07aaff244873dec698d0363e", 0)] [TestCase("24007ea0bd4d6b2418f4caf1b06e2cb4", 324)] [TestCase("47a9b34e6f77bbd4d94f512d266bcd0c", 324)] [TestCase("d01f31227e1b4cd49bc293f44aab2253", 324)] public void GetFontFeatureList(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); if (FontEngine.LoadFontFace(filePath) != FontEngineError.Success) return; OpenTypeFeature[] fontFeatureList = FontEngine.GetOpenTypeFontFeatureList(); if (fontFeatureList == null) return; Assert.AreEqual(recordCount, fontFeatureList.Length); } */ #endif } #if TEXTCORE_FONT_ENGINE_1_6_OR_NEWER [Category("Text Parsing & Layout")] internal class FontFaceHandleTests { [OneTimeSetUp] public void Setup() { // These tests load fonts from the TMP Essential Resources and Examples & Extras. Import // whichever is missing; the prebuild step may already have imported the essentials alone. bool importEssentials = string.IsNullOrEmpty(AssetDatabase.GUIDToAssetPath("f54d1bd14bd3ca042bd867b519fee8cc")); bool importExamples = string.IsNullOrEmpty(AssetDatabase.GUIDToAssetPath("4beb055f07aaff244873dec698d0363e")); if (importEssentials || importExamples) TMP_PackageResourceImporter.ImportResources(importEssentials, importExamples, false); // If the font files these tests depend on still cannot be resolved (resources unavailable // in this environment), skip the whole fixture rather than failing — but log why so the // skip is visible in the test output. if (string.IsNullOrEmpty(AssetDatabase.GUIDToAssetPath("e3265ab4bf004d28a9537516768c1c75"))) { Debug.LogWarning("FontFaceHandleTests skipped: required TMP font resources are not present in the project."); Assert.Ignore("Required TMP font resources are not present; skipping FontFaceHandle tests."); } } [TestCase("e3265ab4bf004d28a9537516768c1c75")] [TestCase("4beb055f07aaff244873dec698d0363e")] public void LoadFontFace_ReturnsValidHandle(string fontFileGUID) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); FontEngineError error = FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle faceHandle); Assert.AreEqual(FontEngineError.Success, error); Assert.IsTrue(faceHandle.IsValid); } [Test] public void LoadFontFace_WithInvalidPath_ReturnsErrorAndDefaultHandle() { FontEngineError error = FontEngine.LoadFontFace("invalid/path/does_not_exist.ttf", 90, 0, out FontFaceHandle faceHandle); Assert.AreNotEqual(FontEngineError.Success, error); Assert.IsFalse(faceHandle.IsValid); } [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans", "Regular")] [TestCase("4beb055f07aaff244873dec698d0363e", "Roboto", "Bold")] public void GetFaceInfo_via_Handle_MatchesGlobalGetFaceInfo(string fontFileGUID, string familyName, string styleName) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); FontEngineError error = FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle faceHandle); Assert.AreEqual(FontEngineError.Success, error); FaceInfo infoViaHandle = FontEngine.GetFaceInfo(faceHandle); FaceInfo infoGlobal = FontEngine.GetFaceInfo(); Assert.AreEqual(familyName, infoViaHandle.familyName); Assert.AreEqual(styleName, infoViaHandle.styleName); Assert.AreEqual(infoGlobal.familyName, infoViaHandle.familyName); Assert.AreEqual(infoGlobal.pointSize, infoViaHandle.pointSize); } // Loads two fonts, stashes their handles, then uses each handle to restore the correct face // without a fresh LoadFontFace call — the core correctness guarantee of FontFaceHandle. [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans", "4beb055f07aaff244873dec698d0363e", "Roboto")] public void FontFaceHandle_RestoresCorrectFace(string guidA, string familyA, string guidB, string familyB) { string pathA = AssetDatabase.GUIDToAssetPath(guidA); string pathB = AssetDatabase.GUIDToAssetPath(guidB); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(pathA, 90, 0, out FontFaceHandle handleA)); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(pathB, 72, 0, out FontFaceHandle handleB)); // Active face is now B — restore A via its handle. FaceInfo infoA = FontEngine.GetFaceInfo(handleA); Assert.AreEqual(familyA, infoA.familyName); Assert.AreEqual(90, infoA.pointSize); // Restore B via its handle. FaceInfo infoB = FontEngine.GetFaceInfo(handleB); Assert.AreEqual(familyB, infoB.familyName); Assert.AreEqual(72, infoB.pointSize); } [TestCase("e3265ab4bf004d28a9537516768c1c75", 2016)] [TestCase("4beb055f07aaff244873dec698d0363e", 70027)] public void GetPairAdjustmentRecords_via_Handle(string fontFileGUID, int recordCount) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); FontEngineError error = FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle faceHandle); Assert.AreEqual(FontEngineError.Success, error); GlyphPairAdjustmentRecord[] records = FontEngine.GetAllPairAdjustmentRecords(faceHandle); Assert.IsNotNull(records); Assert.AreEqual(recordCount, records.Length); } // Exercises the single-pair GPOS lookup whose loop previously used end() as its condition // (iterating past the end of the records vector) and indexed the cached map via operator[] // (default-inserting a missing key). Derives a real pair from the font's own records, then // probes a first glyph against an impossible partner to drive the no-match path that ran // past the end of the vector. [TestCase("e3265ab4bf004d28a9537516768c1c75")] // Liberation Sans (has kerning) public void GetPairAdjustmentRecord_via_Handle(string fontFileGUID) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle faceHandle)); GlyphPairAdjustmentRecord[] all = FontEngine.GetAllPairAdjustmentRecords(faceHandle); Assert.IsNotNull(all); Assert.Greater(all.Length, 0); // A real (first, second) pair taken from the font's own data must resolve to itself. uint first = all[0].firstAdjustmentRecord.glyphIndex; uint second = all[0].secondAdjustmentRecord.glyphIndex; GlyphPairAdjustmentRecord match = FontEngine.GetPairAdjustmentRecord(faceHandle, first, second); Assert.AreEqual(first, match.firstAdjustmentRecord.glyphIndex); Assert.AreEqual(second, match.secondAdjustmentRecord.glyphIndex); // A first glyph that has records, paired with an impossible partner, must scan to the end // and return an empty record rather than reading past it. GlyphPairAdjustmentRecord noMatch = FontEngine.GetPairAdjustmentRecord(faceHandle, first, 0xFFFFFFFE); Assert.AreEqual(0u, noMatch.secondAdjustmentRecord.glyphIndex); } [TestCase("e3265ab4bf004d28a9537516768c1c75")] public void UnloadFontFace_via_Handle_InvalidatesHandle(string fontFileGUID) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle faceHandle)); Assert.IsTrue(faceHandle.IsValid); Assert.AreEqual(FontEngineError.Success, FontEngine.UnloadFontFace(faceHandle)); // After unloading, operations using the stale handle should fail with InvalidFontFaceHandle. // The native layer logs a warning when it cannot resolve the stale handle — that is expected here. LogAssert.Expect(LogType.Warning, "FontEngine: Font face handle no longer resolves to a cached font face."); FontEngineError result = FontEngine.SetFaceSize(ref faceHandle, 90); Assert.AreEqual(FontEngineError.Invalid_FontFaceHandle, result); } [TestCase("e3265ab4bf004d28a9537516768c1c75")] public void StaleHandle_OperationsReturnGracefully(string fontFileGUID) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle faceHandle)); Assert.AreEqual(FontEngineError.Success, FontEngine.UnloadFontFace(faceHandle)); // GetFaceInfo — returns a zeroed FaceInfo rather than throwing. LogAssert.Expect(LogType.Warning, "FontEngine: Font face handle no longer resolves to a cached font face."); FaceInfo faceInfo = FontEngine.GetFaceInfo(faceHandle); Assert.AreEqual(0, faceInfo.pointSize); // TryGetGlyphIndex — returns false rather than throwing. LogAssert.Expect(LogType.Warning, "FontEngine: Font face handle no longer resolves to a cached font face."); bool found = FontEngine.TryGetGlyphIndex(faceHandle, 65, out uint glyphIndex); Assert.IsFalse(found); Assert.AreEqual(0u, glyphIndex); // GetGlyphIndex — returns 0 rather than throwing. LogAssert.Expect(LogType.Warning, "FontEngine: Font face handle no longer resolves to a cached font face."); uint index = FontEngine.GetGlyphIndex(faceHandle, 65); Assert.AreEqual(0u, index); } // A scalable font requested at two different point sizes resolves to the SAME cached FT_Face // (MakeFontID excludes pixel size for scalable fonts), so both handles share one face — and // one per-face lock. Each handle must still operate at, and report, its own size: the handle // path resolves the face locally and re-applies the requested size under the lock rather than // relying on whatever size the previous caller left on the shared face. [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans")] public void SameScalableFont_DifferentSizes_TrackSizePerHandle(string fontFileGUID, string familyName) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle handle90)); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(filePath, 30, 0, out FontFaceHandle handle30)); // Interleave repeatedly; each handle must re-assert its own size every time. for (int i = 0; i < 4; i++) { FaceInfo info90 = FontEngine.GetFaceInfo(handle90); Assert.AreEqual(familyName, info90.familyName); Assert.AreEqual(90, info90.pointSize, "handle90 should report size 90 regardless of interleaving"); FaceInfo info30 = FontEngine.GetFaceInfo(handle30); Assert.AreEqual(familyName, info30.familyName); Assert.AreEqual(30, info30.pointSize, "handle30 should report size 30 regardless of interleaving"); } } // Two distinct faces accessed concurrently must not corrupt each other: with face-threading // the handle path never touches a shared global current-face, so each thread keeps seeing its // own font/size. (Distinct FT_Faces are independently safe per the FreeType contract.) // Faces are loaded on the main thread up front (face creation is not yet concurrency-safe); // the workers only read through their already-valid handles, which the per-face lock guards. [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans", "4beb055f07aaff244873dec698d0363e", "Roboto")] public void ConcurrentHandleAccess_DistinctFaces_RemainCorrect(string guidA, string familyA, string guidB, string familyB) { string pathA = AssetDatabase.GUIDToAssetPath(guidA); string pathB = AssetDatabase.GUIDToAssetPath(guidB); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(pathA, 90, 0, out FontFaceHandle handleA)); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(pathB, 72, 0, out FontFaceHandle handleB)); const int iterations = 200; string failureA = null; string failureB = null; var tA = new Thread(() => { failureA = HammerFaceInfo(handleA, familyA, 90, iterations); }); var tB = new Thread(() => { failureB = HammerFaceInfo(handleB, familyB, 72, iterations); }); tA.Start(); tB.Start(); tA.Join(); tB.Join(); Assert.IsNull(failureA, failureA); Assert.IsNull(failureB, failureB); } // Two handles to the SAME scalable font at different sizes contend on one shared FT_Face. // The per-face lock plus per-handle size re-application must keep each thread's view at its // own size; without the lock the threads would stomp the shared face's active size and read // each other's metrics. [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans")] public void ConcurrentHandleAccess_SameFontDifferentSizes_RemainCorrect(string fontFileGUID, string familyName) { string filePath = AssetDatabase.GUIDToAssetPath(fontFileGUID); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(filePath, 90, 0, out FontFaceHandle handle90)); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(filePath, 30, 0, out FontFaceHandle handle30)); const int iterations = 200; string failure90 = null; string failure30 = null; var t90 = new Thread(() => { failure90 = HammerFaceInfo(handle90, familyName, 90, iterations); }); var t30 = new Thread(() => { failure30 = HammerFaceInfo(handle30, familyName, 30, iterations); }); t90.Start(); t30.Start(); t90.Join(); t30.Join(); Assert.IsNull(failure90, failure90); Assert.IsNull(failure30, failure30); } // Exercises FontFaceCacheLock: a worker thread repeatedly resolves a stable handle while the // main thread churns the font face cache by loading and unloading a DIFFERENT face (insert / // erase, and the rehash they can trigger). Without the cache lock, the worker's lookup inside // ResolveFaceLocked would race the main-thread structural mutation of the shared cache. The // stable face is never unloaded, so the worker must keep reading its correct family. [TestCase("e3265ab4bf004d28a9537516768c1c75", "Liberation Sans", "4beb055f07aaff244873dec698d0363e")] public void ConcurrentResolve_WhileCacheChurns_RemainsStable(string stableGuid, string stableFamily, string churnGuid) { string stablePath = AssetDatabase.GUIDToAssetPath(stableGuid); string churnPath = AssetDatabase.GUIDToAssetPath(churnGuid); Assert.AreEqual(FontEngineError.Success, FontEngine.LoadFontFace(stablePath, 90, 0, out FontFaceHandle stableHandle)); const int iterations = 200; string failure = null; var reader = new Thread(() => { for (int i = 0; i < iterations && failure == null; i++) { FaceInfo info = FontEngine.GetFaceInfo(stableHandle); if (info.familyName != stableFamily) { failure = $"Expected {stableFamily}, got '{info.familyName}' on iteration {i}"; return; } } }); reader.Start(); // Main-thread cache churn: load + unload another face repeatedly (writers take the cache lock). for (int i = 0; i < iterations; i++) { if (FontEngine.LoadFontFace(churnPath, 72, 0, out FontFaceHandle churnHandle) == FontEngineError.Success) FontEngine.UnloadFontFace(churnHandle); } reader.Join(); Assert.IsNull(failure, failure); } // Repeatedly resolves the handle and checks the face reports the expected family/size. // Returns a description of the first mismatch, or null on success (asserted on the main // thread after Join — NUnit assertions don't propagate from worker threads). static string HammerFaceInfo(FontFaceHandle handle, string expectedFamily, int expectedSize, int iterations) { for (int i = 0; i < iterations; i++) { FaceInfo info = FontEngine.GetFaceInfo(handle); if (info.familyName != expectedFamily || info.pointSize != expectedSize) return $"Expected {expectedFamily}@{expectedSize}, got '{info.familyName}'@{info.pointSize} on iteration {i}"; } return null; } } #endif }