Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 77 additions & 32 deletions LibLouis.NET.Test/BrailleSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ private static string Describe(string value) =>
: string.Concat(value.Select(c => c is >= ' ' and <= '~' ? c.ToString() : $"\\u{(int)c:X4}"));
}

/// <summary>
/// A parsed spec: the cases it yields, and a tally of the constructs that were recognised but not
/// driven, so what is being skipped stays visible rather than becoming invisible coverage loss.
/// </summary>
public sealed record BrailleSpec(
IReadOnlyList<BrailleSpecCase> Cases,
IReadOnlyDictionary<string, int> SkippedConstructs);

public enum TestDirection
{
Forward,
Expand All @@ -51,6 +59,9 @@ public enum TestMode
Forward,
Backward,
BothDirections,

/// <summary>A liblouis feature this harness recognises but does not drive yet.</summary>
Unsupported,
}

/// <summary>
Expand All @@ -66,16 +77,19 @@ public enum TestMode
/// Consecutive <c>table</c> keys accumulate rather than replace: the following <c>tests</c> block
/// runs once per accumulated table. <c>flags</c> persists until the next <c>flags</c>.
///
/// Only the constructs the Danish specs actually use are supported. Anything else throws rather
/// than being skipped, so a spec using a feature this reader does not model fails loudly instead
/// of silently testing less than it appears to.
/// Constructs fall into three groups. Those the harness drives are turned into cases. Those
/// liblouis supports but the harness does not drive yet are counted in
/// <see cref="BrailleSpec.SkippedConstructs"/> and their cases dropped. Anything else throws, so a
/// spec using something nobody has looked at fails loudly rather than quietly testing less than it
/// appears to.
/// </remarks>
public static class BrailleSpecReader
{
public static IReadOnlyList<BrailleSpecCase> Read(string path)
public static BrailleSpec Read(string path)
{
string specFile = Path.GetFileName(path);
var cases = new List<BrailleSpecCase>();
var skipped = new SortedDictionary<string, int>(StringComparer.Ordinal);

using var reader = new StreamReader(path);
var parser = new Parser(reader);
Expand Down Expand Up @@ -113,23 +127,22 @@ public static IReadOnlyList<BrailleSpecCase> Read(string path)
break;

case "flags":
mode = ReadFlags(parser);
mode = ReadFlags(parser, skipped);
break;

case "tests":
ReadTests(parser, specFile, tables, displayTable, mode, cases);
ReadTests(parser, specFile, tables, displayTable, mode, cases, skipped);
tablesUsed = true;
break;

default:
throw new NotSupportedException(
$"{specFile}: unsupported top level key '{key}'. This reader models only the " +
"constructs the Danish specs use; see lou_checkyaml.c for the full format.");
$"{specFile}: unsupported top level key '{key}'. See lou_checkyaml.c for the " +
"full format.");
}

}

return cases;
return new BrailleSpec(cases, skipped);
}

/// <summary>
Expand Down Expand Up @@ -169,7 +182,7 @@ private static (string Query, string? AssertMatch) ReadTableValue(IParser parser
return (string.Join(' ', terms), assertMatch);
}

private static TestMode ReadFlags(IParser parser)
private static TestMode ReadFlags(IParser parser, IDictionary<string, int> skipped)
{
parser.Consume<MappingStart>();

Expand All @@ -185,29 +198,42 @@ private static TestMode ReadFlags(IParser parser)
throw new NotSupportedException($"unsupported flag '{key}'");
}

mode = ParseTestMode(value);
mode = ParseTestMode(value, skipped);
}

parser.Consume<MappingEnd>();

return mode;
}

private static TestMode ParseTestMode(string value) => value switch
/// <summary>
/// hyphenate and display are liblouis features this harness does not drive yet — they map to
/// <c>Hyphenate</c> and <c>DotsToCharacters</c>/<c>CharactersToDots</c> — so their cases are
/// counted and dropped. An unrecognised mode still throws.
/// </summary>
private static TestMode ParseTestMode(string value, IDictionary<string, int> skipped) => value switch
{
"forward" => TestMode.Forward,
"backward" => TestMode.Backward,
"bothDirections" => TestMode.BothDirections,
"hyphenate" or "hyphenateBraille" or "display" => Skip($"testmode: {value}", skipped),
_ => throw new NotSupportedException($"unsupported testmode '{value}'"),
};

private static TestMode Skip(string construct, IDictionary<string, int> skipped)
{
skipped[construct] = skipped.TryGetValue(construct, out int n) ? n + 1 : 1;
return TestMode.Unsupported;
}

