Skip to content

Commit 0d605e7

Browse files
test: Widen the braille specs from Danish to 102 upstream specs
Runs liblouis's own expectations for roughly 60 languages through the wrapper instead of only Danish. 102 specs, all passing. Five things the reader did not model, each found by specs failing rather than by reading the format: - a test entry can lead with a description, [label, input, expected] - a translation table can be written inline as a block scalar, not just named. nemeth.yaml failed 133 of 133 on this alone - so can a display table, for the same reason - a table can be named by file rather than by query, in which case it must not go through lou_findTable - typeform, mode, inputPos, outputPos and cursorPos change what the expected output means, so those cases are counted and dropped rather than run against the wrong expectation The counting is the point: BrailleSpec.SkippedConstructs records what was recognised but not driven, so coverage that is not happening stays visible. Anything outside that list still throws. Each spec runs on a thread with a large stack. Compiling a table can recurse deeply - ancient-languages-borger.utb needs between 640KB and 768KB, more than the test host gives a test - and a stack overflow kills the process rather than failing a test. It is compilation, not translation: once a table list is compiled, translating through it runs in 128KB. Nothing about the input matters, and liblouis caches compiled tables process wide, so without a large stack somewhere the outcome depends on which test compiled a table first. 40 specs are held back with their reasons written down in the README. 29 of them fail on table resolution and are probably one root cause rather than 29. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2bedeed commit 0d605e7

102 files changed

Lines changed: 123482 additions & 48 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

LibLouis.NET.Test/BrailleSpec.cs

Lines changed: 77 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ private static string Describe(string value) =>
3535
: string.Concat(value.Select(c => c is >= ' ' and <= '~' ? c.ToString() : $"\\u{(int)c:X4}"));
3636
}
3737

38+
/// <summary>
39+
/// A parsed spec: the cases it yields, and a tally of the constructs that were recognised but not
40+
/// driven, so what is being skipped stays visible rather than becoming invisible coverage loss.
41+
/// </summary>
42+
public sealed record BrailleSpec(
43+
IReadOnlyList<BrailleSpecCase> Cases,
44+
IReadOnlyDictionary<string, int> SkippedConstructs);
45+
3846
public enum TestDirection
3947
{
4048
Forward,
@@ -51,6 +59,9 @@ public enum TestMode
5159
Forward,
5260
Backward,
5361
BothDirections,
62+
63+
/// <summary>A liblouis feature this harness recognises but does not drive yet.</summary>
64+
Unsupported,
5465
}
5566

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

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

115129
case "flags":
116-
mode = ReadFlags(parser);
130+
mode = ReadFlags(parser, skipped);
117131
break;
118132

119133
case "tests":
120-
ReadTests(parser, specFile, tables, displayTable, mode, cases);
134+
ReadTests(parser, specFile, tables, displayTable, mode, cases, skipped);
121135
tablesUsed = true;
122136
break;
123137

124138
default:
125139
throw new NotSupportedException(
126-
$"{specFile}: unsupported top level key '{key}'. This reader models only the " +
127-
"constructs the Danish specs use; see lou_checkyaml.c for the full format.");
140+
$"{specFile}: unsupported top level key '{key}'. See lou_checkyaml.c for the " +
141+
"full format.");
128142
}
129-
130143
}
131144

132-
return cases;
145+
return new BrailleSpec(cases, skipped);
133146
}
134147

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

172-
private static TestMode ReadFlags(IParser parser)
185+
private static TestMode ReadFlags(IParser parser, IDictionary<string, int> skipped)
173186
{
174187
parser.Consume<MappingStart>();
175188

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

188-
mode = ParseTestMode(value);
201+
mode = ParseTestMode(value, skipped);
189202
}
190203

191204
parser.Consume<MappingEnd>();
192205

193206
return mode;
194207
}
195208

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

