include(CTest)

# Tests use the local bvni_* helpers in src/utils/bvn_double_impl.h rather than
# the <math.h> classification and scaling functions, so no executable in this
# suite links libm -- the same rule the shipped library follows
# (CONTRIBUTING.md). The tests that include the header get src/utils on their
# include path below.

# ── Which unit profiles this build carries ──────────────────────────────────
# The per-vocabulary switches (BVNR_WITH_*_PROFILE, see CMakeLists.txt) are
# compile-time, so a suite that asserts "ucum:m is the metre" cannot pass in a
# build without ucum. Each profile suite is therefore registered only when its
# own profile is on, and the gates that span the whole corpus -- the cross-
# vocabulary suite, the conformance runs and their documented case count, and the
# Python suites -- only when all seven are, because their totals are stated as
# single numbers and a partial build would make those numbers wrong rather than
# smaller. What keeps the reduced configurations honest instead is
# bvnr_profile_configs at the end of this file, which compiles and runs the
# extreme case.
#
# A suite's guard names every vocabulary it TOUCHES, not just its own: each of
# these files ends with an "agrees with the vocabularies already here" block that
# asserts one spelling against another's, so bvnr_om_test needs all six and
# bvnr_ucum_test needs one. The lists come from `grep -c '"<ns>:' ` over each
# suite; if one grows a cross-check against a new namespace, its guard has to grow
# with it -- the symptom otherwise is a reduced build failing on an assertion
# about a profile it was told not to carry.
set(BVNR_ALL_PROFILES TRUE)
foreach(_prof IN LISTS BVNR_PROFILE_NAMES)
    if(NOT BVNR_WITH_${_prof}_PROFILE)
        set(BVNR_ALL_PROFILES FALSE)
    endif()
endforeach()

set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tests")

# ── C unit tests ────────────────────────────────────────────────────────────

add_executable(bvnr_dom_test             tests/bovnar_dom_test.c)
add_executable(bvnr_si_test              tests/bovnar_si_units_test.c)
add_executable(bvnr_unit_ext_test        tests/bovnar_unit_ext_test.c)
add_executable(bvnr_ucum_test            tests/bovnar_ucum_test.c)
add_executable(bvnr_unit_source_test     tests/bovnar_unit_source_test.c)
add_executable(bvnr_unece_test           tests/bovnar_unece_test.c)
add_executable(bvnr_qudt_test            tests/bovnar_qudt_test.c)
add_executable(bvnr_udunits_test         tests/bovnar_udunits_test.c)
add_executable(bvnr_om_test              tests/bovnar_om_test.c)
add_executable(bvnr_cf_test              tests/bovnar_cf_test.c)
add_executable(bvnr_crossvocab_test      tests/bovnar_crossvocab_test.c)
add_executable(bvnr_reader_test          tests/bovnar_reader_test.c)
add_executable(bvnr_want_unit_test       tests/bovnar_want_unit_test.c)
add_executable(bvnr_unit_policy_test     tests/bovnar_unit_policy_test.c)
add_executable(bvnr_writer_test          tests/bovnar_writer_test.c)
add_executable(bvnr_extended_reader_test tests/bovnar_extended_reader_test.c)
add_executable(bvnr_utils_test           tests/bovnar_utils_test.c)
add_executable(bvnr_stream_test          tests/bovnar_stream_test.c)
add_executable(bvnr_feed_test            tests/bovnar_feed_test.c)
add_executable(bvnr_stream_vectors_test  tests/bovnar_stream_vectors_test.c)
add_executable(bvnr_alloc_test           tests/bovnar_alloc_test.c)
add_executable(bvnr_high_severity_test   tests/bovnar_high_severity_test.c)
add_executable(bvnr_currency_test        tests/bovnar_currency_test.c)

# The socketpair round-trip test is POSIX-only (socketpair(2) + pthread); it is
# excluded from the Windows/MinGW build. Guarded in three places: the target,
# its link line, and the CTest registration.
if(NOT WIN32)
# Threads::Threads, not the bare library name: it supplies the compiler's own
# -pthread spelling (needed on FreeBSD/OpenBSD for the POSIX thread declarations
# as well as the link) and resolves to libthr/libpthread per platform.
find_package(Threads REQUIRED)
add_executable(bvnr_socketpair_roundtrip_test tests/bovnar_socketpair_roundtrip_test.c)
# Distinct objects on distinct threads must not interfere. The library keeps no
# mutable shared state -- bovnar_lexer.c builds its run LUTs from a pre-main
# constructor precisely so the lazy-init flag cannot be raced on -- and that is a
# deliberate guarantee, so it gets a test. A later lazily-initialised cache would
# otherwise break it silently: the failure mode is a rare wrong parse, not a
# crash. POSIX-only, like the socketpair test above.
add_executable(bvnr_concurrency_test tests/bovnar_concurrency_test.c)
target_compile_options(bvnr_concurrency_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_concurrency_test PRIVATE bvnr_static Threads::Threads)
add_test(NAME bvnr_concurrency COMMAND bvnr_concurrency_test)
set_tests_properties(bvnr_concurrency PROPERTIES
    LABELS "unit;threads"
    FAIL_REGULAR_EXPRESSION "FAILED"
    TIMEOUT 120)
endif()

# What the writer emits, the reader must read back unchanged -- swept over every
# integer width at its boundary values, every numeric base for bignums, and the
# exact set of control bytes a string may carry. That last one is a SYMMETRY
# check: if the reader's and writer's sets ever diverge, the library can read a
# document whose value it cannot write back.
add_executable(bvnr_write_read_roundtrip_test tests/bovnar_write_read_roundtrip_test.c)
target_compile_options(bvnr_write_read_roundtrip_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_write_read_roundtrip_test PRIVATE bvnr_static)
add_test(NAME bvnr_write_read_roundtrip COMMAND bvnr_write_read_roundtrip_test)
set_tests_properties(bvnr_write_read_roundtrip PROPERTIES
    LABELS "unit;writer;reader;roundtrip"
    FAIL_REGULAR_EXPRESSION "FAILED"
    TIMEOUT 60)

add_executable(bvnr_int_test          tests/bvn_int_test.c)
add_executable(bvnr_float_test           tests/bvn_float_test.c)
add_executable(bvnr_datetime_test        tests/bvn_datetime_test.c)

# ABI manifest generator: prints the C compiler's struct sizes/offsets, the
# source of truth the Python ctypes mirrors are checked against (test_abi.py).
add_executable(bvnr_abi_dump             tests/bvnr_abi_dump.c)
target_link_libraries(bvnr_abi_dump PRIVATE bvnr_static)

target_compile_options(bvnr_dom_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_dom_test PRIVATE bvnr_static)

target_compile_options(bvnr_si_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_si_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_si_test PRIVATE bvnr_static)

target_compile_options(bvnr_unit_ext_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_unit_ext_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_unit_ext_test PRIVATE bvnr_static)
target_compile_options(bvnr_ucum_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_ucum_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_ucum_test PRIVATE bvnr_static)
target_compile_options(bvnr_unit_source_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_unit_source_test PRIVATE bvnr_static)

target_compile_options(bvnr_unece_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_unece_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_unece_test PRIVATE bvnr_static)

target_compile_options(bvnr_qudt_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_qudt_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_qudt_test PRIVATE bvnr_static)

target_compile_options(bvnr_udunits_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_udunits_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_udunits_test PRIVATE bvnr_static)

target_compile_options(bvnr_om_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_om_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_om_test PRIVATE bvnr_static)

target_compile_options(bvnr_cf_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_cf_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_cf_test PRIVATE bvnr_static)

target_compile_options(bvnr_crossvocab_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_crossvocab_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_crossvocab_test PRIVATE bvnr_static)

target_compile_options(bvnr_reader_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_reader_test PRIVATE bvnr_static)

target_compile_options(bvnr_want_unit_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_want_unit_test PRIVATE bvnr_static)

target_compile_options(bvnr_unit_policy_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_unit_policy_test PRIVATE bvnr_static)

target_compile_options(bvnr_writer_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_writer_test PRIVATE bvnr_static)

target_compile_options(bvnr_extended_reader_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_extended_reader_test PRIVATE bvnr_static)

target_compile_options(bvnr_utils_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_utils_test PRIVATE bvnr_static)
target_include_directories(bvnr_utils_test PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)

if(NOT WIN32)
target_compile_options(bvnr_socketpair_roundtrip_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_socketpair_roundtrip_test PRIVATE bvnr_static Threads::Threads)
endif()

target_compile_options(bvnr_stream_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_stream_test PRIVATE bvnr_static)

target_compile_options(bvnr_feed_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_feed_test PRIVATE bvnr_static)

target_compile_options(bvnr_stream_vectors_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_stream_vectors_test PRIVATE bvnr_static)

target_compile_options(bvnr_alloc_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_alloc_test PRIVATE bvnr_static)

# Per-message cost of bvnr_mux_send and the demux, batch and push-fed. Built so
# that it keeps compiling, but not a test: run it by hand (see its header).
add_executable(bvnr_stream_bench tests/bovnar_stream_bench.c)
target_compile_options(bvnr_stream_bench PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_stream_bench PRIVATE bvnr_static)

target_compile_options(bvnr_high_severity_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_high_severity_test PRIVATE bvnr_static)
target_include_directories(bvnr_high_severity_test PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)

target_compile_options(bvnr_currency_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_currency_test PRIVATE bvnr_static)

target_compile_options(bvnr_int_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_int_test PRIVATE bvnr_static)

