using System; using System.Collections.Generic; using System.Text; using Amphora; using Azimuth; using Glass.Typesetting; using UnitTestSharp; using GlyphDefinition = Glass.SpriteFontDefinition.GlyphDefinition; namespace Glass.UnitTests.Typesetting { public class GlyphPlacerTests : TestFixture { public const int LineSpacing = 10; public const int Roomy = 1000; /// /// Letters 10 wide, a space 4, and lines apart. /// public static readonly SpriteFontDefinition Font = LineWrapperTests.FontWithTenPixelLines; /// /// The glyph just placed as "index@x,y+advance". /// public static string Describe(in GlyphPlacer placer) { return $"{placer.Index}@{placer.X},{placer.Y}+{placer.Advance}"; } public static GlyphPlacer Placer(StringView text, int containerWidth, IFontMetrics font = null, Alignment.Horizontal alignment = Alignment.Horizontal.Left, int? maxHeight = null, TextOverflow overflow = TextOverflow.Clip, int? maxLines = null, TrailingSpaceMode trailingSpaces = TrailingSpaceMode.Hang) { return new GlyphPlacer(font ?? Font, text, containerWidth, alignment, maxHeight, overflow, maxLines, trailingSpaces); } public static List Placements(StringView text, int containerWidth, IFontMetrics font = null, Alignment.Horizontal alignment = Alignment.Horizontal.Left, int? maxHeight = null, TextOverflow overflow = TextOverflow.Clip, int? maxLines = null, TrailingSpaceMode trailingSpaces = TrailingSpaceMode.Hang) { GlyphPlacer placer = Placer(text, containerWidth, font, alignment, maxHeight, overflow, maxLines, trailingSpaces); var returnMe = new List(); while (placer.MoveNext()) { returnMe.Add(Describe(placer)); } return returnMe; } /// /// The placer once drained, described like a glyph. /// public static string End(StringView text, int containerWidth, IFontMetrics font = null, Alignment.Horizontal alignment = Alignment.Horizontal.Left, int? maxLines = null) { GlyphPlacer placer = Placer(text, containerWidth, font, alignment, maxLines: maxLines); while (placer.MoveNext()) { } return Describe(placer); } public const string Prose = "hello world, this wraps\nacross lines"; public static int Drain(string text, int containerWidth, TextOverflow overflow, int? maxLines = null, Alignment.Horizontal alignment = Alignment.Horizontal.Left, TrailingSpaceMode trailingSpaces = TrailingSpaceMode.Hang) { var placer = new GlyphPlacer(Font, text, containerWidth, alignment, maxHeight: null, overflow, maxLines, trailingSpaces); int count = 0; while (placer.MoveNext()) { ++count; } return count; } /// /// Printable ASCII in assorted widths plus an ellipsis, kerned among the characters /// uses most, with lines apart. /// public static readonly SpriteFontDefinition KernedFont = BuildKernedFont(); public static SpriteFontDefinition BuildKernedFont() { var glyphs = new Dictionary(); for (char character = ' '; character <= '~'; ++character) { glyphs[character] = new GlyphDefinition { Width = character % 7 + 3 }; } glyphs[TruncationMarker.EllipsisGlyph] = new GlyphDefinition { Width = 5 }; string kerned = $" ().-ab{TruncationMarker.EllipsisGlyph}"; var kernings = new Dictionary<(short, short), short>(); foreach (char left in kerned) { foreach (char right in kerned) { kernings[WordMeasurerTests.Pair(left, right)] = (short)((left * 3 + right) % 5 - 2); } } return new SpriteFontDefinition(new SpriteFontDefinition.Data { GlyphDefinitions = glyphs, InvalidGlyph = new GlyphDefinition { Width = WordMeasurerTests.InvalidGlyphWidth }, Kernings = kernings, Baseline = 8, LineHeight = LineSpacing, }); } public static string RandomText(Random random) { string alphabet = $"ab ().-\n{LineBreakIterator.SoftHyphen}{(char)0x4E00}"; var text = new StringBuilder(); int length = random.Next(30); for (int i = 0; i < length; ++i) { text.Append(alphabet[random.Next(alphabet.Length)]); } return text.ToString(); } /// /// How 's pen disagrees with 's widths, or null if it /// doesn't. A line is as wide as the pen after its last non-space glyph, or after a space following it, since /// an emergency split can end a line inside content spaces rather than hanging ones. /// public static string WidthMismatch(string text, int maxWidth, TextOverflow overflow, int? maxLines) { LineLayout layout = LineLayout.FitAll(KernedFont, text, maxWidth, maxHeight: null, overflow, maxLines); var placer = new GlyphPlacer(KernedFont, text, maxWidth, Alignment.Horizontal.Left, maxHeight: null, overflow, maxLines); var candidates = new List[layout.Count]; for (int i = 0; i < candidates.Length; ++i) { candidates[i] = new List { 0 }; } while (placer.MoveNext()) { int line = placer.Y / LineSpacing; if (line >= layout.Count) { return $"a glyph on line {line}, past the layout's {layout.Count}"; } bool isSpace = placer.Index < layout[line].End && text[placer.Index] == ' '; if (!isSpace) { candidates[line].Clear(); } candidates[line].Add(placer.X + placer.Advance); } if (placer.LineCount != layout.Count) { return $"{placer.LineCount} lines placed, {layout.Count} laid out"; } for (int i = 0; i < layout.Count; ++i) { if (!candidates[i].Contains(layout[i].VisibleWidth)) { return $"line {i} is {layout[i].VisibleWidth} wide, pens {string.Join(",", candidates[i])}"; } } return null; } public class CtorTests : TestFixture { public void NullFont_Throws() { CheckThrow(typeof(ArgumentNullException)); new GlyphPlacer(null, "a", Roomy, Alignment.Horizontal.Left); } public void InfiniteWidth_NeverWraps() { CheckEqual(new[] { "0@0,0+10", "1@10,0+4", "2@14,0+10" }, Placements("a b", int.MaxValue)); } public void MeasuredWords_PlaceLikeMeasuringTheText() { var text = new StringView("x" + Prose, 1, Prose.Length + 1); var placer = new GlyphPlacer(new MeasuredWords(Font, text), containerWidth: 60, Alignment.Horizontal.Center, maxHeight: null, TextOverflow.Ellipsis, maxLines: 2); var placements = new List(); while (placer.MoveNext()) { placements.Add(Describe(placer)); } CheckEqual(Placements(text, containerWidth: 60, alignment: Alignment.Horizontal.Center, overflow: TextOverflow.Ellipsis, maxLines: 2), placements); } } public class MoveNextTests : TestFixture { public void EmptyText_PlacesNothing() { CheckEqual(new string[0], Placements("", Roomy)); } public void EachGlyph_SitsWhereTheKerningAndTheOneBeforeLeaveThePen() { CheckEqual(new[] { "0@0,0+10", "1@9,0+10" }, Placements("ab", Roomy, WordMeasurerTests.SharedFont)); } public void Spaces_ArePlacedLikeAnyGlyph_EvenHangingOnes() { CheckEqual(new[] { "0@0,0+10", "1@8,0+4", "2@9,0+10", "3@19,0+4" }, Placements("b c ", Roomy, WordMeasurerTests.SharedFont)); } public void WrappedLines_StackDownLineByLine() { CheckEqual(new[] { "0@0,0+10", "1@10,0+10", "2@20,0+4", "3@0,10+10", "4@10,10+10" }, Placements("ab cd", 25)); } public void LineStart_CarriesNoKerningIn() { CheckEqual(new[] { "0@0,0+10", "1@8,0+4", "2@0,0+10" }, Placements("b c", 12, WordMeasurerTests.SharedFont)); } public void LineFeed_IsNotPlaced() { CheckEqual(new[] { "0@0,0+10", "2@0,10+10" }, Placements("a\nb", Roomy)); } public void CarriageReturnLineFeed_IsNotPlaced() { CheckEqual(new[] { "0@0,0+10", "3@0,10+10" }, Placements("a\r\nb", Roomy)); } public void OtherHardBreak_IsNotPlaced() { CheckEqual(new[] { "0@0,0+10", "2@0,10+10" }, Placements($"a{(char)0x85}b", Roomy)); } public void SoftHyphenNotTaken_IsNotPlaced_AndKerningCarriesAcrossIt() { CheckEqual(new[] { "0@0,0+10", "2@5,0+10" }, Placements($"a{LineBreakIterator.SoftHyphen}c", Roomy, WordMeasurerTests.SharedFont)); } public void SoftHyphenTaken_PlacesAKernedHyphenAtTheLinesEnd() { CheckEqual(new[] { "0@0,0+10", "2@9,0+3", "2@0,0+10" }, Placements( $"b{LineBreakIterator.SoftHyphen}c", 12, WordMeasurerTests.SharedFont)); } public void AstralCharacter_IsOneGlyph() { CheckEqual(new[] { "0@0,0+10", "1@10,0+7", "3@17,0+10" }, Placements($"a{WordMeasurerTests.Emoji}b", Roomy)); } public void InvisibleCharacter_IsNotPlaced() { CheckEqual(new[] { "0@0,0+10", "3@10,0+10" }, Placements($"a{WordMeasurerTests.LanguageTag}b", Roomy)); } public void EllipsisTruncatedLine_PlacesTheMarkerAfterTheKeptText() { CheckEqual(new[] { "0@0,0+10", "1@10,0+10", "2@20,0+5" }, Placements("aaa bbb", 34, overflow: TextOverflow.Ellipsis, maxLines: 1)); } public void Marker_KernsAgainstTheLastKeptGlyph() { SpriteFontDefinition font = LineWrapperTests.FontWithLineHeight( kernings: new Dictionary<(short, short), short> { [WordMeasurerTests.Pair('b', TruncationMarker.EllipsisGlyph)] = -1, }); CheckEqual(new[] { "0@0,0+10", "1@10,0+10", "2@19,0+5" }, Placements("ab\nc", Roomy, font, overflow: TextOverflow.Ellipsis, maxLines: 1)); } public void ThreePeriodMarker_PlacesEveryPeriod() { CheckEqual(new[] { "0@0,0+10", "1@10,0+10", "2@20,0+2", "2@22,0+2", "2@24,0+2" }, Placements("ab\ncd", Roomy, LineWrapperTests.FontWithLineHeight(withEllipsis: false), overflow: TextOverflow.Ellipsis, maxLines: 1)); } public void RightAligned_EachLineAlignsByItsOwnWidth() { CheckEqual(new[] { "0@90,0+10", "2@70,10+10", "3@80,10+10", "4@90,10+10" }, Placements("a\nbbb", 100, alignment: Alignment.Horizontal.Right)); } public void Centered_CanLandOnAHalfPixel() { CheckEqual(new[] { "0@45,0+10" }, Placements("a", 101, alignment: Alignment.Horizontal.Center)); } public void WrapMode_SpacesShiftTheAlignment() { CheckEqual(new[] { "0@86,0+10", "1@96,0+4" }, Placements("a ", 100, alignment: Alignment.Horizontal.Right, trailingSpaces: TrailingSpaceMode.Wrap)); } public void HangingSpaces_DoNotShiftTheAlignment() { CheckEqual(new[] { "0@90,0+10", "1@100,0+4" }, Placements("a ", 100, alignment: Alignment.Horizontal.Right)); } public void LineWiderThanTheContainer_AnchorsLeftWhateverTheAlignment() { CheckEqual(new[] { "0@0,0+10" }, Placements("a", 5, alignment: Alignment.Horizontal.Right)); CheckEqual(new[] { "0@0,0+10" }, Placements("a", 5, alignment: Alignment.Horizontal.Center)); } public void InvalidAlignment_Throws() { CheckThrow(typeof(InvalidOperationException)); Placements("a", Roomy, alignment: (Alignment.Horizontal)999); } public void Drained_StaysDrained() { GlyphPlacer placer = Placer("a", Roomy); placer.MoveNext(); placer.MoveNext(); CheckFalse(placer.MoveNext()); } public void ViewIntoALongerString_PlacesLikeTheStandaloneString() { const string text = "ab cd\nef"; var view = new StringView("xx " + text + " yy", 3, 3 + text.Length); CheckEqual(Placements(text, 25), Placements(view, 25)); } [CpuTimeout(3000)] public void RandomText_EveryLineDrawsExactlyItsVisibleWidth() { var random = new Random(1); var firstMismatches = new List(); for (int trial = 0; trial < 3000 && firstMismatches.Count < 5; ++trial) { string text = RandomText(random); int maxWidth = random.Next(5, 80); TextOverflow overflow = random.Next(2) == 0 ? TextOverflow.Clip : TextOverflow.Ellipsis; int? maxLines = random.Next(3) == 0 ? random.Next(1, 3) : (int?)null; string mismatch = WidthMismatch(text, maxWidth, overflow, maxLines); if (mismatch != null) { firstMismatches.Add( $"[{LineBreakIteratorTests.CodePointsOf(text)}] width {maxWidth} {overflow} " + $"lines {maxLines}: {mismatch}"); } } CheckEqual("", string.Join("\n", firstMismatches)); } [AllocationTest] public void Wrapping_AllocatesNothing() { int count = Drain(Prose, 60, TextOverflow.Clip); CheckNoAllocations(); CheckEqual(Prose.Length - 1, count); } [AllocationTest] public void EllipsisTruncated_AllocatesNothing() { int count = Drain(Prose, 60, TextOverflow.Ellipsis, maxLines: 1); CheckNoAllocations(); CheckEqual("hello".Length + 1, count); } [AllocationTest] public void Centered_AllocatesNothing() { int count = Drain(Prose, 60, TextOverflow.Clip, alignment: Alignment.Horizontal.Center); CheckNoAllocations(); CheckEqual(Prose.Length - 1, count); } [AllocationTest] public void EmergencySplit_AllocatesNothing() { int count = Drain("abcdefgh", 25, TextOverflow.Clip); CheckNoAllocations(); CheckEqual(8, count); } [AllocationTest] public void WrapModeSpill_AllocatesNothing() { int count = Drain("a b", 25, TextOverflow.Clip, trailingSpaces: TrailingSpaceMode.Wrap); CheckNoAllocations(); CheckEqual(12, count); } /// /// Built once, since building it mid-test would count against an allocation test. /// public static readonly MeasuredWords AllocationWords = new MeasuredWords(Font, Prose); [AllocationTest] public void FromMeasuredWords_AllocatesNothing() { var placer = new GlyphPlacer(AllocationWords, containerWidth: 60, Alignment.Horizontal.Left); int count = 0; while (placer.MoveNext()) { ++count; } CheckNoAllocations(); CheckEqual(Prose.Length - 1, count); } } public class XAndYTests : TestFixture { public void Drained_AfterTheLastGlyph_IsThePen() { CheckEqual("2@20,0+0", End("ab", Roomy)); } public void Drained_AfterHangingSpaces_IsPastThem() { CheckEqual("4@28,0+0", End("ab ", Roomy)); } public void Drained_AfterATrailingHardBreak_IsTheNextLinesStart() { CheckEqual("3@0,10+0", End("ab\n", Roomy)); } public void Drained_OnEmptyText_IsTheFirstLinesStart() { CheckEqual("0@0,0+0", End("", Roomy)); } public void Drained_OnEmptyText_HonorsTheAlignment() { CheckEqual("0@100,0+0", End("", 100, alignment: Alignment.Horizontal.Right)); } public void Drained_TruncatedAtAHardBreak_StaysOnTheLastLine() { CheckEqual("2@10,0+0", End("a\nb", Roomy, maxLines: 1)); } } public class GlyphTests : TestFixture { public void IsTheFontsOwnMetrics() { SpriteFontDefinition font = Font; GlyphPlacer placer = Placer("a", Roomy, font); placer.MoveNext(); CheckTrue(ReferenceEquals(font.MetricsTable['a'], placer.Glyph)); } public void Drained_IsNull() { GlyphPlacer placer = Placer("a", Roomy); placer.MoveNext(); placer.MoveNext(); CheckNull(placer.Glyph); } } public class LineCountTests : TestFixture { public void CountsTheLinesPlaced() { GlyphPlacer placer = Placer("a\nb\nc", Roomy); while (placer.MoveNext()) { } CheckEqual(3, placer.LineCount); } } public class HeightTests : TestFixture { public void StacksTheLinesPlaced() { GlyphPlacer placer = Placer("a\nb\nc", Roomy); while (placer.MoveNext()) { } CheckEqual(30, placer.Height); } } public class TruncationTests : TestFixture { public void TextThatFits_IsNull() { GlyphPlacer placer = Placer("ab", Roomy); while (placer.MoveNext()) { } CheckNull(placer.Truncation); } public void TextCutShort_SaysHow() { GlyphPlacer placer = Placer("aaa bbb", 34, overflow: TextOverflow.Ellipsis, maxLines: 1); while (placer.MoveNext()) { } CheckEqual(TextOverflow.Ellipsis, placer.Truncation); } } public class VerticalOffsetForTests : TestFixture { public void Top_IsAlwaysZero() { CheckEqual(0, GlyphPlacer.VerticalOffsetFor(Alignment.Vertical.Top, 200, 80)); } public void Bottom_SitsTheBlockFlushWithTheBottom() { CheckEqual(120, GlyphPlacer.VerticalOffsetFor(Alignment.Vertical.Bottom, 200, 80)); } public void Center_SplitsTheRemainingSpaceInHalf() { CheckEqual(60, GlyphPlacer.VerticalOffsetFor(Alignment.Vertical.Center, 200, 80)); } public void Center_OddRemainder_RoundsDownToAWholePixel() { CheckEqual(60, GlyphPlacer.VerticalOffsetFor(Alignment.Vertical.Center, 201, 80)); } public void Bottom_BlockTallerThanTheContainer_ClampsToZero() { CheckEqual(0, GlyphPlacer.VerticalOffsetFor(Alignment.Vertical.Bottom, 200, 260)); } public void Center_BlockTallerThanTheContainer_ClampsToZero() { CheckEqual(0, GlyphPlacer.VerticalOffsetFor(Alignment.Vertical.Center, 200, 260)); } public void InvalidAlignment_Throws() { CheckThrow(typeof(InvalidOperationException)); GlyphPlacer.VerticalOffsetFor((Alignment.Vertical)999, 200, 80); } } } }