223+
private static TestMode Skip(string construct, IDictionary<string, int> skipped)
224+
{
225+
skipped[construct] = skipped.TryGetValue(construct, out int n) ? n + 1 : 1;
226+
return TestMode.Unsupported;
227+
}
228+
204229
private static void ReadTests(
205230
IParser parser,
206231
string specFile,
207232
List<(string Query, string? AssertMatch)> tables,
208233
string displayTable,
209234
TestMode mode,
210-
List<BrailleSpecCase> cases)
235+
List<BrailleSpecCase> cases,
236+
IDictionary<string, int> skipped)
211237
{
212238
parser.Consume<SequenceStart>();
213239

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

219-
string input = Unescape(parser.Consume<Scalar>().Value);
220-
string expected = Unescape(parser.Consume<Scalar>().Value);
245+
// An entry is [input, expected] or [description, input, expected], the second form
246+
// labelling a group of cases. They are told apart by what follows the first two
247+
// scalars: another scalar means the first was a label.
248+
string first = Unescape(parser.Consume<Scalar>().Value);
249+
string second = Unescape(parser.Consume<Scalar>().Value);
250+
string input, expected;
251+
252+
if (parser.Current is Scalar)
253+
{
254+
input = second;
255+
expected = Unescape(parser.Consume<Scalar>().Value);
256+
}
257+
else
258+
{
259+
input = first;
260+
expected = second;
261+
}
221262

222263
var xfail = XFail.None;
223264
TestMode entryMode = mode;
224-
bool skip = false;
225265

226266
if (parser.Current is MappingStart)
227267
{
228-
(xfail, entryMode, skip) = ReadTestOptions(parser, mode);
268+
(xfail, entryMode) = ReadTestOptions(parser, mode, skipped);
229269
}
230270

231271
parser.Consume<SequenceEnd>();
232272

233-
if (skip)
273+
if (entryMode == TestMode.Unsupported)
234274
{
235275
continue;
236276
}
@@ -275,13 +315,12 @@ private enum XFail
275315
Both = Forward | Backward,
276316
}
277317

278-
private static (XFail XFail, TestMode Mode, bool Skip) ReadTestOptions(
279-
IParser parser, TestMode mode)
318+
private static (XFail XFail, TestMode Mode) ReadTestOptions(
319+
IParser parser, TestMode mode, IDictionary<string, int> skipped)
280320
{
281321
parser.Consume<MappingStart>();
282322

283323
var xfail = XFail.None;
284-
bool skip = false;
285324

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

296335
case "testmode":
297-
mode = ParseTestMode(parser.Consume<Scalar>().Value);
336+
mode = ParseTestMode(parser.Consume<Scalar>().Value, skipped);
298337
break;
299338

300-
// Emphasis is applied through typeform, which this prototype does not drive yet.
301-
// Skip the value so the rest of the file still parses, and drop the case: silently
302-
// running it without the typeform would compare against the wrong expectation.
339+
// Options liblouis supports that this harness does not drive. Each changes what the
340+
// expected output means, so running the case without honouring it would compare
341+
// against the wrong thing. The value is consumed and the case dropped.
303342
case "typeform":
343+
case "mode":
344+
case "inputPos":
345+
case "outputPos":
346+
case "cursorPos":
347+
case "cursorOutPos":
348+
case "maxOutputLength":
349+
case "realInputLength":
304350
parser.SkipThisAndNestedEvents();
305-
skip = true;
351+
mode = Skip($"test option: {key}", skipped);
306352
break;
307353

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

313359
parser.Consume<MappingEnd>();
314360

315-
return (xfail, mode, skip);
361+
return (xfail, mode);
316362
}
317363

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

