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);
}
}
}
}