Skip to content

Commit 2bedeed

Browse files
test: Run the upstream braille specs against the wrapper
Adds a reader for liblouis's braille spec files and runs the three Danish ones through the managed wrapper. 10524 cases, in both directions, whose expectations were written by upstream rather than invented here. The files are not YAML mappings and cannot be deserialised: one document repeats table, flags and tests at the same level, and lou_checkyaml treats the file as an event stream where each key mutates parser state (tools/lou_checkyaml.c:1087-1139). The reader does the same over YamlDotNet's IParser. Consecutive table keys accumulate rather than replace, so a tests block runs once per accumulated table, which is why 5855 entries expand to 10524 cases. Two details of the format are easy to get wrong and both were, before the specs caught them: - the backward leg of bothDirections swaps input and expected, because back translation should turn the braille back into the text, while an explicit testmode: backward does not (lou_checkyaml.c:892 against 900). - a display table can be an inline table written as a block scalar rather than a file name, to include the standard one and override a character. That arrives as an ordinary scalar, so it was read as a path, and every test under it failed to translate. Those are now written out beside the tables, where their own includes resolve. Anything the reader does not model throws rather than being skipped, so a spec using an unsupported construct fails loudly instead of quietly testing less than it appears to. One test per spec file rather than per case: ten thousand xunit cases makes discovery slow and buries a real regression, where a single failure listing every mismatch does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent db3b36e commit 2bedeed

6 files changed

Lines changed: 8699 additions & 0 deletions

File tree

