|
| 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