diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 989d8d1..e46aa7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,14 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - name: Download bitstream compatibility vectors + run: | + curl --fail --location --retry 3 \ + --output "$RUNNER_TEMP/hyprfen-bitstreams_lichess-2013-01_100k.tsv" \ + "https://github.com/hyprchs/hyprfen/releases/download/bitstream-compatibility-v1/hyprfen-bitstreams_lichess-2013-01_100k.tsv" + echo "2a20ca230d172db191d9981734a2e8d99a97ac9b8747604e5e40580a1650ba7f $RUNNER_TEMP/hyprfen-bitstreams_lichess-2013-01_100k.tsv" | shasum -a 256 -c - - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DHYPRFEN_BITSTREAM_VECTORS="$RUNNER_TEMP/hyprfen-bitstreams_lichess-2013-01_100k.tsv" - name: Build run: cmake --build build --config Release - name: Test diff --git a/CMakeLists.txt b/CMakeLists.txt index e7bec72..1fa805c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,14 @@ if(BUILD_TESTING) ) target_link_libraries(hyprfen_tests PRIVATE hyprfen::hyprfen) add_test(NAME hyprfen_tests COMMAND hyprfen_tests) + + set(HYPRFEN_BITSTREAM_VECTORS "" CACHE FILEPATH "Path to hyprfen bitstream compatibility vectors") + if(HYPRFEN_BITSTREAM_VECTORS) + add_test( + NAME hyprfen_bitstream_compatibility + COMMAND hyprfen_tests "${HYPRFEN_BITSTREAM_VECTORS}" + ) + endif() endif() install( diff --git a/README.md b/README.md index 762fac7..da12f8a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > > It is a compact, reversible binary codec for standard chess positions. You give it a FEN string, it gives you bytes, and `decode_fen()` returns the exact original FEN. -This C++ implementation tests for bit-equivalent output to the Python implementation. That means you can encode with `hyprfen` and decode with `hyprfen-cpp` or vice versa to get back the same FEN. +This C++ implementation checks byte-for-byte compatibility with the Python implementation across 100,000 unique FENs from the Lichess January 2013 standard-rated dump. That means you can encode with `hyprfen` and decode with `hyprfen-cpp` or vice versa to get back the same FEN. ## Build @@ -16,6 +16,16 @@ cmake --build build ctest --test-dir build --output-on-failure ``` +CI also runs the 100,000-case bitstream compatibility test. To run it locally: + +```bash +curl --fail --location --output /tmp/hyprfen-bitstreams_lichess-2013-01_100k.tsv \ + https://github.com/hyprchs/hyprfen/releases/download/bitstream-compatibility-v1/hyprfen-bitstreams_lichess-2013-01_100k.tsv +echo '2a20ca230d172db191d9981734a2e8d99a97ac9b8747604e5e40580a1650ba7f /tmp/hyprfen-bitstreams_lichess-2013-01_100k.tsv' | shasum -a 256 -c - +cmake -S . -B build -DHYPRFEN_BITSTREAM_VECTORS=/tmp/hyprfen-bitstreams_lichess-2013-01_100k.tsv +ctest --test-dir build -R hyprfen_bitstream_compatibility --output-on-failure +``` + ## Public API ```cpp diff --git a/tests/test_hyprfen.cpp b/tests/test_hyprfen.cpp index a5f776d..0e750d5 100644 --- a/tests/test_hyprfen.cpp +++ b/tests/test_hyprfen.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -27,6 +28,28 @@ std::string to_hex(std::span bytes) { return out.str(); } +std::vector from_hex(std::string_view hex) { + if (hex.size() % 2 != 0) { + throw std::runtime_error("hex input must have an even length"); + } + const auto hex_digit = [](char ch) -> std::uint8_t { + if (ch >= '0' && ch <= '9') { + return static_cast(ch - '0'); + } + if (ch >= 'a' && ch <= 'f') { + return static_cast(ch - 'a' + 10); + } + throw std::runtime_error("invalid hex digit"); + }; + + std::vector bytes; + bytes.reserve(hex.size() / 2); + for (std::size_t i = 0; i < hex.size(); i += 2) { + bytes.push_back(static_cast((hex_digit(hex[i]) << 4) | hex_digit(hex[i + 1]))); + } + return bytes; +} + void expect(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string(message)); @@ -44,9 +67,44 @@ void expect_throws(Fn&& fn, std::string_view message) { expect(threw, message); } +void test_bitstream_compatibility(std::string_view path) { + std::ifstream input{std::string(path)}; + expect(input.good(), "could not open bitstream vector file"); + + std::size_t line_number = 0; + std::size_t cases = 0; + std::string line; + while (std::getline(input, line)) { + ++line_number; + if (!line.empty() && line.front() == '#') { + continue; + } + const std::size_t separator = line.find('\t'); + if (separator == std::string::npos) { + throw std::runtime_error("invalid bitstream vector at line " + std::to_string(line_number)); + } + const std::string_view fen(line.data(), separator); + std::vector expected; + try { + expected = from_hex(std::string_view(line.data() + separator + 1, line.size() - separator - 1)); + } catch (const std::exception& error) { + throw std::runtime_error("invalid bitstream vector at line " + std::to_string(line_number) + + ": " + error.what()); + } + const std::vector encoded = hyprfen::encode_fen(fen); + if (hyprfen::decode_fen(expected) != fen || encoded != expected || + hyprfen::decode_fen(encoded) != fen) { + throw std::runtime_error("bitstream compatibility mismatch at line " + + std::to_string(line_number)); + } + ++cases; + } + expect(cases == 100'000, "unexpected bitstream vector count"); +} + } // namespace -int main() { +int main(int argc, char* argv[]) { const GoldenCase golden_cases[] = { { "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", @@ -121,6 +179,12 @@ int main() { "non-zero padding bits should be rejected" ); + if (argc == 2) { + test_bitstream_compatibility(argv[1]); + } else { + expect(argc == 1, "expected at most one bitstream vector path"); + } + std::cout << "hyprfen_tests passed\n"; return 0; }