LibLouis.NET.Test/BrailleSpec.cs

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Text;
7+
8+
using YamlDotNet.Core;
9+
using YamlDotNet.Core.Events;
10+
11+
namespace LibLouis.NET.Test;
12+
13+
/// <summary>
14+
/// One translation case from an upstream braille spec.
15+
/// </summary>
16+
public sealed record BrailleSpecCase(
17+
string SpecFile,
18+
int Line,
19+
string TableQuery,
20+
string? AssertMatch,
21+
string DisplayTable,
22+
string Input,
23+
string Expected,
24+
TestDirection Direction,
25+
bool ExpectedToFail)
26+
{
27+
public override string ToString() =>
28+
$"{SpecFile}:{Line} {Direction} {Describe(Input)} -> {Describe(Expected)}";
29+
30+
// Braille output is mostly U+28xx, which is unreadable in a test runner's output, so show the
31+
// code points for anything outside printable ASCII.
32+
private static string Describe(string value) =>
33+
value.All(c => c is >= ' ' and <= '~')
34+
? $"\"{value}\""
35+
: string.Concat(value.Select(c => c is >= ' ' and <= '~' ? c.ToString() : $"\\u{(int)c:X4}"));
36+
}
37+
38+
public enum TestDirection
39+
{
40+
Forward,
41+
Backward,
42+
}
43+
44+
/// <summary>
45+
/// How a spec entry's two values are used. The distinction matters: the backward leg of
46+
/// bothDirections swaps input and expected (lou_checkyaml.c:900-902), while an explicit
47+
/// backward testmode does not (lou_checkyaml.c:892-895).
48+
/// </summary>
49+
public enum TestMode
50+
{
51+
Forward,
52+
Backward,
53+
BothDirections,
54+
}
55+
56+
/// <summary>
57+
/// Reads liblouis braille spec files.
58+
/// </summary>
59+
/// <remarks>
60+
/// These files are not YAML mappings and cannot be deserialised. A single document repeats
61+
/// <c>table</c>, <c>flags</c> and <c>tests</c> at the same level, which duplicate key handling
62+
/// would collapse or reject. lou_checkyaml treats the file as an event stream where each key
63+
/// mutates parser state, and <c>tests</c> executes against whatever is current
64+
/// (tools/lou_checkyaml.c:1087-1139), so this reader does the same over YamlDotNet's IParser.
65+
///
66+
/// Consecutive <c>table</c> keys accumulate rather than replace: the following <c>tests</c> block
67+
/// runs once per accumulated table. <c>flags</c> persists until the next <c>flags</c>.
68+
///
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.
72+
/// </remarks>
73+
public static class BrailleSpecReader
74+
{
75+
public static IReadOnlyList<BrailleSpecCase> Read(string path)
76+
{
77+
string specFile = Path.GetFileName(path);
78+
var cases = new List<BrailleSpecCase>();
79+
80+
using var reader = new StreamReader(path);
81+
var parser = new Parser(reader);
82+
83+
parser.Consume<StreamStart>();
84+
parser.Consume<DocumentStart>();
85+
parser.Consume<MappingStart>();
86+
87+
string displayTable = string.Empty;
88+
var tables = new List<(string Query, string? AssertMatch)>();
89+
TestMode mode = TestMode.Forward;
90+
91+
// Consecutive table keys accumulate, but the first one after a tests block starts a fresh
92+
// set rather than adding to the one just used.
93+
bool tablesUsed = false;
94+
95+
while (parser.Current is not MappingEnd)
96+
{
97+
string key = parser.Consume<Scalar>().Value;
98+
99+
switch (key)
100+
{
101+
case "display":
102+
displayTable = ReadTableValue(parser).Query;
103+
break;
104+
105+
case "table":
106+
if (tablesUsed)
107+
{
108+
tables.Clear();
109+
tablesUsed = false;
110+
}
111+
112+
tables.Add(ReadTableValue(parser));
113+
break;
114+
115+
case "flags":
116+
mode = ReadFlags(parser);
117+
break;
118+
119+
case "tests":
120+
ReadTests(parser, specFile, tables, displayTable, mode, cases);
121+
tablesUsed = true;
122+
break;
123+
124+
default:
125+
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.");
128+
}
129+
130+
}
131+
132+
return cases;
133+
}
134+
135+
/// <summary>
136+
/// A table value is either a file name or a query mapping. Queries are passed to lou_findTable
137+
/// as "key:value key:value"; __assert-match is a harness directive, not part of the query.
138+
/// </summary>
139+
private static (string Query, string? AssertMatch) ReadTableValue(IParser parser)
140+
{
141+
if (parser.Current is Scalar scalar)
142+
{
143+
parser.MoveNext();
144+
return (scalar.Value, null);
145+
}
146+
147+
parser.Consume<MappingStart>();
148+
149+
var terms = new List<string>();
150+
string? assertMatch = null;
151+
152+
while (parser.Current is not MappingEnd)
153+
{
154+
string key = parser.Consume<Scalar>().Value;
155+
string value = parser.Consume<Scalar>().Value;
156+
157+
if (key == "__assert-match")
158+
{
159+
assertMatch = value;
160+
}
161+
else
162+
{
163+
terms.Add($"{key}:{value}");
164+
}
165+
}
166+
167+
parser.Consume<MappingEnd>();
168+
169+
return (string.Join(' ', terms), assertMatch);
170+
}
171+
172+
private static TestMode ReadFlags(IParser parser)
173+
{
174+
parser.Consume<MappingStart>();
175+
176+
TestMode mode = TestMode.Forward;
177+
178+
while (parser.Current is not MappingEnd)
179+
{
180+
string key = parser.Consume<Scalar>().Value;
181+
string value = parser.Consume<Scalar>().Value;
182+
183+
if (key != "testmode")
184+
{
185+
throw new NotSupportedException($"unsupported flag '{key}'");
186+
}
187+
188+
mode = ParseTestMode(value);
189+
}
190+
191+
parser.Consume<MappingEnd>();
192+
193+
return mode;
194+
}
195+
196+
private static TestMode ParseTestMode(string value) => value switch
197+
{
198+
"forward" => TestMode.Forward,
199+
"backward" => TestMode.Backward,
200+
"bothDirections" => TestMode.BothDirections,
201+
_ => throw new NotSupportedException($"unsupported testmode '{value}'"),
202+
};
203+
204+
private static void ReadTests(
205+
IParser parser,
206+
string specFile,
207+
List<(string Query, string? AssertMatch)> tables,
208+
string displayTable,
209+
TestMode mode,
210+
List<BrailleSpecCase> cases)
211+
{
212+
parser.Consume<SequenceStart>();
213+
214+
while (parser.Current is not SequenceEnd)
215+
{
216+
SequenceStart entryStart = parser.Consume<SequenceStart>();
217+
int line = (int)entryStart.Start.Line;
218+
219+
string input = Unescape(parser.Consume<Scalar>().Value);
220+
string expected = Unescape(parser.Consume<Scalar>().Value);
221+
222+
var xfail = XFail.None;
223+
TestMode entryMode = mode;
224+
bool skip = false;
225+
226+
if (parser.Current is MappingStart)
227+
{
228+
(xfail, entryMode, skip) = ReadTestOptions(parser, mode);
229+
}
230+
231+
parser.Consume<SequenceEnd>();
232+
233+
if (skip)
234+
{
235+
continue;
236+
}
237+
238+
foreach ((string query, string? assertMatch) in tables)
239+
{
240+
// Forward compares translate(input) with expected. An explicit backward testmode
241+
// means the entry is already written braille-first, so it is not swapped. The
242+
// backward leg of bothDirections is: the expected braille is the input, and the
243+
// original text is what back translation should produce.
244+
if (entryMode is TestMode.Forward or TestMode.BothDirections)
245+
{
246+
cases.Add(new BrailleSpecCase(
247+
specFile, line, query, assertMatch, displayTable,
248+
input, expected, TestDirection.Forward, xfail.HasFlag(XFail.Forward)));
249+
}
250+
251+
if (entryMode == TestMode.Backward)
252+
{
253+
cases.Add(new BrailleSpecCase(
254+
specFile, line, query, assertMatch, displayTable,
255+
input, expected, TestDirection.Backward, xfail.HasFlag(XFail.Backward)));
256+
}
257+
else if (entryMode == TestMode.BothDirections)
258+
{
259+
cases.Add(new BrailleSpecCase(
260+
specFile, line, query, assertMatch, displayTable,
261+
expected, input, TestDirection.Backward, xfail.HasFlag(XFail.Backward)));
262+
}
263+
}
264+
}
265+
266+
parser.Consume<SequenceEnd>();
267+
}
268+
269+
[Flags]
270+
private enum XFail
271+
{
272+
None = 0,
273+
Forward = 1,
274+
Backward = 2,
275+
Both = Forward | Backward,
276+
}
277+
278+
private static (XFail XFail, TestMode Mode, bool Skip) ReadTestOptions(
279+
IParser parser, TestMode mode)
280+
{
281+
parser.Consume<MappingStart>();
282+
283+
var xfail = XFail.None;
284+
bool skip = false;
285+
286+
while (parser.Current is not MappingEnd)
287+
{
288+
string key = parser.Consume<Scalar>().Value;
289+
290+
switch (key)
291+
{
292+
case "xfail":
293+
xfail = ReadXFail(parser);
294+
break;
295+
296+
case "testmode":
297+
mode = ParseTestMode(parser.Consume<Scalar>().Value);
298+
break;
299+
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.
303+
case "typeform":
304+
parser.SkipThisAndNestedEvents();
305+
skip = true;
306+
break;
307+
308+
default:
309+
throw new NotSupportedException($"unsupported test option '{key}'");
310+
}
311+
}
312+
313+
parser.Consume<MappingEnd>();
314+
315+
return (xfail, mode, skip);
316+
}
317+
318+
/// <summary>
319+
/// xfail is either a scalar, where only "false" and "off" are falsy
320+
/// (tools/lou_checkyaml.c:379-389), or a mapping naming the failing directions.
321+
/// </summary>
322+
private static XFail ReadXFail(IParser parser)
323+
{
324+
if (parser.Current is Scalar scalar)
325+
{
326+
parser.MoveNext();
327+
return scalar.Value is "false" or "off" ? XFail.None : XFail.Both;
328+
}
329+
330+
parser.Consume<MappingStart>();
331+
332+
var xfail = XFail.None;
333+
334+
while (parser.Current is not MappingEnd)
335+
{
336+
string key = parser.Consume<Scalar>().Value;
337+
string value = parser.Consume<Scalar>().Value;
338+
bool set = value is not ("false" or "off");
339+
340+
if (set)
341+
{
342+
xfail |= key switch
343+
{
344+
"forward" => XFail.Forward,
345+
"backward" => XFail.Backward,
346+
_ => throw new NotSupportedException($"unsupported xfail direction '{key}'"),
347+
};
348+
}
349+
}
350+
351+
parser.Consume<MappingEnd>();
352+
353+
return xfail;
354+
}
355+
356+
/// <summary>
357+
/// 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.
362+
/// </summary>
363+
private static string Unescape(string value)
364+
{
365+
if (!value.Contains('\\', StringComparison.Ordinal))
366+
{
367+
return value;
368+
}
369+
370+
var builder = new StringBuilder(value.Length);
371+
372+
for (int i = 0; i < value.Length; i++)
373+
{
374+
if (value[i] == '\\' && i + 1 < value.Length)
375+
{
376+
if (value[i + 1] is 'x' or 'y' or 'u' && i + 5 < value.Length &&
377+
ushort.TryParse(
378+
value.AsSpan(i + 2, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ushort code))
379+
{
380+
builder.Append((char)code);
381+
i += 5;
382+
continue;
383+
}
384+
385+
if (value[i + 1] is '\\' or '"')
386+
{
387+
builder.Append(value[i + 1]);
388+
i++;
389+
continue;
390+
}
391+
}
392+
393+
builder.Append(value[i]);
394+
}
395+
396+
return builder.ToString();
397+
}
398+
}

0 commit comments

Comments
 (0)