private static void ReadTests(
IParser parser,
string specFile,
List<(string Query, string? AssertMatch)> tables,
string displayTable,
TestMode mode,
List<BrailleSpecCase> cases)
List<BrailleSpecCase> cases,
IDictionary<string, int> skipped)
{
parser.Consume<SequenceStart>();

Expand All @@ -216,21 +242,35 @@ private static void ReadTests(
SequenceStart entryStart = parser.Consume<SequenceStart>();
int line = (int)entryStart.Start.Line;

string input = Unescape(parser.Consume<Scalar>().Value);
string expected = Unescape(parser.Consume<Scalar>().Value);
// An entry is [input, expected] or [description, input, expected], the second form
// labelling a group of cases. They are told apart by what follows the first two
// scalars: another scalar means the first was a label.
string first = Unescape(parser.Consume<Scalar>().Value);
string second = Unescape(parser.Consume<Scalar>().Value);
string input, expected;

if (parser.Current is Scalar)
{
input = second;
expected = Unescape(parser.Consume<Scalar>().Value);
}
else
{
input = first;
expected = second;
}

var xfail = XFail.None;
TestMode entryMode = mode;
bool skip = false;

if (parser.Current is MappingStart)
{
(xfail, entryMode, skip) = ReadTestOptions(parser, mode);
(xfail, entryMode) = ReadTestOptions(parser, mode, skipped);
}

parser.Consume<SequenceEnd>();

if (skip)
if (entryMode == TestMode.Unsupported)
{
continue;
}
Expand Down Expand Up @@ -275,13 +315,12 @@ private enum XFail
Both = Forward | Backward,
}

private static (XFail XFail, TestMode Mode, bool Skip) ReadTestOptions(
IParser parser, TestMode mode)
private static (XFail XFail, TestMode Mode) ReadTestOptions(
IParser parser, TestMode mode, IDictionary<string, int> skipped)
{
parser.Consume<MappingStart>();

var xfail = XFail.None;
bool skip = false;

while (parser.Current is not MappingEnd)
{
Expand All @@ -294,15 +333,22 @@ private static (XFail XFail, TestMode Mode, bool Skip) ReadTestOptions(
break;

case "testmode":
mode = ParseTestMode(parser.Consume<Scalar>().Value);
mode = ParseTestMode(parser.Consume<Scalar>().Value, skipped);
break;

// Emphasis is applied through typeform, which this prototype does not drive yet.
// Skip the value so the rest of the file still parses, and drop the case: silently
// running it without the typeform would compare against the wrong expectation.
// Options liblouis supports that this harness does not drive. Each changes what the
// expected output means, so running the case without honouring it would compare
// against the wrong thing. The value is consumed and the case dropped.
case "typeform":
case "mode":
case "inputPos":
case "outputPos":
case "cursorPos":
case "cursorOutPos":
case "maxOutputLength":
case "realInputLength":
parser.SkipThisAndNestedEvents();
skip = true;
mode = Skip($"test option: {key}", skipped);
break;

default:
Expand All @@ -312,7 +358,7 @@ private static (XFail XFail, TestMode Mode, bool Skip) ReadTestOptions(

parser.Consume<MappingEnd>();

return (xfail, mode, skip);
return (xfail, mode);
}

/// <summary>
Expand Down Expand Up @@ -355,10 +401,9 @@ private static XFail ReadXFail(IParser parser)

/// <summary>
/// The specs use single quoted scalars, where YAML performs no escape processing at all, and
/// rely on liblouis to interpret the escapes itself. Only the forms the Danish specs actually
/// use are handled: \xNNNN and \uNNNN code points, and \\ for a literal backslash.
/// Without the backslash case, 'at\\bliver' parses as two backslashes and translates to two
/// cells where upstream expects one.
/// rely on liblouis to interpret the escapes itself. Only the forms the specs use are handled:
/// \xNNNN and \uNNNN code points, and \\ for a literal backslash. Without the backslash case,
/// 'at\\bliver' parses as two backslashes and translates to two cells where one is expected.
/// </summary>
private static string Unescape(string value)
{
Expand Down
108 changes: 92 additions & 16 deletions LibLouis.NET.Test/BrailleSpecTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;

using Xunit;

Expand Down Expand Up @@ -50,15 +51,51 @@
[MemberData(nameof(SpecFiles))]
public void MatchesUpstreamExpectations(string specFile)
{
IReadOnlyList<BrailleSpecCase> cases = BrailleSpecReader.Read(Path.Combine(SpecDirectory, specFile));
// Run on a thread with a generous stack, because compiling a table can recurse deeply.
// ancient-languages-borger.utb needs somewhere between 640KB and 768KB to compile, which is
// more than the test host hands a test, and a stack overflow kills the process rather than
// failing the test.
//
// It is compilation, not translation: once a table list is compiled, translating through it
// runs in 128KB. The first call using a table list is simply the one that pays for the
// compile. Nothing about the input matters - ASCII overflows the same as non-BMP - and
// liblouis caches compiled tables process-wide, so without a large stack somewhere the
// result depends on which test happened to compile a given table first.
Exception? failure = null;
var worker = new Thread(
() =>
{
try
{
RunSpec(specFile);
}
catch (Exception ex)
{
failure = ex;
}
},
64 * 1024 * 1024);

worker.Start();
worker.Join();

if (failure is not null)
{
throw new Xunit.Sdk.XunitException(failure.Message);
}
}

private static void RunSpec(string specFile)
{
BrailleSpec spec = BrailleSpecReader.Read(Path.Combine(SpecDirectory, specFile));

IndexUpstreamTables();

var mismatches = new List<string>();
var unexpectedPasses = new List<string>();
int checkedCount = 0;

foreach (BrailleSpecCase testCase in cases)
foreach (BrailleSpecCase testCase in spec.Cases)
{

string table = ResolveTable(testCase, TableCache.Value);
Expand Down Expand Up @@ -122,10 +159,30 @@

var resolved = new Dictionary<string, string>(StringComparer.Ordinal);

foreach (string query in Directory.EnumerateFiles(SpecDirectory, "*.yaml")
.SelectMany(BrailleSpecReader.Read)
.Select(c => c.TableQuery)
.Distinct(StringComparer.Ordinal))
// A spec this reader cannot parse is skipped here rather than allowed to throw: the cache is
// shared by every spec's test, so one unparseable file would otherwise fail all of them
// instead of just its own.
var queries = new SortedSet<string>(StringComparer.Ordinal);

foreach (string file in Directory.EnumerateFiles(SpecDirectory, "*.yaml"))
{
try
{
foreach (BrailleSpecCase testCase in BrailleSpecReader.Read(file).Cases)
{
if (testCase.TableQuery.Contains(':', StringComparison.Ordinal))
{
queries.Add(testCase.TableQuery);
}
}
}
catch (Exception)
{
// Reported by that spec's own test.
}
}

foreach (string query in queries)
{
resolved[query] = LibLouis.Instance.FindTable(query) ?? string.Empty;
}
Expand All @@ -141,6 +198,19 @@
/// </summary>
private static string ResolveTable(BrailleSpecCase testCase, Dictionary<string, string> cache)
{
// A table is given three ways: as a query for lou_findTable, as a plain file name, or as an
// inline table written as a block scalar. Inline content is the only one containing a
// newline; a query is always key:value pairs, so a colon separates the other two.
if (testCase.TableQuery.Contains('\n', StringComparison.Ordinal))
{
return Materialize(testCase.TableQuery, ".utb");
}

if (!testCase.TableQuery.Contains(':', StringComparison.Ordinal))
{
return Path.Combine(TableDirectory, testCase.TableQuery);
}

if (!cache.TryGetValue(testCase.TableQuery, out string? resolved))
{
resolved = LibLouis.Instance.FindTable(testCase.TableQuery) ?? string.Empty;
Expand Down Expand Up @@ -168,20 +238,26 @@
/// liblouis only takes paths, so the inline form is written out next to the tables, where the
/// includes inside it resolve.
/// </summary>
private static string ResolveDisplayTable(string display)
{
// A file name never contains a newline, so this distinguishes the two forms.
if (!display.Contains('\n', StringComparison.Ordinal))
{
return Path.Combine(TableDirectory, display);
}
private static string ResolveDisplayTable(string display) =>
display.Contains('\n', StringComparison.Ordinal)
? Materialize(display, ".dis")
: Path.Combine(TableDirectory, display);

string name = $"inline-{Convert.ToHexString(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(display)))[..8]}.dis";
string path = Path.Combine(TableDirectory, name);
/// <summary>
/// Writes an inline table out next to the tables, where the include lines inside it resolve.
/// liblouis only takes paths, and specs may define a display or translation table inline as a
/// block scalar rather than naming a file — usually to include a standard table and override a
/// rule or two.
/// </summary>
private static string Materialize(string content, string extension)
{
string hash = Convert.ToHexString(
System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(content)))[..8];

Check warning on line 255 in LibLouis.NET.Test/BrailleSpecTests.cs

View workflow job for this annotation

GitHub Actions / test (windows-latest)

Materialize uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)

Check warning on line 255 in LibLouis.NET.Test/BrailleSpecTests.cs

View workflow job for this annotation

GitHub Actions / managed packages

Materialize uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)

Check warning on line 255 in LibLouis.NET.Test/BrailleSpecTests.cs

View workflow job for this annotation

GitHub Actions / managed packages

Materialize uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)

Check warning on line 255 in LibLouis.NET.Test/BrailleSpecTests.cs

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

Materialize uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)

Check warning on line 255 in LibLouis.NET.Test/BrailleSpecTests.cs

View workflow job for this annotation

GitHub Actions / test (macos-latest)

Materialize uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)
string path = Path.Combine(TableDirectory, $"inline-{hash}{extension}");

if (!File.Exists(path))
{
File.WriteAllText(path, display);
File.WriteAllText(path, content);
}

return path;
Expand Down
Loading
Loading