Files
2026-11-01 23:39:55 +02:00

516 lines
25 KiB
C#

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<Font>(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
}