target_compile_options(bvnr_float_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_float_test PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src/utils
    ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(bvnr_float_test PRIVATE bvnr_static)

target_compile_options(bvnr_datetime_test PRIVATE ${BVNR_C_FLAGS})
target_include_directories(bvnr_datetime_test PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(bvnr_datetime_test PRIVATE bvnr_static)

add_executable(bvnr_float_fix_dec_test tests/bvn_float_fix_dec_test.c)
target_compile_options(bvnr_float_fix_dec_test PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_float_fix_dec_test PRIVATE bvnr_static)
target_include_directories(bvnr_float_fix_dec_test PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)

# NOTE on gating: no test in this file carries a PASS_REGULAR_EXPRESSION. Every
# test binary returns non-zero when a case fails, so the exit code is the
# reliable signal -- and CTest treats PASS_REGULAR_EXPRESSION as a REPLACEMENT
# for the exit code, not an addition. 72 tests once matched their success line
# and nothing else, so a sanitizer report or a leak at teardown, printed after
# that line, was a pass under the CI ASan/UBSan run. A test that must check the
# output as well goes through cmake/cli_expect.cmake, which requires both.
#
# These four binaries were worse: their patterns were substrings of their own
# failure output:
# "0 failed" matches "10 failed", and "Results:.*0 failures" matches
# "Results: 641 tests, 10 failures". A run with 10, 20 or 100 failures was
# reported green. The counters accumulate without aborting, so that is reachable.
# Exit code now gates; FAIL_REGULAR_EXPRESSION is a second line of defence.
add_test(NAME bvnr_float_fix_dec_test COMMAND bvnr_float_fix_dec_test)
set_tests_properties(bvnr_float_fix_dec_test PROPERTIES
    LABELS "unit;float_fix;float_dec"
    FAIL_REGULAR_EXPRESSION "passed, [1-9][0-9]* failed")

# wasm/bvnr_wasm.c built NATIVELY against the library, with an allocator that
# can be made to fail: the result shapes of the WebAssembly exports, including
# the out-of-memory ones no browser can be driven into. Runs without emscripten,
# so the JSON contract of the npm package is checked on every build and not only
# where dist/wasm has been rebuilt. See wasm/test/native_shape_test.c.
add_executable(bvnr_wasm_native_shape_test wasm/test/native_shape_test.c)
target_include_directories(bvnr_wasm_native_shape_test PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
target_link_libraries(bvnr_wasm_native_shape_test PRIVATE bvnr_static)
add_test(NAME bvnr_wasm_native_shape COMMAND bvnr_wasm_native_shape_test)
set_tests_properties(bvnr_wasm_native_shape PROPERTIES
    LABELS "unit;wasm")

add_test(NAME bvnr_dom_test COMMAND bvnr_dom_test)
set_tests_properties(bvnr_dom_test PROPERTIES
    LABELS "unit;dom")

add_test(NAME bvnr_si_test COMMAND bvnr_si_test)
set_tests_properties(bvnr_si_test PROPERTIES
    LABELS "unit;si"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")

add_test(NAME bvnr_unit_policy_test COMMAND bvnr_unit_policy_test)
set_tests_properties(bvnr_unit_policy_test PROPERTIES
    LABELS "unit;si;reader;policy")

add_test(NAME bvnr_unit_ext_test COMMAND bvnr_unit_ext_test)
set_tests_properties(bvnr_unit_ext_test PROPERTIES
    LABELS "unit;si;units_ext"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")

if(BVNR_WITH_UCUM_PROFILE AND BVNR_WITH_UDUNITS_PROFILE)
add_test(NAME bvnr_unit_source_test COMMAND bvnr_unit_source_test)
set_tests_properties(bvnr_unit_source_test PROPERTIES
    LABELS "unit;profile;ucum;writer;dom"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

if(BVNR_WITH_UCUM_PROFILE)
add_test(NAME bvnr_ucum_test COMMAND bvnr_ucum_test)
set_tests_properties(bvnr_ucum_test PROPERTIES
    LABELS "unit;si;profile;ucum"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

# Ten of the unece assertions are agreements against a ucum spelling.
if(BVNR_WITH_UNECE_PROFILE AND BVNR_WITH_UCUM_PROFILE)
add_test(NAME bvnr_unece_test COMMAND bvnr_unece_test)
set_tests_properties(bvnr_unece_test PROPERTIES
    LABELS "unit;si;profile;unece"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

# The qudt suite covers both QUDT namespaces (50 of its assertions are
# qudt-qk) and cross-checks a few rows against ucum and unece.
if(BVNR_WITH_QUDT_PROFILE AND BVNR_WITH_QUDT_QK_PROFILE
   AND BVNR_WITH_UCUM_PROFILE AND BVNR_WITH_UNECE_PROFILE)
add_test(NAME bvnr_qudt_test COMMAND bvnr_qudt_test)
set_tests_properties(bvnr_qudt_test PROPERTIES
    LABELS "unit;si;profile;qudt"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

# The udunits suite cross-checks against ucum, unece and both QUDT
# namespaces — thirteen of its assertions name another vocabulary.
if(BVNR_WITH_UDUNITS_PROFILE AND BVNR_WITH_UCUM_PROFILE
   AND BVNR_WITH_UNECE_PROFILE AND BVNR_WITH_QUDT_PROFILE
   AND BVNR_WITH_QUDT_QK_PROFILE)
add_test(NAME bvnr_udunits_test COMMAND bvnr_udunits_test)
set_tests_properties(bvnr_udunits_test PROPERTIES
    LABELS "unit;si;profile;udunits"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

# "om: agrees with the vocabularies already here" names all five of them.
if(BVNR_WITH_OM_PROFILE AND BVNR_WITH_UCUM_PROFILE
   AND BVNR_WITH_UNECE_PROFILE AND BVNR_WITH_QUDT_PROFILE
   AND BVNR_WITH_QUDT_QK_PROFILE AND BVNR_WITH_UDUNITS_PROFILE)
add_test(NAME bvnr_om_test COMMAND bvnr_om_test)
set_tests_properties(bvnr_om_test PROPERTIES
    LABELS "unit;si;profile;om"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

# The cf suite checks a few of its names against ucum, qudt-qk and udunits
# spellings.
if(BVNR_WITH_CF_PROFILE AND BVNR_WITH_UCUM_PROFILE
   AND BVNR_WITH_QUDT_QK_PROFILE AND BVNR_WITH_UDUNITS_PROFILE)
add_test(NAME bvnr_cf_test COMMAND bvnr_cf_test)
set_tests_properties(bvnr_cf_test PROPERTIES
    LABELS "unit;si;profile;cf"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

# The cross-vocabulary suite: do the vocabularies agree with EACH OTHER? Needs
# every profile by definition -- an agreement between seven tables cannot be
# checked with three of them compiled out.
if(BVNR_ALL_PROFILES)
add_test(NAME bvnr_crossvocab_test COMMAND bvnr_crossvocab_test)
set_tests_properties(bvnr_crossvocab_test PROPERTIES
    LABELS "unit;si;profile;crossvocab;conformance"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")
endif()

add_test(NAME bvnr_reader_test COMMAND bvnr_reader_test)
set_tests_properties(bvnr_reader_test PROPERTIES
    LABELS "unit;reader")

add_test(NAME bvnr_writer_test COMMAND bvnr_writer_test)
set_tests_properties(bvnr_writer_test PROPERTIES
    LABELS "unit;writer")

# Built since the want_unit hook landed but never registered, so neither CI nor
# run_tests.sh ran it: the read-time conversion suite (exactness, output bases,
# refusals) could have regressed without a red test.
add_test(NAME bvnr_want_unit_test COMMAND bvnr_want_unit_test)
set_tests_properties(bvnr_want_unit_test PROPERTIES
    LABELS "unit;reader;policy")

add_test(NAME bvnr_extended_reader_test COMMAND bvnr_extended_reader_test)
set_tests_properties(bvnr_extended_reader_test PROPERTIES
    LABELS "unit;reader")

add_test(NAME bvnr_utils_test COMMAND bvnr_utils_test)
set_tests_properties(bvnr_utils_test PROPERTIES
    LABELS "unit;utils")

if(NOT WIN32)
add_test(NAME bvnr_socketpair_roundtrip_test
    COMMAND bvnr_socketpair_roundtrip_test)
set_tests_properties(bvnr_socketpair_roundtrip_test PROPERTIES
    LABELS "unit;roundtrip")
endif()

add_test(NAME bvnr_stream_test COMMAND bvnr_stream_test)
set_tests_properties(bvnr_stream_test PROPERTIES
    LABELS "unit;stream")

# The push-fed readers against bvnr_read, the frame reader against
# bvnr_doc_stream_read and the fed demux against bvnr_demux_on_event, with every
# file under examples/ in the corpus.
add_test(NAME bvnr_feed_test
    COMMAND bvnr_feed_test ${CMAKE_CURRENT_SOURCE_DIR}/examples)
set_tests_properties(bvnr_feed_test PROPERTIES
    LABELS "unit;stream;feed"
    TIMEOUT 600)

# The shared streaming vectors (tests/stream_vectors/, doc/10 §9) against the
# C reference: the batch and the fed demux, the pull frame reader, and
# bvnr_mux_send and bvnr_frame_write byte for byte. rust/tests/test_stream_vectors.rs
# holds the Rust codec to the same files.
add_test(NAME bvnr_stream_vectors_test
    COMMAND bvnr_stream_vectors_test ${CMAKE_CURRENT_SOURCE_DIR}/tests/stream_vectors)
set_tests_properties(bvnr_stream_vectors_test PROPERTIES
    LABELS "unit;stream;vectors"
    TIMEOUT 600)

# The committed vectors are what tests/gen_stream_vectors.py writes today: an
# edit to the generator that is not followed by a regeneration, or a hand edit
# to a vector, fails here.
if(Python3_Interpreter_FOUND)
    add_test(NAME bvnr_stream_vectors_fresh
        COMMAND ${Python3_EXECUTABLE}
                ${CMAKE_CURRENT_SOURCE_DIR}/tests/gen_stream_vectors.py --check)
    set_tests_properties(bvnr_stream_vectors_fresh PROPERTIES
        LABELS "stream;vectors" TIMEOUT 60)
endif()

# No allocation per message on the streaming hot paths. Counting needs glibc
# without ASan; anywhere else the test exits 77 and is reported as skipped.
add_test(NAME bvnr_alloc_test COMMAND bvnr_alloc_test)
set_tests_properties(bvnr_alloc_test PROPERTIES
    LABELS "unit;stream"
    SKIP_RETURN_CODE 77)

add_test(NAME bvnr_high_severity_test COMMAND bvnr_high_severity_test)
set_tests_properties(bvnr_high_severity_test PROPERTIES
    LABELS "unit;high_severity")

add_test(NAME bvnr_currency_test COMMAND bvnr_currency_test)
set_tests_properties(bvnr_currency_test PROPERTIES
    LABELS "unit;currency"
    FAIL_REGULAR_EXPRESSION "tests, [1-9][0-9]* failures")

add_test(NAME bvnr_int_test COMMAND bvnr_int_test)
set_tests_properties(bvnr_int_test PROPERTIES
    LABELS "unit;int"
    FAIL_REGULAR_EXPRESSION "passed, [1-9][0-9]* failed|FAIL")

add_test(NAME bvnr_float_test COMMAND bvnr_float_test)
set_tests_properties(bvnr_float_test PROPERTIES
    LABELS "unit;float"
    FAIL_REGULAR_EXPRESSION "passed, [1-9][0-9]* failed|FAIL")

add_test(NAME bvnr_datetime_test COMMAND bvnr_datetime_test)
set_tests_properties(bvnr_datetime_test PROPERTIES
    LABELS "unit;datetime;calendar"
    FAIL_REGULAR_EXPRESSION "passed, [1-9][0-9]* failed|FAIL")

# ── Conformance tests ────────────────────────────────────────────────────────
#
# bvnr_conformance: self-contained conformance test driver.
#   Self-test mode  : tests the reference implementation directly.
#   IUT mode        : invokes an external binary and compares its event-log
#                     output to the reference (--iut <path>).
#
# bvnr_conformance_iut: reference IUT adapter binary.
#   Reads Bovnar text from stdin, emits the conformance event log to stdout.
#   Used both for self-testing and as a template for third-party adapters.
#
# See doc/13_bovnar_conformance.md for the full protocol specification.

add_executable(bvnr_conformance     tests/bvnr_conformance.c)
add_executable(bvnr_conformance_iut tests/bvnr_conformance_iut.c)

target_compile_options(bvnr_conformance PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_conformance PRIVATE bvnr_static)

target_compile_options(bvnr_conformance_iut PRIVATE ${BVNR_C_FLAGS})
target_link_libraries(bvnr_conformance_iut PRIVATE bvnr_static)

# Self-test: conformance suite exercised against the built-in reference impl.
# 54 of its cases are the unit_profile group, so the corpus runs whole only in a
# build that carries every profile (see BVNR_ALL_PROFILES at the top).
if(BVNR_ALL_PROFILES)
add_test(NAME bvnr_conformance_self
    COMMAND bvnr_conformance)
set_tests_properties(bvnr_conformance_self PROPERTIES
    LABELS   "conformance;self"
    TIMEOUT  60
    FAIL_REGULAR_EXPRESSION "not ok|conformance tests FAILED")

# Feed equivalence: every case's input fed to the push-fed reader, cut at every
# byte and byte by byte, reads exactly as bvnr_read reads it whole.
add_test(NAME bvnr_conformance_feed
    COMMAND bvnr_conformance --feed)
set_tests_properties(bvnr_conformance_feed PROPERTIES
    LABELS   "conformance;feed"
    TIMEOUT  300
    FAIL_REGULAR_EXPRESSION "not ok|conformance tests FAILED")

# IUT self-test: conformance suite driven through the reference IUT adapter.
# This validates both the IUT protocol layer and the adapter binary itself.
# IUT mode forks/execs the adapter, so it is POSIX-only; on Windows the adapter
# binary still builds, but only the self-test above runs.
if(NOT WIN32)
add_test(NAME bvnr_conformance_iut_self
    COMMAND bvnr_conformance --iut $<TARGET_FILE:bvnr_conformance_iut>)
set_tests_properties(bvnr_conformance_iut_self PROPERTIES
    LABELS   "conformance;iut;self"
    TIMEOUT  60
    DEPENDS  bvnr_conformance_iut
    FAIL_REGULAR_EXPRESSION "not ok|conformance tests FAILED")
endif()
endif()   # BVNR_ALL_PROFILES

# ── Fuzz tests ───────────────────────────────────────────────────────────────

option(BVNR_FUZZ_TEST
    "Build the self-contained fuzz test and register it as CTest tests"
    ON)

set(BVNR_FUZZ_SMOKE_ITER 5000
    CACHE STRING "Mutation rounds for the fast CTest smoke fuzz tests")
set(BVNR_FUZZ_DEEP_ITER 100000
    CACHE STRING "Mutation rounds for the slow CTest deep fuzz tests")
set(BVNR_FUZZ_THOROUGH_ITER 200000
    CACHE STRING "Mutation rounds for the dedicated reader thorough fuzz test")

if (BVNR_FUZZ_TEST)

    add_executable(bvnr_fuzz_test tests/bovnar_fuzz_test.c)
    target_include_directories(bvnr_fuzz_test
        PRIVATE
            ${CMAKE_CURRENT_SOURCE_DIR}/include
            ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
    target_link_libraries(bvnr_fuzz_test PRIVATE bvnr_static)

    target_compile_options(bvnr_fuzz_test PRIVATE
        -Wall -Wextra -g
        $<$<AND:$<BOOL:${BVNR_HAVE_ASAN_UBSAN}>,$<NOT:$<BOOL:${WIN32}>>,$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>>:
            -fsanitize=address,undefined
            -fno-omit-frame-pointer>)

    if (BVNR_HAVE_ASAN_UBSAN AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang" AND NOT WIN32)
        target_link_options(bvnr_fuzz_test PRIVATE
            $<$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>:
                -fsanitize=address,undefined>)
    endif()

    macro(_bvnr_fuzz_test name harness iterations label timeout)
        add_test(
            NAME    ${name}
            COMMAND bvnr_fuzz_test
                        --harness    ${harness}
                        --iterations ${iterations}
                        --seed       42
        )
        set_tests_properties(${name} PROPERTIES
            TIMEOUT ${timeout}
            LABELS  "${label}"
            FAIL_REGULAR_EXPRESSION "CRASH"
        )
    endmacro()

    _bvnr_fuzz_test(bvnr_fuzz_reader reader ${BVNR_FUZZ_SMOKE_ITER} "fuzz;fuzz_smoke" 60)
    _bvnr_fuzz_test(bvnr_fuzz_dom    dom    ${BVNR_FUZZ_SMOKE_ITER} "fuzz;fuzz_smoke" 60)
    _bvnr_fuzz_test(bvnr_fuzz_utils  utils  ${BVNR_FUZZ_SMOKE_ITER} "fuzz;fuzz_smoke" 60)
    _bvnr_fuzz_test(bvnr_fuzz_feed   feed   ${BVNR_FUZZ_SMOKE_ITER} "fuzz;fuzz_smoke" 120)

    _bvnr_fuzz_test(bvnr_fuzz_reader_deep    reader ${BVNR_FUZZ_DEEP_ITER}     "fuzz_deep"     600)
    _bvnr_fuzz_test(bvnr_fuzz_reader_thorough reader ${BVNR_FUZZ_THOROUGH_ITER} "fuzz_thorough" 300)
    _bvnr_fuzz_test(bvnr_fuzz_dom_deep        dom    ${BVNR_FUZZ_DEEP_ITER}     "fuzz_deep"     600)
    _bvnr_fuzz_test(bvnr_fuzz_utils_deep      utils  ${BVNR_FUZZ_DEEP_ITER}     "fuzz_deep"     600)
    _bvnr_fuzz_test(bvnr_fuzz_feed_deep       feed   ${BVNR_FUZZ_DEEP_ITER}     "fuzz_deep"     900)

    add_executable(bvnr_fuzz_writer_test tests/bovnar_fuzz_writer.c)
    target_compile_definitions(bvnr_fuzz_writer_test PRIVATE FUZZ_STANDALONE)
    target_include_directories(bvnr_fuzz_writer_test
        PRIVATE
            ${CMAKE_CURRENT_SOURCE_DIR}/include
            ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
    target_link_libraries(bvnr_fuzz_writer_test PRIVATE bvnr_static)

    target_compile_options(bvnr_fuzz_writer_test PRIVATE
        -Wall -Wextra -g
        $<$<AND:$<BOOL:${BVNR_HAVE_ASAN_UBSAN}>,$<NOT:$<BOOL:${WIN32}>>,$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>>:
            -fsanitize=address,undefined
            -fno-omit-frame-pointer>)

    if (BVNR_HAVE_ASAN_UBSAN AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang" AND NOT WIN32)
        target_link_options(bvnr_fuzz_writer_test PRIVATE
            $<$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>:
                -fsanitize=address,undefined>)
    endif()

    # TWO seeds, deliberately. The original seed was the single byte "X", which
    # sounds useless -- a mutator given one byte only reaches one-byte
    # neighbourhoods, so the harness never saw an annotation, a unit, an array or
    # a struct. But measuring beats assuming: an injected 8-byte overrun in
    # push_mem is caught from the "X" seed at 1000 iterations and NOT caught from
    # the rich document even at 20 000, because the two drive completely
    # different output-length distributions and the bug needs a near-full sink
    # buffer. Keep both rather than trading one blind spot for another.
    set(_bvnr_writer_seed_min "${CMAKE_BINARY_DIR}/bvnr_fuzz_writer_seed_min.bin")
    file(WRITE "${_bvnr_writer_seed_min}" "X")

    set(_bvnr_writer_seed "${CMAKE_BINARY_DIR}/bvnr_fuzz_writer_seed.bvnr")
    file(WRITE "${_bvnr_writer_seed}"
"#!bovnar 1.1\n"
".name = \"seed\";\n"
".port = <uint:16> 443;\n"
".ratio = <sint:32> -7;\n"
".speed = <float:64,k~m/h> 88.5;\n"
".hex = <uint:32,_16> \"deadbeef\";\n"
".price = <float_dec:64,$USD> 19.99;\n"
".when = <datetime:64,tai> 2016-12-31T23:59:60.5Z;\n"
".flag = on;\n"
".sym = ok;\n"
".ref = &.name;\n"
".grid = [1, 2, 3]/[4, 5, 6];\n"
".rec = {.a = 1; .b = \"two\";};\n"
".hole = ;\n"
".esc = \"tab\\there caf\\u{e9}\";\n")

    # Iteration counts. These used to be 25/30/25 with timeouts of 60/600/300 s,
    # i.e. 0.01 s of actual work each -- and the comment justifying the cap
    # claimed --iterations 1000 "reliably trips the __builtin_trap() invariants".
    # That is stale: 200 000 iterations pass cleanly on seeds 42, 7 and 999, and
    # take 1.7 s. The cap bought nothing and cost the writer its only oracle --
    # an injected heap overflow in push_mem survived all three tiers and was
    # caught at 1000. Each tier also uses its own seed now; all three shared 42,
    # so the deep and thorough runs were re-testing the smoke run's inputs.
    add_test(NAME bvnr_fuzz_writer
        COMMAND bvnr_fuzz_writer_test --iterations 20000 --seed 42
                "${_bvnr_writer_seed_min}")
    set_tests_properties(bvnr_fuzz_writer PROPERTIES
        TIMEOUT 60
        LABELS  "fuzz;fuzz_smoke")

    add_test(NAME bvnr_fuzz_writer_deep
        COMMAND bvnr_fuzz_writer_test --iterations 200000 --seed 999
                "${_bvnr_writer_seed_min}")
    set_tests_properties(bvnr_fuzz_writer_deep PROPERTIES
        TIMEOUT 600
        LABELS  "fuzz_deep")

    add_test(NAME bvnr_fuzz_writer_thorough
        COMMAND bvnr_fuzz_writer_test --iterations 100000 --seed 7
                "${_bvnr_writer_seed_min}")
    set_tests_properties(bvnr_fuzz_writer_thorough PROPERTIES
        TIMEOUT 300
        LABELS  "fuzz_thorough")

    # The document seed, over its own distribution.
    add_test(NAME bvnr_fuzz_writer_doc
        COMMAND bvnr_fuzz_writer_test --iterations 100000 --seed 4242
                "${_bvnr_writer_seed}")
    set_tests_properties(bvnr_fuzz_writer_doc PROPERTIES
        TIMEOUT 300
        LABELS  "fuzz;fuzz_thorough")

endif()

option(BVNR_FUZZ_EXTERNAL
    "Build libFuzzer / AFL++ fuzz targets (requires clang or afl-clang-fast)"
    OFF)

if (BVNR_FUZZ_EXTERNAL)

    set(BVNR_FUZZ_ENGINE "libfuzzer" CACHE STRING
        "Fuzzing engine for external targets: libfuzzer | afl")
    set_property(CACHE BVNR_FUZZ_ENGINE PROPERTY STRINGS libfuzzer afl)

    set(BVNR_FUZZ_SANITIZERS "address;undefined" CACHE STRING
        "Sanitizers for -fsanitize= on external fuzz targets")

    if (BVNR_FUZZ_ENGINE STREQUAL "libfuzzer" AND
        NOT CMAKE_C_COMPILER_ID MATCHES "Clang")
        message(WARNING
            "libFuzzer requires clang.  "
            "Set CMAKE_C_COMPILER=clang or switch to BVNR_FUZZ_ENGINE=afl.")
    endif()

    string(JOIN "," _ext_san_str ${BVNR_FUZZ_SANITIZERS})

    # Sanitizer / coverage flags.  The library translation units are
    # instrumented for coverage feedback but must NOT pull in libFuzzer's
    # main() — only the harness target links the fuzzer runtime, hence
    # "fuzzer-no-link" on the library and "fuzzer" on the harness.  Under
    # AFL the compiler driver (afl-clang-fast) performs instrumentation, so
    # the library and harness share one flag set and the source's own
    # __AFL_LOOP main() is compiled in.
    if (BVNR_FUZZ_ENGINE STREQUAL "libfuzzer")
        set(_ext_lib_cf -fsanitize=fuzzer-no-link,${_ext_san_str})
        set(_ext_cf     -fsanitize=fuzzer,${_ext_san_str})
        set(_ext_lf     -fsanitize=fuzzer,${_ext_san_str})
    else()
        set(_ext_lib_cf -fsanitize=${_ext_san_str})
        set(_ext_cf     -fsanitize=${_ext_san_str})
        set(_ext_lf     -fsanitize=${_ext_san_str})
    endif()

    list(APPEND _ext_lib_cf -g -O1 -fno-omit-frame-pointer)
    list(APPEND _ext_cf     -g -O1 -fno-omit-frame-pointer)

    # Instrumented copy of the core library.  Kept separate from
    # bvnr_static (built without instrumentation for the normal unit tests)
    # so the fuzzers get coverage feedback through the parser internals.
    add_library(bvnr_fuzz_objects OBJECT ${BVNR_SOURCES})
    target_compile_options(bvnr_fuzz_objects PRIVATE
        -Wall -Wextra ${_ext_lib_cf})
    target_include_directories(bvnr_fuzz_objects
        PUBLIC  ${CMAKE_CURRENT_SOURCE_DIR}/include
        PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/io
                ${CMAKE_CURRENT_SOURCE_DIR}/src/lexer
                ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)

    # _bvnr_fuzz_external(<name> <source>): a coverage-guided fuzz target.
    # libFuzzer supplies main(); under AFL the source's __AFL_LOOP main() is
    # compiled in by the afl driver.  These are run manually, not by CTest:
    #   ./build/tests/bvnr_fuzz_ext_reader -max_total_time=60 corpus/reader
    macro(_bvnr_fuzz_external name source)
        add_executable(bvnr_fuzz_ext_${name} ${source}
            $<TARGET_OBJECTS:bvnr_fuzz_objects>)
        target_compile_options(bvnr_fuzz_ext_${name} PRIVATE
            -Wall -Wextra ${_ext_cf})
        target_include_directories(bvnr_fuzz_ext_${name} PRIVATE
            ${CMAKE_CURRENT_SOURCE_DIR}/include
            ${CMAKE_CURRENT_SOURCE_DIR}/src/utils)
        target_link_options(bvnr_fuzz_ext_${name} PRIVATE ${_ext_lf})
    endmacro()

    _bvnr_fuzz_external(reader tests/bovnar_fuzz_reader.c)
    _bvnr_fuzz_external(dom    tests/bovnar_fuzz_dom.c)
    _bvnr_fuzz_external(utils  tests/bovnar_fuzz_utils.c)
    _bvnr_fuzz_external(feed   tests/bovnar_fuzz_feed.c)

    # Corpus seed generator: writes starter inputs under
    # <out>/{reader,dom,utils,feed} (default ./corpus).  Plain tool, no
    # instrumentation.  Run before the fuzzers to seed their corpora.
    add_executable(bvnr_fuzz_seed_gen tests/bovnar_fuzz_seed_gen.c)
    target_compile_options(bvnr_fuzz_seed_gen PRIVATE
        -Wall -Wextra)

endif()

# ── Python binding tests (requires Python3 and pytest) ───────────────────────
#
# Generator expressions in ENVIRONMENT require CMake >= 3.22.
# Pure tests run without libbvnr.so; integration tests inject the
# library path via LIBBOVNAR_PATH.
#
# Label taxonomy
#   python            – all Python tests
#   python_pure       – no shared library required
#   python_integration – requires libbvnr.so
#   python_arrays     – array parser / writer tests
#   python_dom        – DOM layer tests
#   python_physics    – SI unit physics API tests
#
# Useful ctest invocations:
#   ctest -L python                  # all Python tests
#   ctest -L python_pure             # only library-free tests
#   ctest -L python_integration      # only integration tests
#   ctest -R bvnr_py_all             # full suite in one pytest run

# The Python binding tests drive libbvnr via ctypes from the host interpreter.
# In the MinGW cross build the library is a PE DLL the host (Linux) Python
# cannot load, and running CPython itself under Wine is out of scope — so the
# whole Python suite is excluded on Windows. The C suite above is the Windows
# coverage.
set(_bvnr_have_pytest FALSE)

if(NOT WIN32)

find_package(Python3 COMPONENTS Interpreter QUIET)

# The interpreter alone is not enough: every test below is `python -m pytest`,
# so without the module all 19 of them fail at once, each reporting nothing more
# useful than "No module named pytest". That is exactly how this suite's first
# CI run went. The header has always said "requires Python3 and pytest" -- only
# the first half was ever checked. Probe for the second half too and skip the
# registrations when it is missing, the same way a missing interpreter does.
if(Python3_FOUND)
    execute_process(
        COMMAND ${Python3_EXECUTABLE} -c "import pytest"
        RESULT_VARIABLE _bvnr_pytest_probe
        OUTPUT_QUIET ERROR_QUIET)
    if(_bvnr_pytest_probe EQUAL 0)
        set(_bvnr_have_pytest TRUE)
    endif()
endif()

# BVNR_ALL_PROFILES as well: the Python suites assert profile behaviour (the Unit
# object parses "ucum:mm[Hg]" and writes it back), and the bindings load whatever
# libbvnr.so the build produced, so a partial build fails them for a reason that
# is not a defect.
if(_bvnr_have_pytest AND BVNR_ALL_PROFILES)

    set(_py_src   "${CMAKE_CURRENT_SOURCE_DIR}/python")
    set(_py_tests "${CMAKE_CURRENT_SOURCE_DIR}/python/tests")

    # Resolve the path to libbvnr.so for injecting into LIBBOVNAR_PATH.
    # The generator expression is evaluated at test time, so the target must
    # exist when the tests are run (i.e. build bvnr_shared before ctest).
    if(TARGET bvnr_shared)
        set(_bvnr_lib_env
            "LIBBOVNAR_PATH=$<TARGET_FILE:bvnr_shared>")
    else()
        # Fall back to whatever the caller has exported in the environment.
        set(_bvnr_lib_env "")
        message(STATUS
            "bvnr_shared target not found; Python integration tests rely "
            "on LIBBOVNAR_PATH set in the environment.")
    endif()

    # ── helper macros ────────────────────────────────────────────────────────
    #
    # pytest exit codes are authoritative and sufficient:
    #   0  all collected tests passed or were skipped  → CTest PASS
    #   1  at least one test failed                    → CTest FAIL
    #   2  interrupted                                 → CTest FAIL
    #   5  no tests collected (misconfigured path)     → CTest FAIL
    #
    # PASS_REGULAR_EXPRESSION is intentionally absent: it would require the
    # summary line to contain "passed", but combining addopts=-q with an
    # explicit -q flag gives -qq which suppresses that line entirely.

    # _bvnr_pytest_pure: a test that needs only Python, no shared library.
    macro(_bvnr_pytest_pure _name _file _labels _timeout)
        add_test(
            NAME    ${_name}
            COMMAND ${Python3_EXECUTABLE} -m pytest
                        "${_py_tests}/${_file}"
                        --tb=short
            WORKING_DIRECTORY "${_py_src}"
        )
        set_tests_properties(${_name} PROPERTIES
            TIMEOUT ${_timeout}
            LABELS  "python;python_pure;${_labels}"
            ENVIRONMENT "${_bvnr_lib_env}"
        )
    endmacro()

    # _bvnr_pytest_lib: a test that requires libbvnr.so.
    macro(_bvnr_pytest_lib _name _file _labels _timeout)
        add_test(
            NAME    ${_name}
            COMMAND ${Python3_EXECUTABLE} -m pytest
                        "${_py_tests}/${_file}"
                        --tb=short
            WORKING_DIRECTORY "${_py_src}"
        )
        set_tests_properties(${_name} PROPERTIES
            TIMEOUT     ${_timeout}
            LABELS      "python;python_integration;${_labels}"
            ENVIRONMENT "${_bvnr_lib_env}"
        )
    endmacro()

    # ── pure Python tests (no library required) ──────────────────────────────

    _bvnr_pytest_pure(bvnr_py_enums
        test_enums.py
        "enums"
        30)

    _bvnr_pytest_pure(bvnr_py_structs
        test_structs.py
        "structs"
        30)

    # ABI-consistency sweep: assert every ctypes Structure mirrors the C struct
    # layout reported by the bvnr_abi_dump generator (the C compiler is the
    # source of truth). Needs no shared library — only the generator binary,
    # passed via BVNR_ABI_DUMP (the test self-compiles it as a fallback).
    add_test(
        NAME    bvnr_py_abi
        COMMAND ${Python3_EXECUTABLE} -m pytest "${_py_tests}/test_abi.py"
                    --tb=short
        WORKING_DIRECTORY "${_py_src}"
    )
    set_tests_properties(bvnr_py_abi PROPERTIES
        TIMEOUT     60
        LABELS      "python;python_pure;abi"
        ENVIRONMENT "BVNR_ABI_DUMP=$<TARGET_FILE:bvnr_abi_dump>"
        FIXTURES_REQUIRED abi_dump_fixture
    )
    # ctest does not build targets, so on its own bvnr_py_abi could compare the
    # ctypes mirrors against a STALE generator binary (built before a header
    # change). This setup fixture rebuilds bvnr_abi_dump first — a no-op when up
    # to date, a refresh when a struct header changed — so the manifest the test
    # reads always reflects the current C layout. It rebuilds bvnr_shared too,
    # whose exports bvnr_abi_baseline reads: one setup for both, because two
    # running `cmake --build` in the same tree at once can race.
    add_test(
        NAME    bvnr_abi_dump_build
        COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR}
                --target bvnr_abi_dump bvnr_shared
    )
    set_tests_properties(bvnr_abi_dump_build PROPERTIES
        FIXTURES_SETUP abi_dump_fixture
        LABELS         "abi")

    # Array state-machine tests: exercises all six fixes in _DictParser
    # (flat/multi-row/nested arrays, struct elements, seal timing, parent_key).
    _bvnr_pytest_pure(bvnr_py_array_parser
        test_array_parser.py
        "python_arrays;parser"
        30)

    # Currency module: pure-Python enums + currency.py API contract.
    _bvnr_pytest_pure(bvnr_py_currency
        test_currency_units.py
        "currency"
        30)

    # The Python currency table is a hand-written THIRD copy of the catalogue,
    # after src/gendata/currencies.bvnr and the C table gen_currencies.py builds
    # from it. It is standalone on purpose (tools import it without libbvnr), but
    # nothing compared it to the source of truth — so a currency added to the
    # gendata would regenerate the C side and leave Python reporting a wrong
    # minor_unit, the field money is formatted with, while every other gate
    # stayed green. Pure Python on both sides.
    _bvnr_pytest_pure(bvnr_py_currency_parity
        test_currency_table_parity.py
        "currency"
        30)

    # Conversion factors re-derived from each unit's DEFINITION. The pint bridge
    # checks bovnar against pint, but for the ~40 units bovnar defines itself
    # the pint definition is generated FROM bovnar's factor -- circular, so a
    # wrong number would agree with itself. This closes that from the other
    # side and needs no library, only the gendata document.
    _bvnr_pytest_pure(bvnr_py_unit_factors
        test_unit_factors_derived.py
        "units;gendata"
        30)

    # ── integration tests (require libbvnr.so) ────────────────────────

    _bvnr_pytest_lib(bvnr_py_reader
        test_reader.py
        "reader"
        60)

    _bvnr_pytest_lib(bvnr_py_writer
        test_writer.py
        "writer"
        60)

    # Unit parsing, serialisation, compound construction, and roundtrips.
    # Also covers make_unit_compound compatibility with parse_unit.
    _bvnr_pytest_lib(bvnr_py_units
        test_units.py
        "units"
        60)

    # Physics unit API: unit_to_si_factor, units_compatible,
    # unit_convert_factor, unit_dimension_vector, unit_reduce, convert_value.
    _bvnr_pytest_lib(bvnr_py_unit_physics
        test_unit_physics.py
        "units;python_physics"
        60)

    # DOM layer: DomDoc.parse, DomNode typed accessors, struct/array traversal,
    # dot-path lookup, to_python(), GC safety.
    _bvnr_pytest_lib(bvnr_py_dom
        test_dom.py
        "python_dom;dom"
        60)

    # write_array high-level API and _emit_array_element token type regression.
    _bvnr_pytest_lib(bvnr_py_write_array
        test_write_array.py
        "writer;python_arrays"
        60)

    # Streaming layer bindings (bovnar_stream.h): multi-document framing, octet
    # multiplexing with cross-chunk reassembly, and document-in-document.
    _bvnr_pytest_lib(bvnr_py_stream
        test_stream.py
        "stream"
        60)

    # Spec version directive (#!bovnar M.N): runtime/spec version accessors,
    # peek_version, Reader.declared_version, strict-mode rejection.
    _bvnr_pytest_lib(bvnr_py_version
        test_version.py
        "version"
        60)

    # Spec 1.1 richer string escapes (\u{...}, \xHH): decoding, UTF-8 guard,
    # invalid code points, version gating.
    _bvnr_pytest_lib(bvnr_py_escapes
        test_escapes.py
        "escapes"
        60)

    # Spec 1.1 native time family (<datetime:width,epoch>): epoch parsing,
    # signed-int validation, version gating.
    _bvnr_pytest_lib(bvnr_py_datetime
        test_datetime.py
        "datetime"
        60)

    _bvnr_pytest_lib(bvnr_py_datetime_oracle
        test_datetime_oracle.py
        "datetime"
        120)

    # numpy bridge (incl. the spec-1.1 datetime64 mapping).
    #
    # pytest.importorskip("numpy") sits at MODULE scope, so without numpy the
    # whole file is skipped during COLLECTION and pytest exits 5 -- "no tests
    # collected" -- which the exit-code table above maps to FAIL. It is not the
    # 0 an all-skipped run would give; this target claimed to pass in numpy-less
    # environments and in fact was the only red test on the CI runner, which has
    # no numpy, while passing everywhere numpy happened to be installed.
    #
    # SKIP_RETURN_CODE says what is actually meant: absent numpy is a skip, not a
    # failure. It costs nothing here because 5 cannot arrive any other way for a
    # single named file -- a mistyped path is a usage error, exit 4.
    #
    # CI installs numpy so this runs for real rather than reporting a green skip.
    _bvnr_pytest_lib(bvnr_py_numpy
        test_numpy_bridge.py
        "numpy"
        60)
    set_tests_properties(bvnr_py_numpy PROPERTIES SKIP_RETURN_CODE 5)

    # ── aggregate gates ──────────────────────────────────────────────────────

    # Run the full Python suite in a single pytest invocation.
    # pytest's exit status gates it: 0 when everything passed or was skipped
    # (a missing library skips), non-zero on any failure.
    add_test(
        NAME    bvnr_py_all
        COMMAND ${Python3_EXECUTABLE} -m pytest "${_py_tests}"
                    --tb=short
        WORKING_DIRECTORY "${_py_src}"
    )
    # WHY 300 AND NOT 120. Measure the suite alone and it costs ~20s of CPU at
    # 100% utilisation. Measure it the way CTest actually runs it -- `ctest -j8`
    # on an 8-core box, alongside 15 other tests AND its own near-duplicate
    # aggregate, both started in the same wave -- and it takes ~46s of wall
    # clock. The budget has to cover the second number, and 60s covered it by
    # 1.3x, which is no margin at all on a busier or slower runner.
    #
    # CONTENTION, NOT SIZE. An earlier version of this comment blamed the
    # catalogue: the pint bridge does compare every PAIR of base units and so
    # is genuinely quadratic in it. But that test is 1.9s of the 20s, and
    # growing the catalogue from 215 units to 261 moved it by about a second.
    # Attributing a 60s timeout to it was a guess made from a measurement taken
    # while this machine was saturated by a parallel build, and it would have
    # sent the next person optimising the wrong thing.
    #
    # A timeout that trips under load reports "the Python bindings are broken"
    # for a suite that passes, which is worse than a slow test.
    set_tests_properties(bvnr_py_all PROPERTIES
        TIMEOUT     300
        LABELS      "python;python_all"
        ENVIRONMENT "${_bvnr_lib_env}"
    )

    # Library-free gate: safe to run in environments without libbvnr.so
    # (e.g. documentation builds, pure static analysis CI jobs).
    add_test(
        NAME    bvnr_py_pure_all
        COMMAND ${Python3_EXECUTABLE} -m pytest "${_py_tests}"
                    -m "not needs_lib"
                    --tb=short
        WORKING_DIRECTORY "${_py_src}"
    )
    # The same 300, for the same reason and one more: this is a SUBSET of
    # bvnr_py_all that costs the same as the whole -- 46.1s against 45.7s in the
    # run above -- because everything slow in the suite is library-free (the
    # pure-Python decimal and binary encoders, the pint bridge, the round-trip
    # property tests). Two near-identical CPU-bound aggregates launched into the
    # same scheduling wave are also most of each other's contention. This one
    # carried half its superset's budget, which is why it was the one that
    # tripped first.
    set_tests_properties(bvnr_py_pure_all PROPERTIES
        TIMEOUT 300
        LABELS  "python;python_pure"
        ENVIRONMENT "${_bvnr_lib_env}"
    )

elseif(NOT Python3_FOUND)
    message(STATUS
        "Python3 interpreter not found -- skipping Python binding tests. "
        "Install Python 3.10+ and 'pip install pytest>=7' to enable them.")
else()
    message(STATUS
        "pytest not importable by ${Python3_EXECUTABLE} -- skipping Python "
        "binding tests. Run 'pip install pytest>=7' to enable them.")
endif()

endif() # NOT WIN32 (Python binding tests)

# ── CLI example-file smoke tests ─────────────────────────────────────────────
#
# For every .bvnr file under examples/ run two commands against the built
# bovnar binary:
#
#   bovnar events  <file>   – must exit 0 and print the validated-ok line
#   bovnar validate <file>  – must exit 0
#
# The macro _bvnr_cli_example(stem) derives the file path from the stem name
# and registers both tests.  Each test depends on the bovnar target so that
# CTest knows to build it first when run in isolation.

macro(_bvnr_cli_example stem)
    set(_bvnr_ex_file "${CMAKE_CURRENT_SOURCE_DIR}/examples/${stem}.bvnr")

    add_test(
        NAME    bvnr_cli_events_${stem}
        COMMAND bovnar events "${_bvnr_ex_file}"
    )
    set_tests_properties(bvnr_cli_events_${stem} PROPERTIES
        LABELS                "cli;examples;events"
        TIMEOUT               30
    )

    add_test(
        NAME    bvnr_cli_validate_${stem}
        COMMAND bovnar validate "${_bvnr_ex_file}"
    )
    set_tests_properties(bvnr_cli_validate_${stem} PROPERTIES
        LABELS  "cli;examples;validate"
        TIMEOUT 30
        FAIL_REGULAR_EXPRESSION "Validation failed|error|Error")

    # Assert the canonical serialiser is idempotent: pretty-printing the
    # canonical output again must reproduce it byte-for-byte (and re-parse).
    add_test(
        NAME    bvnr_cli_idempotent_${stem}
        COMMAND "${CMAKE_COMMAND}"
                -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
                -DBVNR_FILE=${_bvnr_ex_file}
                -DTMP_FILE=${CMAKE_CURRENT_BINARY_DIR}/idem_${stem}.bvnr
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_idempotent.cmake)
    set_tests_properties(bvnr_cli_idempotent_${stem} PROPERTIES
        LABELS  "cli;examples;idempotent"
        TIMEOUT 30)
endmacro()

# ── CLI unit policy (bvnr_reader_set_unit_policy) ───────────────────────────
#
# The CLI is the only place the policy is driven by a command line rather than a
# struct, and the flags interact: --base applies to every --unit target AND to
# the --si fallback, which made it order-dependent until it was stamped after
# the whole line had been read. Both orders are asserted, because one of them
# passing proves nothing about the other.
# _bvnr_cli_expect(name labels rc pass_rx fail_rx <cli args...>) runs the CLI
# through cmake/cli_expect.cmake: the exit status must be exactly <rc> AND the
# output must match <pass_rx> (and not <fail_rx>). See the gating note above.
function(_bvnr_cli_expect name labels rc pass_rx fail_rx)
    string(JOIN "@@" _args ${ARGN})
    add_test(NAME ${name}
             COMMAND "${CMAKE_COMMAND}"
                     -DBOVNAR=$<TARGET_FILE:bovnar>
                     "-DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}"
                     "-DARGS=${_args}" -DEXPECT_RC=${rc}
                     "-DPASS_RX=${pass_rx}" "-DFAIL_RX=${fail_rx}"
                     -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/cli_expect.cmake)
    set_tests_properties(${name} PROPERTIES LABELS "${labels}" TIMEOUT 30)
endfunction()

set(_bvnr_units_ex ${CMAKE_CURRENT_SOURCE_DIR}/examples/units.bvnr)

# 1 g normalises to the SI base unit for mass, which is the KILOgram.
_bvnr_cli_expect(bvnr_cli_policy_si "cli;units;policy" 0 "→ \"0.001\" k~g" ""
                 events --si --leave-inexact ${_bvnr_units_ex})
# units.bvnr holds a length that has no exact base-16 metre value, so without
# --leave-inexact the run ends in unit_inexact (exit 1) after the line matched
# here -- which the old match-only test reported as a pass.
_bvnr_cli_expect(bvnr_cli_policy_unit_then_base "cli;units;policy" 0
                 "→ \"5dc\" m _16" ""
                 events --unit m --base 16 --leave-inexact ${_bvnr_units_ex})
_bvnr_cli_expect(bvnr_cli_policy_base_then_unit "cli;units;policy" 0
                 "→ \"5dc\" m _16" ""
                 events --base 16 --unit m --leave-inexact ${_bvnr_units_ex})

# units.bvnr deliberately contains no_unit values, so --require-unit must reject
# it -- and reject it as a unit error, not as a generic failure.
add_test(NAME bvnr_cli_policy_require_unit_rejects
         COMMAND bovnar validate --require-unit
                 ${CMAKE_CURRENT_SOURCE_DIR}/examples/units.bvnr)
set_tests_properties(bvnr_cli_policy_require_unit_rejects PROPERTIES
    LABELS  "cli;units;policy"
    TIMEOUT 30
    WILL_FAIL TRUE)

# Per-field rules, and the DOM tier taking the same policy: `query` converts the
# field it was asked about, at a path two levels down.
_bvnr_cli_expect(bvnr_cli_policy_query_field "cli;units;policy;dom" 0
                 "1716400000000" ""
                 query --field .portfolio.snapshot_epoch=m~s .portfolio.snapshot_epoch
                 ${CMAKE_CURRENT_SOURCE_DIR}/examples/crypto_portfolio.bvnr)

# ...and a rule naming a field of the wrong quantity is an error, not silence:
# that epoch is a time, so requiring a length of it must fail.
add_test(NAME bvnr_cli_policy_query_field_mismatch
         COMMAND bovnar query --require-field .portfolio.snapshot_epoch=m
                 .portfolio.snapshot_epoch
                 ${CMAKE_CURRENT_SOURCE_DIR}/examples/crypto_portfolio.bvnr)
set_tests_properties(bvnr_cli_policy_query_field_mismatch PROPERTIES
    LABELS  "cli;units;policy;dom"
    TIMEOUT 30
    WILL_FAIL TRUE)

# A unit the parser will not take is refused before the document is opened, so
# the failure names the policy rather than the file.
add_test(NAME bvnr_cli_policy_bad_unit_refused
         COMMAND bovnar validate --require-dimension not_a_unit_at_all
                 ${CMAKE_CURRENT_SOURCE_DIR}/examples/units.bvnr)
set_tests_properties(bvnr_cli_policy_bad_unit_refused PROPERTIES
    LABELS  "cli;units;policy"
    TIMEOUT 30
    WILL_FAIL TRUE)

# The refusal must name the ARGUMENT that caused it. bvnr_reader_set_unit_policy
# answers one bool for the whole policy, and the CLI reported every refusal as
# "check the unit spellings passed to --unit / --require-dimension" -- right for
# two of the six things that reach it. A rule path over BVNR_MAX_UNIT_PATH, one
# without a leading '.', and a bare ".*" are PATH faults reported as unit
# spellings, and a bad unit in --field / --require-field named neither flag.
#
# Each case asserts the message singles out the right flag AND the right half of
# "<path>=<unit>", so a regression to one catch-all line fails here.
string(REPEAT "a" 97 _bvnr_long_path)
foreach(_case
        # name              args                                  expected regex
        "unit_target;--unit@@nosuchunitxyz;--unit 'nosuchunitxyz'"
        "unit_dimension;--require-dimension@@nosuchunitxyz;--require-dimension 'nosuchunitxyz'"
        "rule_unit;--require-field@@.a=nosuchunitxyz;--require-field '\\.a=nosuchunitxyz'"
        "field_unit;--field@@.a=nosuchunitxyz;--field '\\.a=nosuchunitxyz'"
        "rule_path_long;--require-field@@.${_bvnr_long_path}=m;--require-field path .* is unusable"
        "rule_path_no_dot;--require-field@@a=m;--require-field path 'a' is unusable")
    list(GET _case 0 _n)
    list(GET _case 1 _a)
    list(GET _case 2 _rx)
    string(REPLACE "@@" ";" _a "${_a}")
    # A bad argument exits 2, a document that fails exits 1.
    _bvnr_cli_expect(bvnr_cli_policy_names_arg_${_n} "cli;units;policy" 2
                     "${_rx}" "" validate ${_a} ${_bvnr_units_ex})
endforeach()

# `query` reaches the setter through the DOM builder, which surfaces a refused
# policy as error_invalid_argument on the PARSE -- so every bad policy argument
# came back as "Parse error in <file>: invalid_argument". The document was
# blamed, by name, for a fault in the command line, and was read before anyone
# noticed. It now checks the policy before opening the file and reports it the
# way validate and events do; the "Parse error in" refusal regex pins that.
foreach(_case
        "unit;--unit@@nosuchunitxyz;--unit 'nosuchunitxyz'"
        "rule_path;--require-field@@a=m;--require-field path 'a' is unusable")
    list(GET _case 0 _n)
    list(GET _case 1 _a)
    list(GET _case 2 _rx)
    string(REPLACE "@@" ";" _a "${_a}")
    _bvnr_cli_expect(bvnr_cli_policy_query_names_arg_${_n} "cli;units;policy;dom" 2
                     "${_rx}" "Parse error in" query ${_a} .si_second ${_bvnr_units_ex})
endforeach()

# The other half of that distinction, which must NOT change: a policy the
# arguments state correctly but the DOCUMENT cannot satisfy is still reported
# against the file. Requiring a length of a time is a document-tier failure.
_bvnr_cli_expect(bvnr_cli_policy_query_document_still_blamed "cli;units;policy;dom" 1
                 "Parse error in.*unit_mismatch" ""
                 query --require-field .si_second=m .si_second ${_bvnr_units_ex})

# An inline unit on an un-annotated value must survive canonicalisation by being
# folded into the synthesised annotation (e.g. "9.81 m/s" -> "<float:64,_10,m/s>").
# The idempotency test cannot catch a drop here ("no_unit" -> "no_unit" is itself
# idempotent), so assert the units are present in the canonical output directly.
add_test(
    NAME    bvnr_cli_inline_unit_preserved
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DBVNR_FILE=${CMAKE_CURRENT_SOURCE_DIR}/examples/units.bvnr
            "-DREQUIRE=<float:64,_10,m/s> 9.81|<float:64,_10,k~g> 70.5|<uint:64,_10,Gi~B> 4|<uint:64,_10,B> 1500|<sint:64,_10,°C> -40|<float:64,_10,Pa> 101325.0"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_contains.cmake)
set_tests_properties(bvnr_cli_inline_unit_preserved PROPERTIES
    LABELS "cli;examples;units" TIMEOUT 30)

# An inline unit on a value that ALSO carries an explicit (unit-less) annotation
# must survive too. The annotation is emitted before the value's unit is known,
# so it is appended inline ("<float:64> 9.81 m/s"); emitting "<float:64> 9.81"
# alone would silently drop the unit (and change the decoded value). .speed_b in
# floats.bvnr exercises exactly this; the idempotency test cannot catch it.
add_test(
    NAME    bvnr_cli_inline_unit_with_annotation_preserved
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DBVNR_FILE=${CMAKE_CURRENT_SOURCE_DIR}/examples/floats.bvnr
            "-DREQUIRE=<float:64> 9.81 m/s"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_contains.cmake)
set_tests_properties(bvnr_cli_inline_unit_with_annotation_preserved PROPERTIES
    LABELS "cli;examples;units" TIMEOUT 30)

add_test(
    NAME    bvnr_cli_inline_currency_preserved
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DBVNR_FILE=${CMAKE_CURRENT_SOURCE_DIR}/examples/financial.bvnr
            "-DREQUIRE=<float_dec:64,$USD> 29.95|<float_dec:64,$EUR> 149.00"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_contains.cmake)
set_tests_properties(bvnr_cli_inline_currency_preserved PROPERTIES
    LABELS "cli;examples;currency" TIMEOUT 30)

# Glob rather than list. The hand-maintained list had drifted to 13 of the 15
# files, and the two it omitted were numpy_roundtrip and octet_streams -- the
# latter being exactly the file that a .gitattributes eol filter had silently
# corrupted in git, so no test ever noticed that a fresh clone failed to
# validate it. CONFIGURE_DEPENDS re-globs when a file is added or removed.
file(GLOB _bvnr_examples CONFIGURE_DEPENDS
     "${CMAKE_CURRENT_SOURCE_DIR}/examples/*.bvnr")
list(LENGTH _bvnr_examples _bvnr_nexamples)
if(_bvnr_nexamples LESS 1)
    message(FATAL_ERROR "no examples/*.bvnr found -- the glob is wrong")
endif()
foreach(_bvnr_ex ${_bvnr_examples})
    get_filename_component(_bvnr_stem "${_bvnr_ex}" NAME_WE)
    # unit_profiles.bvnr spells a unit in every namespace, so it is a valid
    # document only in a build that carries all of them; with one compiled out
    # (doc/11 §9.4) that namespace is error_unit_profile_unknown, by design.
    if(_bvnr_stem STREQUAL "unit_profiles" AND NOT BVNR_ALL_PROFILES)
        continue()
    endif()
    _bvnr_cli_example(${_bvnr_stem})
endforeach()
message(STATUS "registered ${_bvnr_nexamples} example documents")

# Cross-tier regression: the canonical (pretty-printed) form of a datetime
# document — whose arrays annotate only the first element and leave the rest
# bare — must re-parse at the DOM tier as homogeneous datetime arrays. The
# streaming-only idempotency check above does not exercise the DOM.
add_test(
    NAME    bvnr_cli_dom_canonical_datetime
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DBVNR_FILE=${CMAKE_CURRENT_SOURCE_DIR}/examples/datetime.bvnr
            -DTMP_FILE=${CMAKE_CURRENT_BINARY_DIR}/domcanon_datetime.bvnr
            -DKEY=.matrix
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pretty_print_dom_valid.cmake)
set_tests_properties(bvnr_cli_dom_canonical_datetime PROPERTIES
    LABELS  "cli;examples;datetime;idempotent" TIMEOUT 30)

# ── json <-> bvnr converter ──────────────────────────────────────────────────
# Round-trip fidelity: json -> bvnr -> json must reproduce the input (modulo
# whitespace), covering uint64 above INT64_MAX, nested/jagged/single-row arrays,
# arrays of objects, mixed-type arrays, nested objects, booleans and null.
add_test(
    NAME    bvnr_cli_convert_roundtrip
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DJSON_FILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/json/roundtrip.json
            -DTMP_FILE=${CMAKE_CURRENT_BINARY_DIR}/convert_roundtrip.bvnr
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/convert_json_roundtrip.cmake)
set_tests_properties(bvnr_cli_convert_roundtrip PROPERTIES
    LABELS "cli;convert" TIMEOUT 30)

# Unrepresentable / malformed JSON must fail loudly (non-zero exit + message),
# never silently drop or corrupt data.
macro(_bvnr_convert_fail name file needle)
    add_test(
        NAME    bvnr_cli_convert_fail_${name}
        COMMAND "${CMAKE_COMMAND}"
                -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
                -DJSON_FILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/json/${file}
                "-DNEEDLE=${needle}"
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/convert_expect_fail.cmake)
    set_tests_properties(bvnr_cli_convert_fail_${name} PROPERTIES
        LABELS "cli;convert" TIMEOUT 30)
endmacro()

# ── streaming CLI: frames pack/list + mux pack/list round-trips ──────────────
add_test(
    NAME    bvnr_cli_stream_roundtrip
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DEX_DIR=${CMAKE_CURRENT_SOURCE_DIR}/examples
            -DTMP_DIR=${CMAKE_CURRENT_BINARY_DIR}
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/stream_cli_roundtrip.cmake)
set_tests_properties(bvnr_cli_stream_roundtrip PROPERTIES
    LABELS "cli;stream" TIMEOUT 30)

# The shared streaming vectors through `mux list` / `frames list`: every
# vector of the manifest lists its messages (or documents, each with its own
# outcome) in order and at their lengths, and fails exactly when the vector
# does, naming the error. The manifest is read through `bovnar convert`, so
# the check needs only the binary (and the emulator of a cross build).
if(Python3_Interpreter_FOUND)
    add_test(NAME bvnr_cli_stream_vectors
        COMMAND ${Python3_EXECUTABLE}
                ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_stream_vectors_cli.py
                $<TARGET_FILE:bovnar>
                ${CMAKE_CURRENT_SOURCE_DIR}/tests/stream_vectors
                ${CMAKE_CROSSCOMPILING_EMULATOR})
    set_tests_properties(bvnr_cli_stream_vectors PROPERTIES
        LABELS "cli;stream;vectors" TIMEOUT 120)
endif()

# ── the command line's own contract ───────────────────────────────────────────
# Exit statuses (0 / 1 the document or the I/O failed / 2 the command line was
# wrong), refused arguments, where help goes, and the messages that have to name
# their cause -- some hundred cases, each one a defect a review found by running
# the tool and each seen to fail against the binary before its fix. They live in
# one script because they share one shape; see cmake/cli_regressions.cmake.
add_test(
    NAME    bvnr_cli_regressions
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DTMP_DIR=${CMAKE_CURRENT_BINARY_DIR}
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/cli_regressions.cmake)
set_tests_properties(bvnr_cli_regressions PROPERTIES
    LABELS "cli" TIMEOUT 120)

_bvnr_convert_fail(bad_key          bad_key.json              "not a valid bovnar identifier")
# A JSON integer wider than 64 bits is written at a wider width -- except in an
# array, whose elements share one type.
_bvnr_convert_fail(int_overflow     bad_overflow.json         "wider than 64 bits")
_bvnr_convert_fail(trailing         bad_trailing.json         "trailing content")
_bvnr_convert_fail(nul_in_string    bad_nul.json              "NUL")
_bvnr_convert_fail(dup_key          bad_dupkey.json           "duplicate key")
_bvnr_convert_fail(diff_key_records bad_diffkeys.json         "differing key sets")
# -2^127 is the least sint:128, so a negative power of two takes no extra bit.
_bvnr_cli_expect(bvnr_cli_convert_wide_int "cli;convert" 0
                 ".n = <uint:128> 99999999999999999999;.*.m = <sint:128> -99999999999999999999;.*.p = <sint:128> -170141183460469231731687303715884105728;" ""
                 convert ${CMAKE_CURRENT_SOURCE_DIR}/tests/json/wide_int.json)

# bvnr -> json must refuse a datetime with sub-second digits rather than
# silently truncate the fraction the integer carrier cannot hold.
add_test(
    NAME    bvnr_cli_convert_fail_datetime_fraction
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DINPUT_FILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/frac_datetime.bvnr
            -DFROM=bvnr -DTO=json
            "-DNEEDLE=sub-second"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/convert_expect_fail.cmake)
set_tests_properties(bvnr_cli_convert_fail_datetime_fraction PROPERTIES
    LABELS "cli;convert" TIMEOUT 30)

# bvnr -> json must REPORT that a "/"-separated multi-dimensional array became a
# nested JSON array, because the two render identically and only the nested one
# survives the trip back. Every other loss the converter can inflict was already
# reported; this one exited 0 in silence, which is the single outcome the report
# exists to prevent. The fixture also carries a genuinely nested array, so a
# false positive on THAT fails the round-trip test above rather than passing here
# unnoticed.
add_test(
    NAME    bvnr_cli_convert_fail_dim_array
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DINPUT_FILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/dim_array.bvnr
            -DFROM=bvnr -DTO=json
            "-DNEEDLE=multi-dimensional"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/convert_expect_fail.cmake)
set_tests_properties(bvnr_cli_convert_fail_dim_array PROPERTIES
    LABELS "cli;convert" TIMEOUT 30)

# query must print a sub-second datetime as the faithful ISO literal, not the
# fraction-dropping integer carrier.
add_test(
    NAME    bvnr_cli_query_datetime_fraction
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DFILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/frac_datetime.bvnr
            -DQPATH=.t
            "-DNEEDLE=2026-06-15T12:00:00.123Z"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/query_expect.cmake)
set_tests_properties(bvnr_cli_query_datetime_fraction PROPERTIES
    LABELS "cli;query" TIMEOUT 30)

# A legal document with a long compound unit overflowed the CLI's stack. The
# annotation rebuilder in `events -d` used a 128-byte buffer, ignored
# bvn_unit_to_string's return, and handed the result to "%s" -- but that function
# writes no NUL when the text does not fit, so it read off the end. Eight
# components of the longest prefixable symbol need 150 bytes.
add_test(
    NAME    bvnr_cli_events_long_unit
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DFILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/long_unit.bvnr
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/events_ok.cmake)
set_tests_properties(bvnr_cli_events_long_unit PROPERTIES
    LABELS "cli;events;unit" TIMEOUT 30)

# `query` printed every float through "%g" -- six significant digits -- so an
# exact-decimal money value came out as 1.23457e+07, cents and notation both
# gone, in a format whose premise is that the number is not to be guessed at.
foreach(_qcase "money;12345678.99" "big;1234567.89" "plain;1.5")
    list(GET _qcase 0 _qkey)
    list(GET _qcase 1 _qwant)
    add_test(
        NAME    bvnr_cli_query_float_precision_${_qkey}
        COMMAND "${CMAKE_COMMAND}"
                -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
                -DFILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/float_precision.bvnr
                -DQPATH=.${_qkey}
                "-DNEEDLE=${_qwant}"
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/query_expect.cmake)
    set_tests_properties(bvnr_cli_query_float_precision_${_qkey} PROPERTIES
        LABELS "cli;query" TIMEOUT 30)
endforeach()

# /-separated dimension rows must survive `query`, which prints a value someone
# reads or pipes onward. They used to be flattened, so ".rows" and ".flat" below
# printed identically.
add_test(
    NAME    bvnr_cli_query_array_dims
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DFILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/array_dims.bvnr
            -DQPATH=.rows
            "-DNEEDLE=[[1, 2, 3], [4, 5, 6]]"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/query_expect.cmake)
set_tests_properties(bvnr_cli_query_array_dims PROPERTIES
    LABELS "cli;query;arrays" TIMEOUT 30)

add_test(
    NAME    bvnr_cli_query_array_flat
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DFILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/array_dims.bvnr
            -DQPATH=.flat
            "-DNEEDLE=[1, 2, 3, 4, 5, 6]"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/query_expect.cmake)
set_tests_properties(bvnr_cli_query_array_flat PROPERTIES
    LABELS "cli;query;arrays" TIMEOUT 30)

add_test(
    NAME    bvnr_cli_query_array_dims3
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DFILE=${CMAKE_CURRENT_SOURCE_DIR}/tests/array_dims.bvnr
            -DQPATH=.deep
            "-DNEEDLE=[[1, 2], [3, 4], [5, 6]]"
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/query_expect.cmake)
set_tests_properties(bvnr_cli_query_array_dims3 PROPERTIES
    LABELS "cli;query;arrays" TIMEOUT 30)

# A test that quietly stops registering is worse than one that fails: the suite
# still reports 100% and the guard is simply gone. That happened once already --
# bvnr_wasm_freshness guarded on BVNR_PACKAGE, which at this point in the
# configure had not been declared yet, so the condition was false and the test
# vanished without a word. Assert the expected count instead of trusting it.
# Deliberately NOT a cache variable: a cached value survives an edit to the
# default here, so bumping the count after adding a test would have no effect in
# an existing build directory -- the check would keep reporting the old number.
set(BVNR_EXPECTED_TEST_COUNT 200)   # 134 base + rust_build_sync + canonical_keeps_meaning + interval_array + bench_iterations_zero + sitemap + convert_wide_int + unit_source + 3 for examples/unit_profiles.bvnr + i18n_de + wrapper_sync + doc_counts + doc_examples + doc_unit_factors + doc_profile_atoms + doc_ambiguities + doc_python_examples + doc_policy + 9 policy-diagnostic (6 validate + 2 query + 1 document-tier) + cli_regressions + wasm_native_shape + web_browser + abi_baseline + crate_provenance
                                    #             + citation
                                    #         + profiles_off_build
                                    # + om_test + cf_test
                                    # + cache_stamps + profile_factors
                                    #         + playground_shim + wasm_cli_diff
                                    #         + amalgam_smoke + convert_fail_dim_array
                                    #         + ietf_draft + canonical_converges
                                    #         (both DISABLED without node)
                                    #           + csp + font_stamps + html_docs
                                    #           + web_links + release_links
                                    #           + 3 cli_query_float_precision
                                    #           + conformance_doc_examples
                                    #           + cli_events_long_unit
                                    #           + py_currency_parity
                                    #           + doc_layout + doc_refs
                                    #           + llms
                                    #           + want_unit_test
                                    #           + stream_vectors_test
                                    #           + stream_vectors_fresh
                                    #           + cli_stream_vectors

# ── bvnr_data.py vs the reference reader ────────────────────────────────────
# bvnr_data.py is a SECOND implementation of the format: a dependency-free parser
# the code generators use so a clean checkout can rebuild the unit, prefix and
# currency tables without a built libbvnr. Where it disagreed with the reference
# reader it shipped wrong tables silently -- `off` came back as the truthy string
# "off", so ".exact = off;" would have read as exact.
if(Python3_Interpreter_FOUND AND NOT WIN32)
    add_test(NAME bvnr_data_parity
        COMMAND ${Python3_EXECUTABLE}
                ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_bvnr_data_parity.py)
    set_tests_properties(bvnr_data_parity PROPERTIES
        LABELS "python;codegen"
        ENVIRONMENT "LIBBOVNAR_PATH=$<TARGET_FILE:bvnr_shared>"
        FAIL_REGULAR_EXPRESSION "FAILED"
        TIMEOUT 60)
endif()

# ── Conformance counts vs the documentation ─────────────────────────────────
# The documented total said 306 in six places while the suite ran 319, and one
# whole group was missing from the table. Check the numbers instead of copying
# them again.
if(BVNR_ALL_PROFILES)
add_test(
    NAME    bvnr_conformance_doc_counts
    COMMAND "${CMAKE_COMMAND}"
            -DCONFORMANCE=$<TARGET_FILE:bvnr_conformance>
            -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DDOCFILE=${CMAKE_CURRENT_SOURCE_DIR}/doc/13_bovnar_conformance.md
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/conformance_counts.cmake)
set_tests_properties(bvnr_conformance_doc_counts PROPERTIES
    LABELS "conformance;docs" TIMEOUT 60)
endif()   # BVNR_ALL_PROFILES

# ── doc/13's worked examples are what the adapter actually emits ──────────────
# doc/13 tells a third-party implementor that conformance means output
# "byte-for-byte identical to the reference", so its worked examples are what
# somebody copies. Nothing checked them, and the suite structurally could not:
# bvnr_conformance_iut_self compares the adapter's log against the reference's
# INTERNAL capture, and those two formatters are line-for-line duplicates -- both
# agreeing says nothing about either matching the document. Four of six examples
# were wrong, including the only one with an explicit annotation.
if(Python3_Interpreter_FOUND AND NOT WIN32
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_conformance_doc.py)
    add_test(NAME bvnr_conformance_doc_examples
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_conformance_doc.py
                     $<TARGET_FILE:bvnr_conformance_iut>)
    set_tests_properties(bvnr_conformance_doc_examples PROPERTIES
        LABELS "conformance;docs;iut" TIMEOUT 60
        DEPENDS bvnr_conformance_iut)
endif()

# ── WebAssembly artifact freshness ──────────────────────────────────────────
# The committed web/ module is presented as the reference implementation. It
# silently drifted 35 commits behind the library once; nothing detected it,
# because the ?v= cache-busting hashes key on the artifact's own bytes. This
# compares the sources it was built from against the sources as they are now, and
# needs neither emscripten nor node.
# The stamp covers the amalgamated sources, which only the bvnr_package target
# produces -- so with BVNR_PACKAGE=OFF there is nothing to hash and the test
# would hard-fail on a missing input rather than say nothing useful.
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/wasm/built_from.sha256 AND BVNR_PACKAGE)
    add_test(
        NAME    bvnr_wasm_freshness
        COMMAND "${CMAKE_COMMAND}"
                -DSTAMP=${CMAKE_CURRENT_SOURCE_DIR}/wasm/built_from.sha256
                "-DSOURCES=${CMAKE_CURRENT_BINARY_DIR}/amalgamate/bovnar.c;${CMAKE_CURRENT_BINARY_DIR}/amalgamate/bovnar.h;${CMAKE_CURRENT_SOURCE_DIR}/wasm/bvnr_wasm.c;${CMAKE_CURRENT_SOURCE_DIR}/wasm/build_wasm.sh"
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/wasm_freshness.cmake)
    set_tests_properties(bvnr_wasm_freshness PROPERTIES
        LABELS "wasm;packaging" TIMEOUT 60)
endif()

# ── The generated WASM wrapper still matches its source ─────────────────────
# web/bovnar_wasm.js is generated from wasm/index.mjs by build_wasm.sh. Editing
# the generated copy works until the next build silently reverts it, and nothing
# else in the tree notices -- the artifact stamp covers the compiled core, not
# this hand-written wrapper.
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/wasm/index.mjs)
    add_test(
        NAME    bvnr_wasm_wrapper_sync
        COMMAND "${CMAKE_COMMAND}"
                -DSRC=${CMAKE_CURRENT_SOURCE_DIR}/wasm/index.mjs
                -DCOPY=${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_wasm.js
                -DROOT=${CMAKE_CURRENT_SOURCE_DIR}
                -DDIST=${CMAKE_CURRENT_SOURCE_DIR}/dist/wasm
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/wasm_wrapper_sync.cmake)
    set_tests_properties(bvnr_wasm_wrapper_sync PROPERTIES
        LABELS "wasm;web" TIMEOUT 60)
endif()

# ── The playground's parser shim still agrees with the C core ───────────────
# web/bovnar_parser_wasm.js is the one part of the playground that is NOT the C
# core: it walks the reference event stream and rebuilds the document tree from
# it. Nothing on the C side can see that walk, and it was wrong -- a struct
# inside an array row became a keyed assignment in the enclosing scope, so
# `.a = [{.b=3;},{.c=4;}];` lost its array entirely and four of the shipped
# examples rendered with `null` keys. The test diffs the shim's tree against
# bvnr_wasm_doc's DOM projection of the same document, so the core stays the
# reference.
#
# node ships inside emsdk, so anyone who can build the WASM already has one --
# look there too rather than losing the gate to a PATH that lacks it. PATHS, not
# HINTS: HINTS are searched BEFORE the system PATH, which would pin the gate to
# emsdk's node on a machine that has one installed properly. This is the
# fallback, so it goes last.
file(GLOB BVNR_EMSDK_NODE_BIN "$ENV{EMSDK}/node/*/bin" "$ENV{HOME}/emsdk/node/*/bin")
find_program(NODE_EXECUTABLE NAMES node nodejs PATHS ${BVNR_EMSDK_NODE_BIN})
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/dist/wasm/index.mjs
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/wasm/test/playground_shim_test.mjs)
    # Registered either way, and DISABLED rather than dropped when there is no
    # node: ctest then reports it as skipped instead of silently not existing,
    # and the registration self-check below keeps one number for both cases.
    if(NODE_EXECUTABLE)
        add_test(
            NAME    bvnr_playground_shim
            COMMAND ${NODE_EXECUTABLE}
                    ${CMAKE_CURRENT_SOURCE_DIR}/wasm/test/playground_shim_test.mjs)
        set_tests_properties(bvnr_playground_shim PROPERTIES
            ENVIRONMENT "BOVNAR_CLI=$<TARGET_FILE:bovnar>")
    else()
        add_test(NAME bvnr_playground_shim COMMAND ${CMAKE_COMMAND} -E echo
                 "no node found -- install node or source emsdk to run this gate")
        set_tests_properties(bvnr_playground_shim PROPERTIES DISABLED TRUE)
    endif()
    set_tests_properties(bvnr_playground_shim PROPERTIES
        LABELS "wasm;web" TIMEOUT 120)
endif()

# ── The page, in a real browser, running the real WASM ──────────────────────
# The static wiring in playground_shim_test.mjs proves the scripts name each
# other; it cannot prove the chain boots, that the module instantiates, or that
# the demos and the playground render values that came out of the reference
# parser. This loads the real page in headless Chrome and checks exactly that.
# Chrome is optional: with none found the test exits 77 ("SKIP: no Chrome"),
# which SKIP_RETURN_CODE reports as skipped rather than failed.
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/dist/wasm/index.mjs
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/wasm/test/browser_smoke.mjs)
    if(NODE_EXECUTABLE)
        add_test(
            NAME    bvnr_web_browser
            COMMAND ${NODE_EXECUTABLE}
                    ${CMAKE_CURRENT_SOURCE_DIR}/wasm/test/browser_smoke.mjs)
    else()
        add_test(NAME bvnr_web_browser COMMAND ${CMAKE_COMMAND} -E echo
                 "no node found -- install node or source emsdk to run this gate")
        set_tests_properties(bvnr_web_browser PROPERTIES DISABLED TRUE)
    endif()
    set_tests_properties(bvnr_web_browser PROPERTIES
        LABELS "wasm;web" TIMEOUT 180 SKIP_RETURN_CODE 77)
endif()

# ── The WASM build answers like the native CLI ──────────────────────────────
# wasm/test/diff_test.mjs has compared the two since it was written and has
# never run automatically: validate() and toJSON() against `bovnar validate` and
# `bovnar convert`, over the example corpus plus deliberately malformed
# snippets. It is the only test that holds the shipped WebAssembly artifact to
# the C implementation's own answers -- the artifact stamp beside it proves the
# module was built from these sources, not that it behaves like them.
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/dist/wasm/index.mjs
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/wasm/test/diff_test.mjs)
    if(NODE_EXECUTABLE)
        add_test(
            NAME    bvnr_wasm_cli_diff
            COMMAND ${NODE_EXECUTABLE}
                    ${CMAKE_CURRENT_SOURCE_DIR}/wasm/test/diff_test.mjs)
        set_tests_properties(bvnr_wasm_cli_diff PROPERTIES
            ENVIRONMENT "BOVNAR_CLI=$<TARGET_FILE:bovnar>")
    else()
        add_test(NAME bvnr_wasm_cli_diff COMMAND ${CMAKE_COMMAND} -E echo
                 "no node found -- install node or source emsdk to run this gate")
        set_tests_properties(bvnr_wasm_cli_diff PROPERTIES DISABLED TRUE)
    endif()
    set_tests_properties(bvnr_wasm_cli_diff PROPERTIES
        LABELS "wasm;web" TIMEOUT 300)
endif()

# ── Content-Security-Policy still authorises the pages' inline scripts ───────
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/gen_csp.py)
    add_test(
        NAME    bvnr_csp
        COMMAND "${CMAKE_COMMAND}"
                -DPYTHON=${Python3_EXECUTABLE}
                -DSCRIPT=${CMAKE_CURRENT_SOURCE_DIR}/gen_csp.py
                -DWORKDIR=${CMAKE_CURRENT_SOURCE_DIR}
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/csp.cmake)
    set_tests_properties(bvnr_csp PROPERTIES LABELS "web;security" TIMEOUT 60)
endif()

# ── Font/stylesheet cache stamps still match their bytes ────────────────────
# Same contract as bvnr_cache_stamps, one level down: the woff2 URLs live inside
# fonts.css and the pages' link to fonts.css is stamped from that file, so an
# edited font that skipped the generator would be requested under the old
# immutable URL and served from cache for a year. The script prints its own
# remedy, so no wrapper is needed to explain the failure.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/gen_font_stamps.py)
    add_test(NAME bvnr_font_stamps
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/gen_font_stamps.py --check)
    set_tests_properties(bvnr_font_stamps PROPERTIES LABELS "web" TIMEOUT 60)
endif()

# ── The generated HTML doc pages match their Markdown sources ───────────────
# web/docs/**/index.html is committed, so an edited doc/*.md that never went
# through the generator ships a page contradicting the Markdown beside it -- the
# drift every other generator here is already gated on.
#
# The interpreter alone is not enough, exactly as above: gen_html_docs.py renders
# through python-markdown, a third-party module the runner does not ship. Without
# it the test failed on the import line with "No module named 'markdown'" -- a
# missing tool reported as a doc-drift failure. Probe for the module and skip the
# registration when it is absent, the way a missing interpreter already does.
# CI installs it and then asserts this test exists, so the skip cannot quietly
# become how the check stops running there.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/gen_html_docs.py)
    execute_process(
        COMMAND ${Python3_EXECUTABLE} -c "import markdown"
        RESULT_VARIABLE _bvnr_markdown_probe
        OUTPUT_QUIET ERROR_QUIET)
    if(_bvnr_markdown_probe EQUAL 0)
        set(_bvnr_have_markdown TRUE)
        add_test(NAME bvnr_html_docs
                 COMMAND ${Python3_EXECUTABLE}
                         ${CMAKE_CURRENT_SOURCE_DIR}/gen_html_docs.py --check)
        set_tests_properties(bvnr_html_docs PROPERTIES LABELS "web;docs" TIMEOUT 120)
        # web/sitemap.xml is committed and lists every document page; it needs
        # gen_html_docs' doc set, hence markdown. Its <lastmod> dates are commit
        # dates refreshed on publish, so --check compares the URLs only.
        add_test(NAME bvnr_sitemap
                 COMMAND ${Python3_EXECUTABLE}
                         ${CMAKE_CURRENT_SOURCE_DIR}/gen_sitemap.py --check)
        set_tests_properties(bvnr_sitemap PROPERTIES LABELS "web;docs" TIMEOUT 60)
    else()
        message(STATUS
            "markdown not importable by ${Python3_EXECUTABLE} -- skipping the "
            "generated HTML doc check. Run 'pip install markdown' to enable it.")
    endif()
endif()

# ── The LLM views are regenerated when the documents are ────────────────────
# web/llms.txt, web/index.md and web/llms-full.txt -- the last a megabyte, a
# concatenation of the README and every document -- are committed, and nothing
# compared them with their sources: publish_web.sh regenerates them, so the live
# site was right, while the tree a tag is cut from and the source tarball could
# carry a copy from before the last doc edit. (f058bb2 was exactly that.) The
# generator is stdlib-only and deterministic, so it needs no probe.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/gen_llms.py)
    add_test(NAME bvnr_llms
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/gen_llms.py --check)
    set_tests_properties(bvnr_llms PROPERTIES LABELS "web;docs" TIMEOUT 60)
endif()

# ── Every internal link on the site goes somewhere ──────────────────────────
# bvnr_html_docs above proves each page matches its source; it says nothing about
# whether the page's links resolve, and a page can be perfectly fresh and still
# link into thin air. That is not hypothetical: gen_html_docs rewrites an
# inter-doc link to its HTML page, and a document with NO html page fell through
# with the relative href its Markdown wrote, so three published pages pointed at
# /docs/<page>/07_bovnar_unit_ambiguities.md while the file served from /doc/. Every
# generator was green throughout.
#
# The check also refuses a link to a path publish_web.sh excludes from the
# upload, which is the one dead link a checkout cannot see -- the file is right
# there locally and simply never reaches the server. Pure stdlib, no network.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_web_links.py)
    add_test(NAME bvnr_web_links
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_web_links.py)
    set_tests_properties(bvnr_web_links PROPERTIES LABELS "web;docs" TIMEOUT 120)
endif()

# ── The site's Downloads menu points at the NEWEST release ──────────────────
# bvnr_web_links deliberately never fetches an external host, so the six links in
# the landing page's downloads menu are unchecked by it -- and those are the one
# set of external links on the site that go stale on a schedule. The archives are
# named bovnar-<version>-<count>-…, with <count> the tagged commit's commit
# count, so there is no generic URL to point at and the menu has to be retyped at
# every release. Miss it and nothing looks wrong: the links still resolve, still
# download, and still hand out the previous version to everyone who trusts the
# site. The names are fully derivable from the tag, so derive them and compare --
# same recipe as pack_artifacts.cmake, no network.
# ── The unit and currency tables in doc/ match src/gendata ─────────────────
# doc/04 and doc/05 carry every unit and every currency, twice over, as
# hand-written markdown -- roughly 1150 rows of symbols, accepted spellings, ISO
# numeric codes and minor-unit counts. Everything else derived from gendata is
# generated or gated; these two documents were the copy nothing compared, so a
# renamed unit or a corrected minor-unit digit could leave the reference a reader
# trusts saying something else with the build still green. And minor_unit is the
# field an application formats money with.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_unit_tables.py)
    add_test(NAME bvnr_doc_unit_tables
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_unit_tables.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_doc_unit_tables PROPERTIES
        LABELS "docs;units;gendata" TIMEOUT 60)
endif()

# ── The Factor column in doc/04 and doc/05 matches src/gendata ─────────────
# The gate above compares symbols, spellings and enumerators and says in its own
# header that it does not compare the Factor column, "prose as much as data".
# That column is the one a reader USES -- it is where somebody looking up `inHg`
# learns the number to multiply by -- and it turned out to be a small grammar
# rather than prose, so it is now evaluated in exact rational arithmetic (with π
# carried symbolically) and compared, along with the DIMENSION the cell's own
# unit product implies and its "(exact)" claim. It found five truncated factors
# and a section written in ASCII-degraded notation.
# Where the six gates below find THIS build. They load libbvnr through the Python
# package, run the CLI and run the per-profile test binaries, and they used to
# look for all three under <source>/build -- so from a build directory by any
# other name they printed "no built library; skipped" and passed, and in the
# sanitizer pass, which CI configures into build-asan, they re-checked the first
# pass's binaries. BVNR_REQUIRE_BUILD turns each of those skips into a failure
# (bvnr_build_env.py); run by hand, without it, the scripts still skip.
#
# Only where the host interpreter can load what was built: not a sanitized
# libbvnr (ASan refuses to be dlopen'ed into an uninstrumented python), not a PE
# DLL from the cross build, not Windows. There the gates keep their old search
# and their old skip -- they are document checks, and the documents are checked
# by the plain build.
if(NOT WIN32 AND NOT CMAKE_CROSSCOMPILING AND NOT BVNR_SANITIZE)
    set(_bvnr_doc_gate_env
        "LIBBOVNAR_PATH=$<TARGET_FILE:bvnr_shared>"
        "BOVNAR_CLI=$<TARGET_FILE:bovnar>"
        "BVNR_TESTS_DIR=${CMAKE_CURRENT_BINARY_DIR}/tests"
        "BVNR_REQUIRE_BUILD=1")
else()
    set(_bvnr_doc_gate_env "BVNR_DOC_GATE_BUILD=unpinned")
endif()
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_unit_factors.py)
    add_test(NAME bvnr_doc_unit_factors
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_unit_factors.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_doc_unit_factors PROPERTIES
        LABELS "docs;units;gendata" TIMEOUT 60
        ENVIRONMENT "${_bvnr_doc_gate_env}")
endif()

# ── doc/11 §6.1 lists every mapped UCUM atom, and lists it right ───────────
# §6.1 opens with "What follows is the whole mapped list" and it was not: 188
# codes are mapped and 155 appeared, so a third of the profile was invisible in
# the one place that promises completeness -- and the same section said the US
# survey series "is refused" while §6.3 correctly listed all nine as mapped.
# check_doc_error_claims (in check_profile_factors.py) could not see either,
# because it keys on an `error_unit_*` token and plain English carries none.
# This asks the completeness question instead, and checks every row's target and
# factor against the reader rather than against a restatement of its rules.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_profile_atoms.py)
    add_test(NAME bvnr_doc_profile_atoms
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_profile_atoms.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_doc_profile_atoms PROPERTIES
        LABELS "docs;units;gendata;profiles" TIMEOUT 60
        ENVIRONMENT "${_bvnr_doc_gate_env}")
endif()

# ── doc/07's claims, put to the reference parser ───────────────────────────
# doc/07's header promises "every row here was checked against the reference
# parser; where a token is refused, it really is error_unit_illegal" -- and
# nothing re-asked, which is the one property a table of REFUSALS cannot keep on
# its own. A refusal rots the moment the catalogue grows: adding a unit spelled
# `usb`, or an alias that made `kWh` resolve, would leave §4 and §13 asserting an
# error the library no longer raises, in the direction no test notices. §2, §3,
# §4, §5, §9 and §13 are now all put to the parser, including §9's "families, in
# full" sentence, which is checked for CLOSURE -- a new native unit of one of
# those dimensions joins the family whether or not anybody updates the sentence.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_ambiguities.py)
    add_test(NAME bvnr_doc_ambiguities
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_ambiguities.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_doc_ambiguities PROPERTIES
        LABELS "docs;units;gendata" TIMEOUT 60
        ENVIRONMENT "${_bvnr_doc_gate_env}")
endif()

# ── doc/09's `# →` expectations are evaluated ──────────────────────────────
# doc/09 is the Python reference and states its results the way a reader reads
# them ("unit_to_profile(\"unece\", kg)   # -> \"KGM\""). Nothing ran any of it:
# check_doc_examples.py parses the embedded BOVNAR documents and this file's
# other gates cover the tables, leaving 76 KB of Python as the copy nobody
# executed. Three claims in it were wrong at once, all around UnitFlags.REDUCE --
# including one that told a caller to apply a scale that double-applies wherever
# the formatter re-attaches a prefix. Blocks that need a file, a handler or an
# optional import are skipped, and the skipped count is printed so that
# "nothing is checked" cannot look like success.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_python_examples.py)
    add_test(NAME bvnr_doc_python_examples
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_python_examples.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_doc_python_examples PROPERTIES
        LABELS "docs;python;units" TIMEOUT 60
        ENVIRONMENT "${_bvnr_doc_gate_env}")
endif()

# ── doc/06's unit-policy claims, put to the library ────────────────────────
# doc/06 was the only unit document with no gate at all, and it cost doc/06 §2.4:
# an `error_unit_inexact` example whose degrees line was glossed as if
# --leave-inexact had saved it, when doc/06 §2.3 leaves every dimensionless unit
# alone
# and the strict run actually fails on the k~m/h two lines below.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_policy.py)
    add_test(NAME bvnr_doc_policy
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_policy.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_doc_policy PROPERTIES
        LABELS "docs;units" TIMEOUT 120
        ENVIRONMENT "${_bvnr_doc_gate_env}")
endif()

# ── The catalogue counts stated in prose match src/gendata ─────────────────
# The tables are gated above; the SENTENCES were not. A stale unit count sat in
# the README, the cheatsheet's scope line, the EBNF commentary, the IETF draft
# and the JOSS paper for as long as the registry had been growing, because a
# count in prose is invisible to a table check. This reads the headline numbers
# back out of the documents and compares them to what src/gendata actually
# holds. (Stating the old figure here would be a claim like any other, and this
# gate would rightly fail on it -- hence the wording.)
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_counts.py)
    add_test(NAME bvnr_doc_counts
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_counts.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_doc_counts PROPERTIES
        LABELS "docs;units;gendata" TIMEOUT 60
        ENVIRONMENT "${_bvnr_doc_gate_env}")
endif()

# ── The documents embedded in the docs parse ───────────────────────────────
# Counts and tables are gated above; the EXAMPLES were not, and the front door
# was wrong because of it. README.md's first example -- the first bovnar
# document most readers ever see -- opened with a comment on the spec-version
# directive line, which is error_invalid_spec_version: the annotation is the
# "trailing junk" doc/03 forbids four lines above its own example block, and
# conformance case VER-013 asserts the rejection. Prose, reader and suite all
# agreed; the two most-read examples disagreed with all three.
#
# Needs the built CLI, so it skips rather than fails when there is none.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_examples.py)
    add_test(NAME bvnr_doc_examples
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_examples.py)
    set_tests_properties(bvnr_doc_examples PROPERTIES
        LABELS "docs" TIMEOUT 120
        ENVIRONMENT "BOVNAR_CLI=$<TARGET_FILE:bovnar>")
endif()

# ── The installed header set is self-contained ─────────────────────────────
# install(FILES ...) names each public header explicitly, which is right — a
# glob would ship whatever was lying in include/ — but it is a hand-maintained
# second list of something bovnar.h already states by including it. That is
# exactly how bovnar_profiles.gen.h came to be missing from it: the library
# built, every test passed, and the installed bovnar.h could not preprocess,
# because nothing here ever compiled against an install. This walks the include
# graph from the shipped set and fails when the closure is not covered.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_install_headers.py)
    add_test(NAME bvnr_install_headers
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_install_headers.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_install_headers PROPERTIES
        LABELS "packaging" TIMEOUT 60)
endif()

# ── The public ABI, against its committed baseline ─────────────────────────
# The binding ABI tests compare the Python and Rust mirrors with the headers,
# so a header change that both mirrors follow passes them, while a program
# built against the previous release still reads the old layout from the same
# soname. check_abi.py compares the headers' struct layouts, enum values,
# integer constants and prototypes, and libbvnr.so's exports, with
# tests/abi/bvnr-<BVNR_ABI_MAJOR>.*; its failure message says what to do. The
# baseline is a 64-bit GCC/Clang one, and the export list is read with nm -D,
# so on ELF only.
if(Python3_Interpreter_FOUND AND CMAKE_SIZEOF_VOID_P EQUAL 8
   AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang" AND NOT CMAKE_CROSSCOMPILING)
    set(_bvnr_abi_lib "")
    if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_NM AND TARGET bvnr_shared)
        set(_bvnr_abi_lib --lib $<TARGET_FILE:bvnr_shared> --nm ${CMAKE_NM})
    endif()
    add_test(NAME bvnr_abi_baseline
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_abi.py
                     --cc ${CMAKE_C_COMPILER} ${_bvnr_abi_lib})
    # The fixture (registered with the Python gates) rebuilds bvnr_shared
    # first; without it the test reads whatever the last build left.
    set_tests_properties(bvnr_abi_baseline PROPERTIES
        LABELS "abi;packaging" TIMEOUT 60
        FIXTURES_REQUIRED abi_dump_fixture)
endif()

# rust/build.rs compiles the C library from its own copy of the source,
# profile and generator lists; this fails when it drifts from CMakeLists.txt.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_rust_build.py)
    add_test(NAME bvnr_rust_build_sync
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_rust_build.py
                     ${CMAKE_CURRENT_SOURCE_DIR})
    set_tests_properties(bvnr_rust_build_sync PROPERTIES
        LABELS "packaging" TIMEOUT 60)
endif()

# ── The crate's PROVENANCE.json is the commit it names ─────────────────────
# rust/package.py stages the crate crates.io gets, writes its manifest (commit,
# tag, every file's SHA-256) and checks it with check_crate_provenance.py:
# staged again from that commit, the crate must come out the same. --no-cargo
# stops there, so this needs Python and git but no Rust toolchain.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_crate_provenance.py)
    add_test(NAME bvnr_crate_provenance
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/rust/package.py --no-cargo)
    set_tests_properties(bvnr_crate_provenance PROPERTIES
        LABELS "packaging" TIMEOUT 120)
endif()

if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_release_links.py)
    add_test(NAME bvnr_release_links
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_release_links.py)
    set_tests_properties(bvnr_release_links PROPERTIES
        LABELS "web;packaging" TIMEOUT 60)
endif()

# ── CITATION.cff is a schema-valid CFF 1.2 file ─────────────────────────────
# Nothing read this file but machines, and machines do not complain: GitHub's
# citation widget, Zenodo and cffconvert degrade quietly on a document that fails
# validation. One line -- `abbreviation: BVNR`, a real CFF key that is legal only
# inside a `references:` entry -- sat at the top level, where the schema sets
# additionalProperties: false, and invalidated the whole file for as long as it
# was there. It also matters more than it did: the file's `references:` block now
# carries the machine-readable attribution for the six unit vocabularies whose
# identifiers ship in the library (THIRD_PARTY_NOTICES.md), and attribution that
# fails to parse is attribution nobody receives.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_citation.py)
    add_test(NAME bvnr_citation
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_citation.py)
    set_tests_properties(bvnr_citation PROPERTIES
        LABELS "packaging" TIMEOUT 60)
endif()

# ── Every document in doc/ has the same shape ───────────────────────────────
# bvnr_web_links proves a link goes somewhere; nothing proved a document was
# still built like its ten siblings. Eleven documents written over as many
# months had drifted into eleven layouts -- four with no table of contents at
# all, two spellings of the heading for the ones that had it, five different
# metadata key sets under the title, and section numbering in six of them. None
# of that fails a build, renders wrong, or breaks a link, so nothing here caught
# it. The worst of it is the TOC: adding a section is one edit and updating the
# list at the top is another, and the second one is the one that gets forgotten.
# The check recomputes each TOC from the headings, using gen_html_docs' own
# slugify, so a passing TOC is one whose links resolve on the published page too.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_layout.py)
    add_test(NAME bvnr_doc_layout
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_layout.py)
    set_tests_properties(bvnr_doc_layout PROPERTIES LABELS "docs" TIMEOUT 60)
endif()

# ── A "§N.M" in a comment still points at a section that exists ─────────────
# The code cites the documentation constantly -- a test says what it pins with
# "doc/08 §1.11 promises …" -- and those citations are the only thread from a
# subtle test back to the sentence that made it a requirement. Renumbering
# doc/08's function sections cut every one of them at once, and the whole suite
# stayed green: bvnr_web_links checks the documents' links, not what the C says
# about them. Two more had been wrong for longer -- "spec §501" (bovnar:no-section-check;
# this comment NARRATES a dead citation, and the checker now reads the root),
# never a section number, and a quoted doc/05 heading the document does not
# contain.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_refs.py)
    add_test(NAME bvnr_doc_refs
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_doc_refs.py)
    set_tests_properties(bvnr_doc_refs PROPERTIES LABELS "docs" TIMEOUT 60)
endif()

# ── canonical document form ──────────────────────────────────────────────────
# `pretty-print --canonical` claims something pretty-printing does not: that two
# documents which MEAN the same thing produce the same bytes. That is what a
# signature, a content hash or a semantic diff needs, and it is testable in
# exactly one way -- feed it two spellings of one document and compare.
#
# Idempotence is checked in the same run, because a "canonical" form that moves
# on a second pass is just one more spelling and would break any consumer storing
# a hash of it.
add_test(
    NAME    bvnr_cli_canonical_converges
    COMMAND "${CMAKE_COMMAND}"
            -DBOVNAR=$<TARGET_FILE:bovnar> -DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}
            -DFILE_A=${CMAKE_CURRENT_SOURCE_DIR}/tests/canonical_prefixed.bvnr
            -DFILE_B=${CMAKE_CURRENT_SOURCE_DIR}/tests/canonical_reduced.bvnr
            -DTMP_DIR=${CMAKE_CURRENT_BINARY_DIR}/canonical
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/canonical_converges.cmake)
set_tests_properties(bvnr_cli_canonical_converges PROPERTIES
    LABELS "cli;canonical;units" TIMEOUT 30)
# ...and two spellings that do NOT mean the same thing stay apart: a datetime
# epoch is not rewritten as a unit (`galileo` is also the gal), an affine scale
# in a compound is not reduced into one that converts, and ΔK·m/m is not K·m/m.
_bvnr_cli_expect(bvnr_cli_canonical_keeps_meaning "cli;canonical;units" 0
                 "<datetime:64,galileo> 5" "Gal>|°C>"
                 pretty-print --canonical
                 ${CMAKE_CURRENT_SOURCE_DIR}/tests/canonical_keeps_meaning.bvnr)
# Zero benchmark rounds measure nothing; it used to become one silently.
_bvnr_cli_expect(bvnr_cli_bench_iterations_zero "cli" 2
                 "--iterations must be at least 1" ""
                 bench --iterations 0)
_bvnr_cli_expect(bvnr_cli_interval_array "cli;units" 1
                 "array_element_type_mismatch" ""
                 validate ${CMAKE_CURRENT_SOURCE_DIR}/tests/interval_array.bvnr)

# ── the IETF draft against the header ────────────────────────────────────────
# doc/ietf/draft-sonntag-bovnar-00 is a SECOND, independent statement of the
# format, rendered into four committed artifacts, and nothing compared it to
# anything: bvnr_doc_refs checks that citations resolve and that the spec's error
# table matches the header, bvnr_doc_layout checks structure, and neither looks
# at the draft or at any numeric CLAIM.
#
# So it drifted, and doc/05 with it: both said a compound unit may not exceed
# "8 components" long after BVNR_MAX_UNIT_COMPONENTS became 32, while doc/03 said
# 32. Someone implementing from the draft would have rejected documents the
# reference implementation accepts -- an interoperability defect in the one
# document whose whole purpose is interoperability.
#
# The renderings are checked too, not just the .md they are generated from:
# kramdown-rfc and xml2rfc are not run here or in CI, so a fix applied to the
# source alone would leave three committed files stating the old value.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_ietf_draft.py)
    add_test(NAME bvnr_ietf_draft
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_ietf_draft.py)
    set_tests_properties(bvnr_ietf_draft PROPERTIES LABELS "docs;ietf" TIMEOUT 60)
endif()

# ── the profile tables against the publications they transliterate ───────────
# The one thing nothing else covers: every other check proves the tables are
# self-consistent, and five tables wrong in the same way would agree with each
# other perfectly (doc/11 §14.3). This reads UCUM's and UDUNITS' own machine-
# readable definitions and compares each mapped row's factor and dimension
# against what the reference library says the native target is worth.
#
# It SKIPS, green, when the publisher files are not cached: a test must not
# depend on the network, which is the same rule bvnr_web_links follows. Populate
# the cache once with
#     python3 check_profile_factors.py --fetch
# and every later run checks for real. The scheduled workflow
# .github/workflows/vocabulary-drift.yml does exactly that weekly, with --strict
# so that a skip there is a failure rather than a quiet pass.
#
# Labelled "python" although the name does not start with bvnr_py_: the label
# records "drives libbvnr from a Python interpreter", and LIBBOVNAR_DIR below is
# exactly that -- the checker resolves every native target through the ctypes
# bindings. A sanitizer build cannot do it (ASan refuses a dlopen of its own
# runtime into an uninstrumented interpreter: "ASan runtime does not come first
# in initial library list"), so the ASan pass excludes it with -LE python, the
# same way bvnr_data_parity is excluded. The unsanitized pass still runs it.
if(Python3_Interpreter_FOUND
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/check_profile_factors.py)
    add_test(NAME bvnr_profile_factors
             COMMAND ${Python3_EXECUTABLE}
                     ${CMAKE_CURRENT_SOURCE_DIR}/check_profile_factors.py
                     --cache ${CMAKE_CURRENT_BINARY_DIR}/vocab)
    set_tests_properties(bvnr_profile_factors PROPERTIES
        LABELS "python;units;profile;vocab" TIMEOUT 120
        ENVIRONMENT "LIBBOVNAR_DIR=${CMAKE_CURRENT_BINARY_DIR}")
endif()

# ── ?v= cache-bust stamps still match the files they label ──────────────────
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/web/index.html)
    add_test(
        NAME    bvnr_cache_stamps
        COMMAND "${CMAKE_COMMAND}"
                -DPAGE=${CMAKE_CURRENT_SOURCE_DIR}/web/index.html
                "-DPAIRS=${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_parser_wasm.js:bovnar_parser_wasm\\.js;${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_wasm.js:bovnar_wasm\\.js;${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_wasm_core.js:bovnar_wasm_core\\.js;${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_highlight.js:bovnar_highlight\\.js;${CMAKE_CURRENT_SOURCE_DIR}/web/fonts/fonts.css:fonts/fonts\\.css;${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_wasm.js:bovnar_wasm\\.js:${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_parser_wasm.js;${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_wasm_core.js:bovnar_wasm_core\\.js:${CMAKE_CURRENT_SOURCE_DIR}/web/bovnar_wasm.js;${CMAKE_CURRENT_SOURCE_DIR}/web/earth.jpg:earth\\.jpg;${CMAKE_CURRENT_SOURCE_DIR}/web/earth_night.jpg:earth_night\\.jpg"
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/cache_stamps.cmake)
    set_tests_properties(bvnr_cache_stamps PROPERTIES LABELS "wasm;web" TIMEOUT 60)
endif()

# ── Website translations still match the English source ─────────────────────
# gen_i18n.py keys each translation on the English text it was made from, so
# editing web/index.html orphans the German for that string. Nothing about the
# English page changes when that happens -- it keeps working perfectly -- which
# is exactly why this needs a test: the failure is invisible until a German
# visitor reads a paragraph that silently reverted to English. --check also
# rejects a document-relative asset path added to a <script>, which would 404
# only on the translated pages.
if(Python3_Interpreter_FOUND AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/web/i18n)
    # CONFIGURE_DEPENDS so dropping in web/i18n/fr.json re-runs CMake and
    # actually registers its gate, instead of waiting for an unrelated
    # reconfigure. Note a new language file also moves the registered test
    # count, so BVNR_EXPECTED_TEST_COUNT must be bumped with it.
    file(GLOB _i18n_tables CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/web/i18n/*.json)
    foreach(_tbl ${_i18n_tables})
        get_filename_component(_lang "${_tbl}" NAME_WE)
        add_test(
            NAME    bvnr_i18n_${_lang}
            COMMAND ${Python3_EXECUTABLE}
                    ${CMAKE_CURRENT_SOURCE_DIR}/gen_i18n.py --check ${_lang})
        set_tests_properties(bvnr_i18n_${_lang} PROPERTIES
            LABELS "web;i18n" TIMEOUT 60)
    endforeach()
endif()

# ── Amalgamation reproducibility ────────────────────────────────────────────
# bvnr_wasm_freshness above hashes the amalgamated sources, so it is only a
# freshness check if that hash depends on the SOURCES and nothing else. Twice now
# it has also depended on the build directory's name, and the second time the
# suite was green in build/ while failing in build-asan/ over identical inputs --
# a gate accusing the tree of being stale when the tree was fine. Pin the
# property the gate rests on rather than rediscovering it from the wreckage.
if(Python3_Interpreter_FOUND AND NOT WIN32)
    add_test(
        NAME    bvnr_amalgam_reproducible
        COMMAND "${CMAKE_COMMAND}"
                -DPYTHON=${Python3_EXECUTABLE}
                -DSCRIPT=${CMAKE_CURRENT_SOURCE_DIR}/amalgamate.py
                -DGENERATED=${CMAKE_CURRENT_BINARY_DIR}/generated
                -DWORKDIR=${CMAKE_CURRENT_BINARY_DIR}/amalgam_repro
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/amalgam_reproducible.cmake)
    set_tests_properties(bvnr_amalgam_reproducible PROPERTIES
        LABELS "wasm;packaging;codegen" TIMEOUT 120)
endif()

# ── Single-file amalgamation ────────────────────────────────────────────────
# dist/bovnar.{c,h} are git-ignored, so the FRESHNESS half of this test (does the
# committed amalgamation match the sources?) has nothing to compare against and
# the driver skips it. The other half is not conditional on shipping anything and
# was disabled along with it, which left the amalgamation with no compile
# coverage at all: bvnr_amalgam_reproducible above proves only that two
# generations are byte-identical, which a file that does not compile satisfies
# perfectly.
#
# That gap has a cost the rest of the suite cannot see. An integrator's first act
# with a single-file library is to drop bovnar.c into their build, under their
# own warning flags. Ours were hiding a real one: -Wno-override-init in
# BVNR_C_FLAGS silenced 546 deliberate designated-initialiser overrides in the
# lexer state table for THIS build only, and the amalgamation carries that table
# verbatim -- so their first build produced 546 warnings ours never showed. The
# suppression now lives in a pragma in src/lexer/bovnar_state_table.c, where it
# travels with the code, and this test is what keeps it true: strict flags,
# -Werror, and NO -Wno- of any kind, which is the integrator's build and not
# ours.
if(Python3_Interpreter_FOUND AND NOT WIN32 AND NOT BVNR_SANITIZE
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/amalgam_smoke.c)
    add_test(
        NAME    bvnr_amalgam_smoke
        COMMAND "${CMAKE_COMMAND}"
                -DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
                -DPYTHON=${Python3_EXECUTABLE}
                -DCC=${CMAKE_C_COMPILER}
                -DBIN_DIR=${CMAKE_CURRENT_BINARY_DIR}/amalgam_smoke
                -DGENERATED=${BVNR_GENERATED_DIR}
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/amalgam_smoke.cmake)
    set_tests_properties(bvnr_amalgam_smoke PROPERTIES
        LABELS "packaging;codegen;amalgam" TIMEOUT 300)
endif()

# ── A build with every unit profile switched off ─────────────────────────────
# The per-vocabulary switches are compile-time, so nothing in this build can
# reach the off state, and a build configuration nothing reaches is one that
# quietly stops compiling. This compiles the amalgamation TWICE under the
# integrator's strict flags -- once with all seven at 0, once with a PARTIAL set
# -- and runs a smoke test against each. The all-off build pins the contract of
# an absent profile (error_unit_profile_unknown, unwritable as well as
# unreadable, native units untouched); the partial build pins what only a mixed
# configuration can show: that the profile index skips absent entries rather
# than walking them, that the blocked id space does not compact when a block is
# unused, and that an opaque unit whose profile is absent stays incommensurable
# and has no spelling. It also reports what the seven cost in binary size, which
# is the reason the switches exist.
#
# Registered UNCONDITIONALLY of the switches, unlike the suites above: this is
# the test that keeps the reduced configurations working, so a reduced build is
# the last place it should be skipped.
if(Python3_Interpreter_FOUND AND NOT WIN32 AND NOT BVNR_SANITIZE
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/profiles_off_smoke.c
   AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/profiles_partial_smoke.c)
    add_test(
        NAME    bvnr_profile_configs
        COMMAND "${CMAKE_COMMAND}"
                -DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
                -DPYTHON=${Python3_EXECUTABLE}
                -DCC=${CMAKE_C_COMPILER}
                -DBIN_DIR=${CMAKE_CURRENT_BINARY_DIR}/profiles_off
                -DGENERATED=${BVNR_GENERATED_DIR}
                -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/profiles_off_build.cmake)
    set_tests_properties(bvnr_profile_configs PROPERTIES
        LABELS "packaging;codegen;amalgam;profile" TIMEOUT 300)
endif()

# ── Tests that are about ALL the profiles ────────────────────────────────────
# Seven gates hold documents and tables that name every vocabulary, and ask the
# BUILT LIBRARY whether what they say is true: the WASM/CLI differential (it
# validates examples/unit_profiles.bvnr against a WASM bundle that always
# carries all seven), the embedded-example check (doc/11's examples are profile
# units), the factor proof (it reads each table through the library), and the
# four document gates that resolve the codes doc/07 and doc/11 quote and count
# the assertions the per-profile suites run. In a build with a profile compiled
# out (doc/11 §9.4) every one of them fails, for that reason and no other --
# `ucum:st` "does not resolve", a refusal documented as "unsupported" comes back
# "unknown", a suite runs fewer assertions than the document says. So there they
# are disabled: visibly, as "Not Run", rather than left to report a defect that
# is the build's own configuration. The documents describe the full build.
if(NOT BVNR_ALL_PROFILES)
    foreach(_bvnr_t bvnr_wasm_cli_diff bvnr_doc_examples bvnr_profile_factors
                    bvnr_doc_unit_factors bvnr_doc_profile_atoms
                    bvnr_doc_ambiguities bvnr_doc_counts)
        if(TEST ${_bvnr_t})
            set_tests_properties(${_bvnr_t} PROPERTIES DISABLED TRUE)
        endif()
    endforeach()
endif()

# ── Registration self-check ─────────────────────────────────────────────────
# Only meaningful for a default configure; any option that adds or removes tests
# makes the count legitimately differ, so this compares only when the relevant
# knobs are at their defaults. _bvnr_have_pytest, not Python3_Interpreter_FOUND:
# the Python gates now need the module too, so an interpreter without it is a
# legitimate shortfall (the bvnr_py_* gates plus the bvnr_abi_dump_build
# fixture registered beside them) rather than something silently unregistering.
# _bvnr_have_markdown for the same reason: bvnr_html_docs registers only where
# python-markdown imports, and without this term a machine with pytest and
# without markdown could not configure at all -- "registered 179 tests,
# expected 180", for a module the message above had just called optional.
get_property(_bvnr_all_tests DIRECTORY PROPERTY TESTS)
list(LENGTH _bvnr_all_tests _bvnr_ntests)
if(BVNR_PACKAGE AND BVNR_FUZZ_TEST AND NOT BVNR_SANITIZE AND NOT WIN32
   AND NOT CMAKE_CROSSCOMPILING AND _bvnr_have_pytest AND _bvnr_have_markdown
   AND BVNR_ALL_PROFILES)
    if(NOT _bvnr_ntests EQUAL ${BVNR_EXPECTED_TEST_COUNT})
        message(FATAL_ERROR
            "registered ${_bvnr_ntests} tests, expected "
            "${BVNR_EXPECTED_TEST_COUNT}. If you added or removed one, update "
            "BVNR_EXPECTED_TEST_COUNT in CMakeLists_tests.txt. If you did not, a "
            "test has silently stopped registering -- check the conditions "
            "guarding it.")
    endif()
endif()
message(STATUS "registered ${_bvnr_ntests} tests")
