From 742094643d54d24e0285fb0118e5b3f732a109c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20O=2E=20S=C3=B8rensen?= Date: Wed, 5 Aug 2026 12:24:07 +0200 Subject: [PATCH 1/2] test: Separate Nota's tables from the upstream set LibLouis.NET.Test/tables held thirty of Nota's Danish tables while LibLouis.NET.Tables copies the upstream set into the same output directory. Twenty two of the thirty share a file name with an upstream table and differ from it, so which copy a test got depended on MSBuild item ordering, and nothing said so anywhere. Moves them to nota-tables/, kept separate in the output, and points every test at that path. Each test now states which set it means, and the upstream tables are no longer shadowed, which is what lets upstream's braille specs be checked against them. Nota's tables include seven general upstream tables, and liblouis resolves an include relative to the directory of the table doing the including, so those are copied in alongside. They come from the staged upstream set rather than being committed, so they cannot drift from it. NotaTablesAreSelfContained asserts that list stays complete, since an explicit list goes stale the moment a table gains an include. The second guard checks that none of Nota's tables reach tables/. Sharing a file name across the two directories is expected - most of Nota's are forks of an upstream table of the same name - so the tables that exist nowhere upstream are the canary: if one appears in tables/, the two sets are being copied to the same place again. No table content changes. Twenty three of the thirty are reachable from no test - they are a stale copy of the set the application ships - but they are kept and documented rather than deleted, because deciding what is canonical is not this change's business. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + LibLouis.NET.Test/HyphenateTests.cs | 2 +- LibLouis.NET.Test/IndexTablesTests.cs | 2 +- LibLouis.NET.Test/InputLengthTests.cs | 2 +- LibLouis.NET.Test/LibLouis.NET.Test.csproj | 26 ++++- LibLouis.NET.Test/NativeLockTests.cs | 2 +- LibLouis.NET.Test/NativeMethodsTests.cs | 8 +- LibLouis.NET.Test/NonBmpTests.cs | 2 +- LibLouis.NET.Test/NotaTablesTests.cs | 98 +++++++++++++++++++ LibLouis.NET.Test/OutputDotsTests.cs | 2 +- LibLouis.NET.Test/PositionMappingTests.cs | 2 +- LibLouis.NET.Test/ShutdownTests.cs | 2 +- LibLouis.NET.Test/TypeFormBufferTests.cs | 2 +- .../UTF8StringNoFreeMarshallerTests.cs | 2 +- LibLouis.NET.Test/nota-tables/README.md | 31 ++++++ .../da-dk-6miscChars.cti | 0 .../da-dk-6miscChars_1993.cti | 0 .../da-dk-8miscChars.cti | 0 .../da-dk-8miscChars_1993.cti | 0 .../{tables => nota-tables}/da-dk-braillo.dis | 0 .../{tables => nota-tables}/da-dk-g08.ctb | 0 .../da-dk-g08_1993.ctb | 0 .../da-dk-g16-crossword.ctb | 0 .../da-dk-g16-lit_1993.ctb | 0 .../da-dk-g16-markers.ctb | 0 .../{tables => nota-tables}/da-dk-g16.ctb | 0 .../da-dk-g16_1993-markers.ctb | 0 .../da-dk-g16_1993.ctb | 0 .../{tables => nota-tables}/da-dk-g18.ctb | 0 .../da-dk-g18_1993.ctb | 0 .../{tables => nota-tables}/da-dk-g2.dic | 0 .../da-dk-g26-lit_1993.ctb | 0 .../{tables => nota-tables}/da-dk-g26.ctb | 0 .../da-dk-g26_1993.ctb | 0 .../da-dk-g26l-lit.ctb | 0 .../da-dk-g26l-lit_1993.ctb | 0 .../{tables => nota-tables}/da-dk-g26l.ctb | 0 .../da-dk-g26l_1993.ctb | 0 .../{tables => nota-tables}/da-dk-g28.ctb | 0 .../da-dk-g28_1993.ctb | 0 .../{tables => nota-tables}/da-dk-g28l.ctb | 0 .../da-dk-g28l_1993.ctb | 0 .../{tables => nota-tables}/da-dk-g2_1993.dic | 0 .../da-dk-octobraille.dis | 0 .../da-dk-octobraille_1993.dis | 0 45 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 LibLouis.NET.Test/NotaTablesTests.cs create mode 100644 LibLouis.NET.Test/nota-tables/README.md rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-6miscChars.cti (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-6miscChars_1993.cti (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-8miscChars.cti (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-8miscChars_1993.cti (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-braillo.dis (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g08.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g08_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g16-crossword.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g16-lit_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g16-markers.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g16.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g16_1993-markers.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g16_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g18.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g18_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g2.dic (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g26-lit_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g26.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g26_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g26l-lit.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g26l-lit_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g26l.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g26l_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g28.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g28_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g28l.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g28l_1993.ctb (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-g2_1993.dic (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-octobraille.dis (100%) rename LibLouis.NET.Test/{tables => nota-tables}/da-dk-octobraille_1993.dis (100%) diff --git a/.gitignore b/.gitignore index 86c063c..cedb7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ runtime.*.liblouis/runtimes/ # Rider / ReSharper per-user settings *.DotSettings.user + +# Worktrees created by spawned Claude Code sessions +.claude/worktrees/ diff --git a/LibLouis.NET.Test/HyphenateTests.cs b/LibLouis.NET.Test/HyphenateTests.cs index a9b022e..2da22eb 100644 --- a/LibLouis.NET.Test/HyphenateTests.cs +++ b/LibLouis.NET.Test/HyphenateTests.cs @@ -18,7 +18,7 @@ public class HyphenateTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g26.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; [Fact] public void Hyphenate_ReturnsOneHyphenationFlagPerCharacter() diff --git a/LibLouis.NET.Test/IndexTablesTests.cs b/LibLouis.NET.Test/IndexTablesTests.cs index 8f0f8fa..7bfac4e 100644 --- a/LibLouis.NET.Test/IndexTablesTests.cs +++ b/LibLouis.NET.Test/IndexTablesTests.cs @@ -19,7 +19,7 @@ public class IndexTablesTests private static readonly string[] Tables = ["da-dk-g26.ctb", "da-dk-g16-markers.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; /// /// liblouis logs one "Analyzing table <name>" line per array entry it walks, so the number diff --git a/LibLouis.NET.Test/InputLengthTests.cs b/LibLouis.NET.Test/InputLengthTests.cs index 120d509..605a37e 100644 --- a/LibLouis.NET.Test/InputLengthTests.cs +++ b/LibLouis.NET.Test/InputLengthTests.cs @@ -29,7 +29,7 @@ public class InputLengthTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g26.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; /// /// Translate() only requires outputPosition to hold input.Length entries, so liblouis must diff --git a/LibLouis.NET.Test/LibLouis.NET.Test.csproj b/LibLouis.NET.Test/LibLouis.NET.Test.csproj index 7138be2..40fc277 100644 --- a/LibLouis.NET.Test/LibLouis.NET.Test.csproj +++ b/LibLouis.NET.Test/LibLouis.NET.Test.csproj @@ -29,7 +29,31 @@ - + + + PreserveNewest + + + + PreserveNewest diff --git a/LibLouis.NET.Test/NativeLockTests.cs b/LibLouis.NET.Test/NativeLockTests.cs index d36966c..48ce8b8 100644 --- a/LibLouis.NET.Test/NativeLockTests.cs +++ b/LibLouis.NET.Test/NativeLockTests.cs @@ -27,7 +27,7 @@ public class NativeLockTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g26.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; [Fact] public void VersionIsReported() diff --git a/LibLouis.NET.Test/NativeMethodsTests.cs b/LibLouis.NET.Test/NativeMethodsTests.cs index b95fa58..0bb2032 100644 --- a/LibLouis.NET.Test/NativeMethodsTests.cs +++ b/LibLouis.NET.Test/NativeMethodsTests.cs @@ -23,7 +23,7 @@ public void SingleMode() Array.Fill(modes, TypeForm.ForeignLanguage); string resultString = LibLouis.Instance.Translate( - tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t)), + tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t)), input, outputLength, modes, @@ -48,7 +48,7 @@ public void NestedMode_EmphasisInForeignLanguage() modes[6] = TypeForm.ForeignLanguage | TypeForm.Emphasis; string resultString = LibLouis.Instance.Translate( - tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t)), + tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t)), input, outputLength, modes, @@ -89,7 +89,7 @@ public void NestedMode_ForeignLanguageInEmphasis() Assert.Equal(inputLength, modes.Length); string resultString = LibLouis.Instance.Translate( - tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t)), + tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t)), input, outputLength, modes, @@ -105,7 +105,7 @@ public void TestPositionResults() const string input = "Første linje. Anden linje, med kursiveret tekst. Tredje linje."; const string expected = "@fze linje. @anç linje, m kursi#rò ükz. @tàdje linje."; - string[] tables = ["tables/da-dk-braillo.dis", "tables/da-dk-g26.ctb"]; + string[] tables = ["nota-tables/da-dk-braillo.dis", "nota-tables/da-dk-g26.ctb"]; int outputLength = input.Length * 4; int cursorPosition = 0; diff --git a/LibLouis.NET.Test/NonBmpTests.cs b/LibLouis.NET.Test/NonBmpTests.cs index 261417e..18a5999 100644 --- a/LibLouis.NET.Test/NonBmpTests.cs +++ b/LibLouis.NET.Test/NonBmpTests.cs @@ -23,7 +23,7 @@ public class NonBmpTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g26.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; /// /// How many widechars liblouis sees for : whole characters on a UCS-4 diff --git a/LibLouis.NET.Test/NotaTablesTests.cs b/LibLouis.NET.Test/NotaTablesTests.cs new file mode 100644 index 0000000..dc9d247 --- /dev/null +++ b/LibLouis.NET.Test/NotaTablesTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +using Xunit; + +namespace LibLouis.NET.Test; + +/// +/// Guards the nota-tables/ directory, which the tests use in place of the upstream set. +/// +public class NotaTablesTests +{ + private static readonly string NotaTableDirectory = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables"); + + private static readonly string UpstreamTableDirectory = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables"); + + /// + /// liblouis resolves an include relative to the directory of the table doing the including, so + /// every table nota-tables/ pulls in has to be there too. Most are Nota's own; the handful of + /// general upstream tables they need are copied in by an explicit list in the csproj, and an + /// explicit list is exactly the kind of thing that goes stale the moment a table gains an + /// include. + /// + [Fact] + public void NotaTablesAreSelfContained() + { + var present = TableFilesIn(NotaTableDirectory).ToHashSet(StringComparer.Ordinal); + var missing = new SortedSet(StringComparer.Ordinal); + + foreach (string file in present) + { + foreach (string included in IncludesOf(Path.Combine(NotaTableDirectory, file))) + { + if (!present.Contains(included)) + { + missing.Add($"{included} (included by {file})"); + } + } + } + + Assert.True( + missing.Count == 0, + "nota-tables/ is missing tables it includes, so those cannot be compiled from that " + + "directory alone. Add them to the upstream copy list in the csproj:\n " + + string.Join("\n ", missing)); + } + + /// + /// The separate directory exists so that nothing of Nota's shadows the upstream set. Sharing a + /// file name across the two directories is expected and fine - twenty two of Nota's tables are + /// forks of an upstream table of the same name. What must never happen is Nota's tables being + /// copied into tables/ as well, which is what the old single directory did. + /// + /// The tables that exist nowhere upstream are the reliable canary: if one of them turns up in + /// tables/, the copy is misconfigured, whatever the file names say. + /// + [Fact] + public void NotaTablesDoNotLeakIntoTheUpstreamDirectory() + { + Assert.True(Directory.Exists(UpstreamTableDirectory), "the upstream tables were not staged"); + + string[] notaOnly = + [ + "da-dk-g16-markers.ctb", + "da-dk-g16_1993-markers.ctb", + "da-dk-braillo.dis", + "da-dk-g16-crossword.ctb", + "da-dk-g26l.ctb", + "da-dk-g26l-lit.ctb", + "da-dk-g28l.ctb", + "da-dk-g2_1993.dic", + ]; + + var upstream = TableFilesIn(UpstreamTableDirectory).ToHashSet(StringComparer.Ordinal); + var leaked = notaOnly.Where(upstream.Contains).OrderBy(f => f, StringComparer.Ordinal).ToList(); + + Assert.True( + leaked.Count == 0, + "tables/ should hold only the upstream set, but these tables of Nota's are in it, so " + + "the two are being copied to the same place again:\n " + string.Join("\n ", leaked)); + } + + private static IEnumerable TableFilesIn(string directory) => + Directory.EnumerateFiles(directory) + .Select(Path.GetFileName) + .Where(f => f is not null && !f.EndsWith(".md", StringComparison.OrdinalIgnoreCase))!; + + private static IEnumerable IncludesOf(string path) => + File.ReadLines(path) + .Select(line => Regex.Match(line, @"^\s*include\s+(\S+)")) + .Where(m => m.Success) + .Select(m => m.Groups[1].Value); +} diff --git a/LibLouis.NET.Test/OutputDotsTests.cs b/LibLouis.NET.Test/OutputDotsTests.cs index 290db35..6ce3373 100644 --- a/LibLouis.NET.Test/OutputDotsTests.cs +++ b/LibLouis.NET.Test/OutputDotsTests.cs @@ -18,7 +18,7 @@ public class OutputDotsTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g08.ctb"]; private static string[] EightDotTables() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; private static TranslatedString TranslateWithTypeForm(string input) { diff --git a/LibLouis.NET.Test/PositionMappingTests.cs b/LibLouis.NET.Test/PositionMappingTests.cs index aae28b8..9ec1d80 100644 --- a/LibLouis.NET.Test/PositionMappingTests.cs +++ b/LibLouis.NET.Test/PositionMappingTests.cs @@ -21,7 +21,7 @@ public class PositionMappingTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g26.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; private static TranslatedString Translate(string input) { diff --git a/LibLouis.NET.Test/ShutdownTests.cs b/LibLouis.NET.Test/ShutdownTests.cs index e41c0fc..f7ccec0 100644 --- a/LibLouis.NET.Test/ShutdownTests.cs +++ b/LibLouis.NET.Test/ShutdownTests.cs @@ -24,7 +24,7 @@ public class ShutdownTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g26.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; private static FieldInfo ShutDownField => typeof(LibLouis).GetField("_shutDown", BindingFlags.NonPublic | BindingFlags.Static) diff --git a/LibLouis.NET.Test/TypeFormBufferTests.cs b/LibLouis.NET.Test/TypeFormBufferTests.cs index c4fd76d..3d01cf2 100644 --- a/LibLouis.NET.Test/TypeFormBufferTests.cs +++ b/LibLouis.NET.Test/TypeFormBufferTests.cs @@ -24,7 +24,7 @@ public class TypeFormBufferTests private static readonly string[] Tables = ["da-dk-braillo.dis", "da-dk-g16-markers.ctb"]; private static string[] TablePaths() => - [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables", t))]; + [.. Tables.Select(t => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nota-tables", t))]; /// /// Documents the native contract that makes the overrun possible, independent of the wrapper: diff --git a/LibLouis.NET.Test/UTF8StringNoFreeMarshallerTests.cs b/LibLouis.NET.Test/UTF8StringNoFreeMarshallerTests.cs index 0373381..eea4522 100644 --- a/LibLouis.NET.Test/UTF8StringNoFreeMarshallerTests.cs +++ b/LibLouis.NET.Test/UTF8StringNoFreeMarshallerTests.cs @@ -16,7 +16,7 @@ public unsafe class UTF8StringNoFreeMarshallerTests [Theory] [InlineData("")] [InlineData("a")] - [InlineData("tables/da-dk-g26.ctb")] + [InlineData("nota-tables/da-dk-g26.ctb")] [InlineData("Første linje")] // multi-byte UTF-8 [InlineData("\U0001D11E")] // non-BMP, surrogate pair on the managed side public void ConvertToManaged_ReadsNulTerminatedUtf8(string value) diff --git a/LibLouis.NET.Test/nota-tables/README.md b/LibLouis.NET.Test/nota-tables/README.md new file mode 100644 index 0000000..da6339e --- /dev/null +++ b/LibLouis.NET.Test/nota-tables/README.md @@ -0,0 +1,31 @@ +# Nota's Danish tables, for the tests + +Kept in their own directory, and copied to `nota-tables/` in the output rather than `tables/`. + +`LibLouis.NET.Tables` copies the upstream table set into `tables/`. Five of the files here share a +name with an upstream table and differ from it, so a single shared directory would make which copy +a test gets depend on MSBuild item ordering. Separate directories mean every test states which set +it means, and the upstream braille specs can be checked against upstream tables without this set +shadowing them. + +## What these are + +A fork of an older upstream, not a patch on the current one. They add things upstream does not +have, such as the `foreign` emphasis class behind `TypeForm.ForeignLanguage`, and they are missing +things upstream has since fixed, such as the rules that remove the space between `§` and a following +number. Do not assume a file here matches the upstream file of the same name in either direction. + +Two of them, `da-dk-g16-markers.ctb` and `da-dk-braillo.dis`, exist nowhere upstream, which is why +the tests cannot simply use the upstream set. + +## These are not canonical + +The canonical Nota tables ship with the application. This is a copy, and a stale one: only seven of +the thirty files are reachable from any test. + +Reachable: `da-dk-g16-markers.ctb`, `da-dk-braillo.dis`, `da-dk-g08.ctb`, `da-dk-g26.ctb`, and the +three they include (`da-dk-6miscChars.cti`, `da-dk-octobraille.dis`, `da-dk-g2.dic`). + +The other twenty three are referenced by nothing. They are kept for now, but nothing keeps them in +step with the application's copy, so treat any of them as evidence of nothing. Prefer adding a test +that needs a file over adding a file that no test needs. diff --git a/LibLouis.NET.Test/tables/da-dk-6miscChars.cti b/LibLouis.NET.Test/nota-tables/da-dk-6miscChars.cti similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-6miscChars.cti rename to LibLouis.NET.Test/nota-tables/da-dk-6miscChars.cti diff --git a/LibLouis.NET.Test/tables/da-dk-6miscChars_1993.cti b/LibLouis.NET.Test/nota-tables/da-dk-6miscChars_1993.cti similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-6miscChars_1993.cti rename to LibLouis.NET.Test/nota-tables/da-dk-6miscChars_1993.cti diff --git a/LibLouis.NET.Test/tables/da-dk-8miscChars.cti b/LibLouis.NET.Test/nota-tables/da-dk-8miscChars.cti similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-8miscChars.cti rename to LibLouis.NET.Test/nota-tables/da-dk-8miscChars.cti diff --git a/LibLouis.NET.Test/tables/da-dk-8miscChars_1993.cti b/LibLouis.NET.Test/nota-tables/da-dk-8miscChars_1993.cti similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-8miscChars_1993.cti rename to LibLouis.NET.Test/nota-tables/da-dk-8miscChars_1993.cti diff --git a/LibLouis.NET.Test/tables/da-dk-braillo.dis b/LibLouis.NET.Test/nota-tables/da-dk-braillo.dis similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-braillo.dis rename to LibLouis.NET.Test/nota-tables/da-dk-braillo.dis diff --git a/LibLouis.NET.Test/tables/da-dk-g08.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g08.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g08.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g08.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g08_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g08_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g08_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g08_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g16-crossword.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g16-crossword.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g16-crossword.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g16-crossword.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g16-lit_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g16-lit_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g16-lit_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g16-lit_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g16-markers.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g16-markers.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g16-markers.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g16-markers.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g16.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g16.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g16.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g16.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g16_1993-markers.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g16_1993-markers.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g16_1993-markers.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g16_1993-markers.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g16_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g16_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g16_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g16_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g18.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g18.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g18.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g18.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g18_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g18_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g18_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g18_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g2.dic b/LibLouis.NET.Test/nota-tables/da-dk-g2.dic similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g2.dic rename to LibLouis.NET.Test/nota-tables/da-dk-g2.dic diff --git a/LibLouis.NET.Test/tables/da-dk-g26-lit_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g26-lit_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g26-lit_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g26-lit_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g26.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g26.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g26.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g26.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g26_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g26_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g26_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g26_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g26l-lit.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g26l-lit.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g26l-lit.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g26l-lit.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g26l-lit_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g26l-lit_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g26l-lit_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g26l-lit_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g26l.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g26l.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g26l.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g26l.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g26l_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g26l_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g26l_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g26l_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g28.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g28.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g28.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g28.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g28_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g28_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g28_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g28_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g28l.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g28l.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g28l.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g28l.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g28l_1993.ctb b/LibLouis.NET.Test/nota-tables/da-dk-g28l_1993.ctb similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g28l_1993.ctb rename to LibLouis.NET.Test/nota-tables/da-dk-g28l_1993.ctb diff --git a/LibLouis.NET.Test/tables/da-dk-g2_1993.dic b/LibLouis.NET.Test/nota-tables/da-dk-g2_1993.dic similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-g2_1993.dic rename to LibLouis.NET.Test/nota-tables/da-dk-g2_1993.dic diff --git a/LibLouis.NET.Test/tables/da-dk-octobraille.dis b/LibLouis.NET.Test/nota-tables/da-dk-octobraille.dis similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-octobraille.dis rename to LibLouis.NET.Test/nota-tables/da-dk-octobraille.dis diff --git a/LibLouis.NET.Test/tables/da-dk-octobraille_1993.dis b/LibLouis.NET.Test/nota-tables/da-dk-octobraille_1993.dis similarity index 100% rename from LibLouis.NET.Test/tables/da-dk-octobraille_1993.dis rename to LibLouis.NET.Test/nota-tables/da-dk-octobraille_1993.dis From ef748af0182a7d820afb12f96fac8da461c584c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20O=2E=20S=C3=B8rensen?= Date: Wed, 5 Aug 2026 10:12:11 +0200 Subject: [PATCH 2/2] 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 --- LibLouis.NET.Test/BrailleSpec.cs | 398 ++ LibLouis.NET.Test/BrailleSpecTests.cs | 194 + LibLouis.NET.Test/LibLouis.NET.Test.csproj | 10 + .../braille-specs/da-dk-6dot.yaml | 1124 ++++ .../braille-specs/da-dk-8dot.yaml | 1003 +++ .../braille-specs/da-dk_1993.yaml | 5970 +++++++++++++++++ 6 files changed, 8699 insertions(+) create mode 100644 LibLouis.NET.Test/BrailleSpec.cs create mode 100644 LibLouis.NET.Test/BrailleSpecTests.cs create mode 100644 LibLouis.NET.Test/braille-specs/da-dk-6dot.yaml create mode 100644 LibLouis.NET.Test/braille-specs/da-dk-8dot.yaml create mode 100644 LibLouis.NET.Test/braille-specs/da-dk_1993.yaml diff --git a/LibLouis.NET.Test/BrailleSpec.cs b/LibLouis.NET.Test/BrailleSpec.cs new file mode 100644 index 0000000..a3e944e --- /dev/null +++ b/LibLouis.NET.Test/BrailleSpec.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace LibLouis.NET.Test; + +/// +/// One translation case from an upstream braille spec. +/// +public sealed record BrailleSpecCase( + string SpecFile, + int Line, + string TableQuery, + string? AssertMatch, + string DisplayTable, + string Input, + string Expected, + TestDirection Direction, + bool ExpectedToFail) +{ + public override string ToString() => + $"{SpecFile}:{Line} {Direction} {Describe(Input)} -> {Describe(Expected)}"; + + // Braille output is mostly U+28xx, which is unreadable in a test runner's output, so show the + // code points for anything outside printable ASCII. + private static string Describe(string value) => + value.All(c => c is >= ' ' and <= '~') + ? $"\"{value}\"" + : string.Concat(value.Select(c => c is >= ' ' and <= '~' ? c.ToString() : $"\\u{(int)c:X4}")); +} + +public enum TestDirection +{ + Forward, + Backward, +} + +/// +/// How a spec entry's two values are used. The distinction matters: the backward leg of +/// bothDirections swaps input and expected (lou_checkyaml.c:900-902), while an explicit +/// backward testmode does not (lou_checkyaml.c:892-895). +/// +public enum TestMode +{ + Forward, + Backward, + BothDirections, +} + +/// +/// Reads liblouis braille spec files. +/// +/// +/// These files are not YAML mappings and cannot be deserialised. A single document repeats +/// table, flags and tests at the same level, which duplicate key handling +/// would collapse or reject. lou_checkyaml treats the file as an event stream where each key +/// mutates parser state, and tests executes against whatever is current +/// (tools/lou_checkyaml.c:1087-1139), so this reader does the same over YamlDotNet's IParser. +/// +/// Consecutive table keys accumulate rather than replace: the following tests block +/// runs once per accumulated table. flags persists until the next flags. +/// +/// 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. +/// +public static class BrailleSpecReader +{ + public static IReadOnlyList Read(string path) + { + string specFile = Path.GetFileName(path); + var cases = new List(); + + using var reader = new StreamReader(path); + var parser = new Parser(reader); + + parser.Consume(); + parser.Consume(); + parser.Consume(); + + string displayTable = string.Empty; + var tables = new List<(string Query, string? AssertMatch)>(); + TestMode mode = TestMode.Forward; + + // Consecutive table keys accumulate, but the first one after a tests block starts a fresh + // set rather than adding to the one just used. + bool tablesUsed = false; + + while (parser.Current is not MappingEnd) + { + string key = parser.Consume().Value; + + switch (key) + { + case "display": + displayTable = ReadTableValue(parser).Query; + break; + + case "table": + if (tablesUsed) + { + tables.Clear(); + tablesUsed = false; + } + + tables.Add(ReadTableValue(parser)); + break; + + case "flags": + mode = ReadFlags(parser); + break; + + case "tests": + ReadTests(parser, specFile, tables, displayTable, mode, cases); + 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."); + } + + } + + return cases; + } + + /// + /// A table value is either a file name or a query mapping. Queries are passed to lou_findTable + /// as "key:value key:value"; __assert-match is a harness directive, not part of the query. + /// + private static (string Query, string? AssertMatch) ReadTableValue(IParser parser) + { + if (parser.Current is Scalar scalar) + { + parser.MoveNext(); + return (scalar.Value, null); + } + + parser.Consume(); + + var terms = new List(); + string? assertMatch = null; + + while (parser.Current is not MappingEnd) + { + string key = parser.Consume().Value; + string value = parser.Consume().Value; + + if (key == "__assert-match") + { + assertMatch = value; + } + else + { + terms.Add($"{key}:{value}"); + } + } + + parser.Consume(); + + return (string.Join(' ', terms), assertMatch); + } + + private static TestMode ReadFlags(IParser parser) + { + parser.Consume(); + + TestMode mode = TestMode.Forward; + + while (parser.Current is not MappingEnd) + { + string key = parser.Consume().Value; + string value = parser.Consume().Value; + + if (key != "testmode") + { + throw new NotSupportedException($"unsupported flag '{key}'"); + } + + mode = ParseTestMode(value); + } + + parser.Consume(); + + return mode; + } + + private static TestMode ParseTestMode(string value) => value switch + { + "forward" => TestMode.Forward, + "backward" => TestMode.Backward, + "bothDirections" => TestMode.BothDirections, + _ => throw new NotSupportedException($"unsupported testmode '{value}'"), + }; + + private static void ReadTests( + IParser parser, + string specFile, + List<(string Query, string? AssertMatch)> tables, + string displayTable, + TestMode mode, + List cases) + { + parser.Consume(); + + while (parser.Current is not SequenceEnd) + { + SequenceStart entryStart = parser.Consume(); + int line = (int)entryStart.Start.Line; + + string input = Unescape(parser.Consume().Value); + string expected = Unescape(parser.Consume().Value); + + var xfail = XFail.None; + TestMode entryMode = mode; + bool skip = false; + + if (parser.Current is MappingStart) + { + (xfail, entryMode, skip) = ReadTestOptions(parser, mode); + } + + parser.Consume(); + + if (skip) + { + continue; + } + + foreach ((string query, string? assertMatch) in tables) + { + // Forward compares translate(input) with expected. An explicit backward testmode + // means the entry is already written braille-first, so it is not swapped. The + // backward leg of bothDirections is: the expected braille is the input, and the + // original text is what back translation should produce. + if (entryMode is TestMode.Forward or TestMode.BothDirections) + { + cases.Add(new BrailleSpecCase( + specFile, line, query, assertMatch, displayTable, + input, expected, TestDirection.Forward, xfail.HasFlag(XFail.Forward))); + } + + if (entryMode == TestMode.Backward) + { + cases.Add(new BrailleSpecCase( + specFile, line, query, assertMatch, displayTable, + input, expected, TestDirection.Backward, xfail.HasFlag(XFail.Backward))); + } + else if (entryMode == TestMode.BothDirections) + { + cases.Add(new BrailleSpecCase( + specFile, line, query, assertMatch, displayTable, + expected, input, TestDirection.Backward, xfail.HasFlag(XFail.Backward))); + } + } + } + + parser.Consume(); + } + + [Flags] + private enum XFail + { + None = 0, + Forward = 1, + Backward = 2, + Both = Forward | Backward, + } + + private static (XFail XFail, TestMode Mode, bool Skip) ReadTestOptions( + IParser parser, TestMode mode) + { + parser.Consume(); + + var xfail = XFail.None; + bool skip = false; + + while (parser.Current is not MappingEnd) + { + string key = parser.Consume().Value; + + switch (key) + { + case "xfail": + xfail = ReadXFail(parser); + break; + + case "testmode": + mode = ParseTestMode(parser.Consume().Value); + 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. + case "typeform": + parser.SkipThisAndNestedEvents(); + skip = true; + break; + + default: + throw new NotSupportedException($"unsupported test option '{key}'"); + } + } + + parser.Consume(); + + return (xfail, mode, skip); + } + + /// + /// xfail is either a scalar, where only "false" and "off" are falsy + /// (tools/lou_checkyaml.c:379-389), or a mapping naming the failing directions. + /// + private static XFail ReadXFail(IParser parser) + { + if (parser.Current is Scalar scalar) + { + parser.MoveNext(); + return scalar.Value is "false" or "off" ? XFail.None : XFail.Both; + } + + parser.Consume(); + + var xfail = XFail.None; + + while (parser.Current is not MappingEnd) + { + string key = parser.Consume().Value; + string value = parser.Consume().Value; + bool set = value is not ("false" or "off"); + + if (set) + { + xfail |= key switch + { + "forward" => XFail.Forward, + "backward" => XFail.Backward, + _ => throw new NotSupportedException($"unsupported xfail direction '{key}'"), + }; + } + } + + parser.Consume(); + + return xfail; + } + + /// + /// 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. + /// + private static string Unescape(string value) + { + if (!value.Contains('\\', StringComparison.Ordinal)) + { + return value; + } + + var builder = new StringBuilder(value.Length); + + for (int i = 0; i < value.Length; i++) + { + if (value[i] == '\\' && i + 1 < value.Length) + { + if (value[i + 1] is 'x' or 'y' or 'u' && i + 5 < value.Length && + ushort.TryParse( + value.AsSpan(i + 2, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ushort code)) + { + builder.Append((char)code); + i += 5; + continue; + } + + if (value[i + 1] is '\\' or '"') + { + builder.Append(value[i + 1]); + i++; + continue; + } + } + + builder.Append(value[i]); + } + + return builder.ToString(); + } +} diff --git a/LibLouis.NET.Test/BrailleSpecTests.cs b/LibLouis.NET.Test/BrailleSpecTests.cs new file mode 100644 index 0000000..3a8dc95 --- /dev/null +++ b/LibLouis.NET.Test/BrailleSpecTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +using Xunit; + +namespace LibLouis.NET.Test; + +/// +/// Runs the upstream braille specs for Danish through the managed wrapper. +/// +/// +/// These are liblouis's own expectations, so they check the wrapper end to end against thousands of +/// cases nobody here had to invent: not just that a translation succeeds, but that it produces the +/// characters upstream says it should, in both directions. +/// +/// The specs live in braille-specs/ and are copied verbatim from +/// upstream/liblouis-<version>/tests/braille-specs/. Re-copy them when the upstream version is +/// bumped; the diff is the set of expectations that changed. +/// +/// One test per spec file rather than per case. Ten thousand xunit cases makes discovery slow and +/// buries a real regression in an unreadable log; a single failure listing every mismatch is more +/// use than ten thousand separate red entries. +/// +public class BrailleSpecTests +{ + private static readonly string SpecDirectory = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "braille-specs"); + + // tables/ is the upstream set. Nota's own tables live in nota-tables/ and no longer shadow it, + // so the specs can be checked against the tables they were actually written for. + private static readonly string TableDirectory = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tables"); + + public static TheoryData SpecFiles() + { + var data = new TheoryData(); + + foreach (string path in Directory.EnumerateFiles(SpecDirectory, "*.yaml").OrderBy(f => f, StringComparer.Ordinal)) + { + data.Add(Path.GetFileName(path)); + } + + return data; + } + + [Theory] + [MemberData(nameof(SpecFiles))] + public void MatchesUpstreamExpectations(string specFile) + { + IReadOnlyList cases = BrailleSpecReader.Read(Path.Combine(SpecDirectory, specFile)); + + IndexUpstreamTables(); + + var mismatches = new List(); + var unexpectedPasses = new List(); + int checkedCount = 0; + + foreach (BrailleSpecCase testCase in cases) + { + + string table = ResolveTable(testCase, TableCache.Value); + string? actual = Run(testCase, table); + bool matched = actual == testCase.Expected; + checkedCount++; + + if (testCase.ExpectedToFail) + { + // Upstream reports an unexpected pass as a warning rather than an error: the case + // is known-broken, and it passing usually means the expectation moved. + if (matched) + { + unexpectedPasses.Add(testCase.ToString()); + } + } + else if (!matched) + { + mismatches.Add($"{testCase}\n actual: {Describe(actual)}"); + } + } + + + Assert.True( + mismatches.Count == 0, + $"{specFile}: {mismatches.Count} of {checkedCount} cases did not match upstream " + + $"({unexpectedPasses.Count} xfail cases passed unexpectedly).\n " + + string.Join("\n ", mismatches.Take(25)) + + (mismatches.Count > 25 ? $"\n ... and {mismatches.Count - 25} more" : string.Empty)); + } + + private static string? Run(BrailleSpecCase testCase, string table) + { + string[] tables = [ResolveDisplayTable(testCase.DisplayTable), table]; + int outputLength = Math.Max(testCase.Input.Length, testCase.Expected.Length) * 4; + + try + { + return testCase.Direction == TestDirection.Forward + ? LibLouis.Instance.Translate(tables, testCase.Input, outputLength, null, null, TranslationMode.Regular) + : LibLouis.Instance.BackTranslate(tables, testCase.Input, outputLength, null, null, TranslationMode.Regular); + } + catch (LibLouisException ex) + { + // A translation that fails outright is a mismatch, not an error: upstream marks some of + // these xfail, and letting it throw would stop the whole file at the first one. The + // message is carried into the comparison so a failure says why, rather than only that + // nothing came back. + return $""; + } + } + + // Every query from every spec is resolved once, up front, before any translation runs. + // lou_findTable's return value is freed by the marshaller with the wrong allocator (P/Invoke + // audit item 3), so interleaving these calls with translations corrupts the native heap. + private static readonly Lazy> TableCache = new(() => + { + LibLouis.Instance.IndexTables( + Directory.EnumerateFiles(TableDirectory) + .Where(f => Path.GetExtension(f) is ".ctb" or ".utb" or ".uti" or ".dis" or ".cti" or ".dic")); + + var resolved = new Dictionary(StringComparer.Ordinal); + + foreach (string query in Directory.EnumerateFiles(SpecDirectory, "*.yaml") + .SelectMany(BrailleSpecReader.Read) + .Select(c => c.TableQuery) + .Distinct(StringComparer.Ordinal)) + { + resolved[query] = LibLouis.Instance.FindTable(query) ?? string.Empty; + } + + return resolved; + }); + + private static void IndexUpstreamTables() => _ = TableCache.Value; + + /// + /// Resolving a table query is what lou_findTable does, and the specs assert which file a query + /// should select, so this covers table resolution as well as translation. + /// + private static string ResolveTable(BrailleSpecCase testCase, Dictionary cache) + { + if (!cache.TryGetValue(testCase.TableQuery, out string? resolved)) + { + resolved = LibLouis.Instance.FindTable(testCase.TableQuery) ?? string.Empty; + cache[testCase.TableQuery] = resolved; + } + + Assert.False( + string.IsNullOrEmpty(resolved), + $"No table matched the query '{testCase.TableQuery}' from {testCase.SpecFile}:{testCase.Line}"); + + if (testCase.AssertMatch is not null) + { + Assert.True( + string.Equals(Path.GetFileName(resolved), testCase.AssertMatch, StringComparison.Ordinal), + $"Query '{testCase.TableQuery}' resolved to {Path.GetFileName(resolved)}, " + + $"but {testCase.SpecFile}:{testCase.Line} asserts {testCase.AssertMatch}"); + } + + return resolved; + } + + /// + /// A spec's display table is usually a file name, but it can also be an inline table written as + /// a YAML block scalar, for instance to include the standard one and then override a character. + /// liblouis only takes paths, so the inline form is written out next to the tables, where the + /// includes inside it resolve. + /// + 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); + } + + string name = $"inline-{Convert.ToHexString(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(display)))[..8]}.dis"; + string path = Path.Combine(TableDirectory, name); + + if (!File.Exists(path)) + { + File.WriteAllText(path, display); + } + + return path; + } + + private static string Describe(string? value) => + value is null + ? "" + : string.Concat(value.Select(c => c is >= ' ' and <= '~' ? c.ToString() : $"\\u{(int)c:X4}")); +} diff --git a/LibLouis.NET.Test/LibLouis.NET.Test.csproj b/LibLouis.NET.Test/LibLouis.NET.Test.csproj index 40fc277..1e8ca27 100644 --- a/LibLouis.NET.Test/LibLouis.NET.Test.csproj +++ b/LibLouis.NET.Test/LibLouis.NET.Test.csproj @@ -17,6 +17,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -29,6 +30,15 @@ + + + PreserveNewest + +