356402
/// <summary>
357403
/// The specs use single quoted scalars, where YAML performs no escape processing at all, and
358-
/// rely on liblouis to interpret the escapes itself. Only the forms the Danish specs actually
359-
/// use are handled: \xNNNN and \uNNNN code points, and \\ for a literal backslash.
360-
/// Without the backslash case, 'at\\bliver' parses as two backslashes and translates to two
361-
/// cells where upstream expects one.
404+
/// rely on liblouis to interpret the escapes itself. Only the forms the specs use are handled:
405+
/// \xNNNN and \uNNNN code points, and \\ for a literal backslash. Without the backslash case,
406+
/// 'at\\bliver' parses as two backslashes and translates to two cells where one is expected.
362407
/// </summary>
363408
private static string Unescape(string value)
364409
{

LibLouis.NET.Test/BrailleSpecTests.cs

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.IO;
44
using System.Linq;
55
using System.Text;
6+
using System.Threading;
67

78
using Xunit;
89

@@ -50,15 +51,51 @@ public static TheoryData<string> SpecFiles()
5051
[MemberData(nameof(SpecFiles))]
5152
public void MatchesUpstreamExpectations(string specFile)
5253
{
53-
IReadOnlyList<BrailleSpecCase> cases = BrailleSpecReader.Read(Path.Combine(SpecDirectory, specFile));
54+
// Run on a thread with a generous stack, because compiling a table can recurse deeply.
55+
// ancient-languages-borger.utb needs somewhere between 640KB and 768KB to compile, which is
56+
// more than the test host hands a test, and a stack overflow kills the process rather than
57+
// failing the test.
58+
//
59+
// It is compilation, not translation: once a table list is compiled, translating through it
60+
// runs in 128KB. The first call using a table list is simply the one that pays for the
61+
// compile. Nothing about the input matters - ASCII overflows the same as non-BMP - and
62+
// liblouis caches compiled tables process-wide, so without a large stack somewhere the
63+
// result depends on which test happened to compile a given table first.
64+
Exception? failure = null;
65+
var worker = new Thread(
66+
() =>
67+
{
68+
try
69+
{
70+
RunSpec(specFile);
71+
}
72+
catch (Exception ex)
73+
{
74+
failure = ex;
75+
}
76+
},
77+
64 * 1024 * 1024);
78+
79+
worker.Start();
80+
worker.Join();
81+
82+
if (failure is not null)
83+
{
84+
throw new Xunit.Sdk.XunitException(failure.Message);
85+
}
86+
}
87+
88+
private static void RunSpec(string specFile)
89+
{
90+
BrailleSpec spec = BrailleSpecReader.Read(Path.Combine(SpecDirectory, specFile));
5491

5592
IndexUpstreamTables();
5693

5794
var mismatches = new List<string>();
5895
var unexpectedPasses = new List<string>();
5996
int checkedCount = 0;
6097

61-
foreach (BrailleSpecCase testCase in cases)
98+
foreach (BrailleSpecCase testCase in spec.Cases)
6299
{
63100

64101
string table = ResolveTable(testCase, TableCache.Value);
@@ -122,10 +159,30 @@ public void MatchesUpstreamExpectations(string specFile)
122159

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

125-
foreach (string query in Directory.EnumerateFiles(SpecDirectory, "*.yaml")
126-
.SelectMany(BrailleSpecReader.Read)
127-
.Select(c => c.TableQuery)
128-
.Distinct(StringComparer.Ordinal))
162+
// A spec this reader cannot parse is skipped here rather than allowed to throw: the cache is
163+
// shared by every spec's test, so one unparseable file would otherwise fail all of them
164+
// instead of just its own.
165+
var queries = new SortedSet<string>(StringComparer.Ordinal);
166+
167+
foreach (string file in Directory.EnumerateFiles(SpecDirectory, "*.yaml"))
168+
{
169+
try
170+
{
171+
foreach (BrailleSpecCase testCase in BrailleSpecReader.Read(file).Cases)
172+
{
173+
if (testCase.TableQuery.Contains(':', StringComparison.Ordinal))
174+
{
175+
queries.Add(testCase.TableQuery);
176+
}
177+
}
178+
}
179+
catch (Exception)
180+
{
181+
// Reported by that spec's own test.
182+
}
183+
}
184+
185+
foreach (string query in queries)
129186
{
130187
resolved[query] = LibLouis.Instance.FindTable(query) ?? string.Empty;
131188
}
@@ -141,6 +198,19 @@ public void MatchesUpstreamExpectations(string specFile)
141198
/// </summary>
142199
private static string ResolveTable(BrailleSpecCase testCase, Dictionary<string, string> cache)
143200
{
201+
// A table is given three ways: as a query for lou_findTable, as a plain file name, or as an
202+
// inline table written as a block scalar. Inline content is the only one containing a
203+
// newline; a query is always key:value pairs, so a colon separates the other two.
204+
if (testCase.TableQuery.Contains('\n', StringComparison.Ordinal))
205+
{
206+
return Materialize(testCase.TableQuery, ".utb");
207+
}
208+
209+
if (!testCase.TableQuery.Contains(':', StringComparison.Ordinal))
210+
{
211+
return Path.Combine(TableDirectory, testCase.TableQuery);
212+
}
213+
144214
if (!cache.TryGetValue(testCase.TableQuery, out string? resolved))
145215
{
146216
resolved = LibLouis.Instance.FindTable(testCase.TableQuery) ?? string.Empty;
@@ -168,20 +238,26 @@ private static string ResolveTable(BrailleSpecCase testCase, Dictionary<string,
168238
/// liblouis only takes paths, so the inline form is written out next to the tables, where the
169239
/// includes inside it resolve.
170240
/// </summary>
171-
private static string ResolveDisplayTable(string display)
172-
{
173-
// A file name never contains a newline, so this distinguishes the two forms.
174-
if (!display.Contains('\n', StringComparison.Ordinal))
175-
{
176-
return Path.Combine(TableDirectory, display);
177-
}
241+
private static string ResolveDisplayTable(string display) =>
242+
display.Contains('\n', StringComparison.Ordinal)
243+
? Materialize(display, ".dis")
244+
: Path.Combine(TableDirectory, display);
178245

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

182258
if (!File.Exists(path))
183259
{
184-
File.WriteAllText(path, display);
260+
File.WriteAllText(path, content);
185261
}
186262

187263
return path;

0 commit comments

Comments
 (0)