diff --git a/Cargo.lock b/Cargo.lock index 2dc9b96..c0a1201 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,6 +245,8 @@ dependencies = [ "once_cell", "proptest", "rustc-hash", + "serde", + "serde_json", ] [[package]] @@ -362,6 +364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7089b77..d11d64b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ once_cell = "1" [dev-dependencies] proptest = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [profile.release] opt-level = 3 diff --git a/benches/lua_bench.lua b/benches/lua_bench.lua index 208abdb..daa8a89 100644 --- a/benches/lua_bench.lua +++ b/benches/lua_bench.lua @@ -1,8 +1,9 @@ -package.path = package.path .. ";./lua/?.lua" +package.path = package.path .. ";./lua/?.lua;./benches/?.lua" package.cpath = package.cpath .. ";./target/release/lib?.so" local qjson = require("qjson") local cjson = require("cjson") +local manifest_mod = require("manifest") local simdjson_ok, simdjson_or_err = pcall(function() return require("resty.simdjson").new() end) @@ -955,6 +956,40 @@ local function run_benchmarks() end) end end -- filter == "interleaved" + + -- Manifest-driven scenarios: reuse the real-world fixture manifest shared + -- with the Rust correctness gate (tests/fixtures/manifest.json). Fixture + -- paths, per-fixture iteration counts and access paths all come from the + -- manifest, so the benchmark and the correctness tests cover the same + -- payloads and paths. The access closure is generated from each fixture's + -- declared checks rather than being hand-written here. NDJSON fixtures are + -- correctness-only (validated by the Rust gate) and skipped here. + if not filter or filter == "manifest" then + print("=== manifest fixtures ===") + + local manifest = manifest_mod.load() + for _, f in ipairs(manifest.fixtures) do + if manifest_mod.has_ci(f, "bench") and f.format == "json" then + local payload = read_file(f.path) + local access = manifest_mod.qjson_access(f.checks) + local iters = f.bench_iters or 500 + print(string.format("--- %s [%s] (%d bytes) ---", + f.id, f.payload_type, #payload)) + + bench("qjson.parse + manifest access", iters, function() + local d = qjson.parse(payload) + access(d) + end) + + if has_pooled_api then + bench("qjson pooled :parse + manifest access", iters, function() + local d = pooled_decoder:parse(payload) + access(d) + end) + end + end + end + end -- filter == "manifest" end if smoke_mode then diff --git a/benches/manifest.lua b/benches/manifest.lua new file mode 100644 index 0000000..e6fee78 --- /dev/null +++ b/benches/manifest.lua @@ -0,0 +1,68 @@ +-- Shared loader for the real-world fixture manifest (issue #139). +-- +-- The manifest at tests/fixtures/manifest.json is the single source of truth +-- for fixture paths, payload classes, access paths and expected values. The +-- Rust correctness gate (tests/manifest_fixtures.rs) and this Lua benchmark +-- helper both read it, so paths and scenarios live in exactly one place. + +local cjson = require("cjson") + +local M = {} + +local function read_file(p) + local f = assert(io.open(p, "rb")) + local s = f:read("*a") + f:close() + return s +end + +-- Decode the manifest. `path` is repo-root relative; the bench is launched from +-- the repository root (see the Makefile `bench` target). +function M.load(path) + return cjson.decode(read_file(path or "tests/fixtures/manifest.json")) +end + +-- Index fixtures by id for direct lookup. +function M.by_id(manifest) + local m = {} + for _, f in ipairs(manifest.fixtures) do + m[f.id] = f + end + return m +end + +-- True if `fixture.ci` contains `tag` (pr | scheduled | bench). +function M.has_ci(fixture, tag) + for _, c in ipairs(fixture.ci or {}) do + if c == tag then return true end + end + return false +end + +-- Build an access function over a qjson Doc that touches every check path with +-- the getter matching its declared type. This is the manifest-driven +-- replacement for hand-written per-fixture access closures. +function M.qjson_access(checks) + return function(d) + for i = 1, #checks do + local c = checks[i] + local t = c.type + if t == "string" then + local _ = d:get_str(c.path) + elseif t == "number" then + local _ = d:get_f64(c.path) + elseif t == "bool" then + local _ = d:get_bool(c.path) + elseif t == "null" then + local _ = d:is_null(c.path) + else -- object | array + local _ = d:typeof(c.path) + end + if c.len ~= nil then + local _ = d:len(c.path) + end + end + end +end + +return M diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..b526142 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,107 @@ +# Real-world fixture manifest + +`manifest.json` is the single source of truth for qjson's real-world test +corpus. Both the Rust correctness gate (`tests/manifest_fixtures.rs`) and the +Lua benchmark harness (`benches/lua_bench.lua` via `benches/manifest.lua`) read +it, so fixture paths, access paths and expected values are declared in exactly +one place instead of being duplicated across the two suites. + +Originating issue: [#139](https://github.com/api7/lua-qjson/issues/139). + +## Format + +`manifest.json` is plain JSON so both consumers can parse it with tools they +already have — `serde_json` (a Rust dev-dependency) on the Rust side and +`lua-cjson` on the Lua side. The manifest is deliberately **not** parsed with +qjson itself, to avoid a bug in qjson breaking the harness that is supposed to +catch it. + +All `path` values are **repo-root relative**. Both consumers run from the +repository root (`CARGO_MANIFEST_DIR` for Rust, the `Makefile` working directory +for Lua). + +## Schema + +```jsonc +{ + "version": 1, + "fixtures": [ + { + "id": "rest_api_small", // unique identifier + "path": "benches/fixtures/small_api.json", + "source": "qjson bench fixture", // human-readable origin + "payload_type": "rest_api", // rest_api | unicode_heavy | wide_object + // | deep_nesting | ndjson + "format": "json", // json | ndjson + "size_bytes": 2115, // informational + "structural_density": "medium", // optional: low | medium | high + "workloads": ["parse_access", "decode_access", + "decode_encode", "modify_encode"], + "ci": ["pr", "scheduled", "bench"], // where the fixture is exercised + "bench_iters": 5000, // optional: benchmark iteration count + "checks": [ /* see below */ ] + } + ] +} +``` + +### Check entries + +Each check declares one access path and its expected result as a +`type` + optional `value` + optional `len` triple: + +```jsonc +{ "path": "info.version", "type": "string", "value": "1.0.0" } +{ "path": "messages", "type": "array", "len": 4 } +{ "record": 2, "path": "[1]", "type": "string", "value": "Motorola" } +``` + +- `path` — qjson path syntax: dotted keys plus `[i]` indices + (e.g. `choices[0].finish_reason`). The empty string `""` addresses the + document root (useful for scalar roots and for `len` on the top-level + container). +- `type` — one of `string | number | bool | null | object | array`. Always + checked via `qjson_typeof`. +- `value` — optional expected value for scalars (`string` / `number` / `bool`). + Omitted for `null` and containers. See the number note below. +- `len` — optional. For `string` it is the decoded **byte** length; for + `object` / `array` it is the member / element count. +- `record` — **NDJSON only**: 0-based line index selecting which record the + `path` is resolved against. + +### Numbers + +`value` for a `number` check is stored as a JSON number and compared as `f64` +with exact equality. Pick fixture values that are exactly representable as +`f64` (integers, short decimals like `0.5`). Avoid values such as `2.9` that +have no exact binary representation; check the type only, or assert on a +neighbouring exact field instead. + +### NDJSON + +A fixture with `"format": "ndjson"` is split on `\n` (a trailing `\r` is +trimmed) and each non-empty line is parsed as an independent qjson document. +Checks select a line with `record` (default `0`) and resolve `path` within that +record. NDJSON fixtures are correctness-only; the Lua latency benchmark skips +them. + +## Adding a fixture + +1. Prefer reusing an existing corpus file: `benches/fixtures/*`, + `tests/vendor/simdjson/jsonexamples/*`, `tests/vendor/cJSON/tests/*`, or a + `JSONTestSuite` sample. Only add a new file under `tests/fixtures/data/` when + no existing payload fits (e.g. the generated `wide_object.json` / + `deep_nesting.json`). +2. Add a fixture entry to `manifest.json` with its `id`, `path`, + `payload_type`, `format`, `ci`, `workloads` and at least one `check`. +3. Add checks for the key access paths you want guarded, using values read from + the actual file. +4. Run the Rust gate: + + ```sh + cargo test --release --test manifest_fixtures + ``` + + It fails loudly if a path, type, value or length does not match the file. +5. To exercise the fixture in the benchmark, include `"bench"` in `ci` and set + `bench_iters`; `make bench` (or `... lua_bench.lua manifest`) will pick it up. diff --git a/tests/fixtures/data/deep_nesting.json b/tests/fixtures/data/deep_nesting.json new file mode 100644 index 0000000..3534b2e --- /dev/null +++ b/tests/fixtures/data/deep_nesting.json @@ -0,0 +1 @@ +[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[42]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] diff --git a/tests/fixtures/data/wide_object.json b/tests/fixtures/data/wide_object.json new file mode 100644 index 0000000..5692b4e --- /dev/null +++ b/tests/fixtures/data/wide_object.json @@ -0,0 +1 @@ +{"k00000":0,"k00001":2,"k00002":4,"k00003":6,"k00004":8,"k00005":10,"k00006":12,"k00007":14,"k00008":16,"k00009":18,"k00010":20,"k00011":22,"k00012":24,"k00013":26,"k00014":28,"k00015":30,"k00016":32,"k00017":34,"k00018":36,"k00019":38,"k00020":40,"k00021":42,"k00022":44,"k00023":46,"k00024":48,"k00025":50,"k00026":52,"k00027":54,"k00028":56,"k00029":58,"k00030":60,"k00031":62,"k00032":64,"k00033":66,"k00034":68,"k00035":70,"k00036":72,"k00037":74,"k00038":76,"k00039":78,"k00040":80,"k00041":82,"k00042":84,"k00043":86,"k00044":88,"k00045":90,"k00046":92,"k00047":94,"k00048":96,"k00049":98,"k00050":100,"k00051":102,"k00052":104,"k00053":106,"k00054":108,"k00055":110,"k00056":112,"k00057":114,"k00058":116,"k00059":118,"k00060":120,"k00061":122,"k00062":124,"k00063":126,"k00064":128,"k00065":130,"k00066":132,"k00067":134,"k00068":136,"k00069":138,"k00070":140,"k00071":142,"k00072":144,"k00073":146,"k00074":148,"k00075":150,"k00076":152,"k00077":154,"k00078":156,"k00079":158,"k00080":160,"k00081":162,"k00082":164,"k00083":166,"k00084":168,"k00085":170,"k00086":172,"k00087":174,"k00088":176,"k00089":178,"k00090":180,"k00091":182,"k00092":184,"k00093":186,"k00094":188,"k00095":190,"k00096":192,"k00097":194,"k00098":196,"k00099":198,"k00100":200,"k00101":202,"k00102":204,"k00103":206,"k00104":208,"k00105":210,"k00106":212,"k00107":214,"k00108":216,"k00109":218,"k00110":220,"k00111":222,"k00112":224,"k00113":226,"k00114":228,"k00115":230,"k00116":232,"k00117":234,"k00118":236,"k00119":238,"k00120":240,"k00121":242,"k00122":244,"k00123":246,"k00124":248,"k00125":250,"k00126":252,"k00127":254,"k00128":256,"k00129":258,"k00130":260,"k00131":262,"k00132":264,"k00133":266,"k00134":268,"k00135":270,"k00136":272,"k00137":274,"k00138":276,"k00139":278,"k00140":280,"k00141":282,"k00142":284,"k00143":286,"k00144":288,"k00145":290,"k00146":292,"k00147":294,"k00148":296,"k00149":298,"k00150":300,"k00151":302,"k00152":304,"k00153":306,"k00154":308,"k00155":310,"k00156":312,"k00157":314,"k00158":316,"k00159":318,"k00160":320,"k00161":322,"k00162":324,"k00163":326,"k00164":328,"k00165":330,"k00166":332,"k00167":334,"k00168":336,"k00169":338,"k00170":340,"k00171":342,"k00172":344,"k00173":346,"k00174":348,"k00175":350,"k00176":352,"k00177":354,"k00178":356,"k00179":358,"k00180":360,"k00181":362,"k00182":364,"k00183":366,"k00184":368,"k00185":370,"k00186":372,"k00187":374,"k00188":376,"k00189":378,"k00190":380,"k00191":382,"k00192":384,"k00193":386,"k00194":388,"k00195":390,"k00196":392,"k00197":394,"k00198":396,"k00199":398,"k00200":400,"k00201":402,"k00202":404,"k00203":406,"k00204":408,"k00205":410,"k00206":412,"k00207":414,"k00208":416,"k00209":418,"k00210":420,"k00211":422,"k00212":424,"k00213":426,"k00214":428,"k00215":430,"k00216":432,"k00217":434,"k00218":436,"k00219":438,"k00220":440,"k00221":442,"k00222":444,"k00223":446,"k00224":448,"k00225":450,"k00226":452,"k00227":454,"k00228":456,"k00229":458,"k00230":460,"k00231":462,"k00232":464,"k00233":466,"k00234":468,"k00235":470,"k00236":472,"k00237":474,"k00238":476,"k00239":478,"k00240":480,"k00241":482,"k00242":484,"k00243":486,"k00244":488,"k00245":490,"k00246":492,"k00247":494,"k00248":496,"k00249":498,"k00250":500,"k00251":502,"k00252":504,"k00253":506,"k00254":508,"k00255":510} diff --git a/tests/fixtures/manifest.json b/tests/fixtures/manifest.json new file mode 100644 index 0000000..9de6989 --- /dev/null +++ b/tests/fixtures/manifest.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "fixtures": [ + { + "id": "rest_api_small", + "path": "benches/fixtures/small_api.json", + "source": "qjson bench fixture", + "payload_type": "rest_api", + "format": "json", + "size_bytes": 2115, + "structural_density": "medium", + "workloads": ["parse_access", "decode_access", "decode_encode", "modify_encode"], + "ci": ["pr", "scheduled", "bench"], + "bench_iters": 5000, + "checks": [ + { "path": "model", "type": "string", "value": "gpt-4" }, + { "path": "stream", "type": "bool", "value": false }, + { "path": "max_tokens", "type": "number", "value": 1024 }, + { "path": "messages", "type": "array", "len": 4 }, + { "path": "messages[0].role", "type": "string", "value": "system" }, + { "path": "metadata.tags", "type": "array", "len": 4 }, + { "path": "metadata.user_preferences.code_blocks", "type": "bool", "value": true } + ] + }, + { + "id": "rest_api_medium", + "path": "benches/fixtures/medium_resp.json", + "source": "qjson bench fixture", + "payload_type": "rest_api", + "format": "json", + "size_bytes": 60368, + "structural_density": "low", + "workloads": ["parse_access", "decode_access", "modify_encode"], + "ci": ["scheduled", "bench"], + "bench_iters": 500, + "checks": [ + { "path": "id", "type": "string", "value": "resp_2026051599999" }, + { "path": "object", "type": "string", "value": "chat.completion" }, + { "path": "created", "type": "number", "value": 1747353600 }, + { "path": "choices", "type": "array", "len": 1 }, + { "path": "choices[0].finish_reason", "type": "string", "value": "stop" }, + { "path": "usage.total_tokens", "type": "number", "value": 1750 } + ] + }, + { + "id": "unicode_heavy_twitter", + "path": "tests/vendor/simdjson/jsonexamples/twitter.json", + "source": "simdjson jsonexamples", + "payload_type": "unicode_heavy", + "format": "json", + "size_bytes": 631515, + "structural_density": "high", + "workloads": ["parse_access", "decode_access"], + "ci": ["scheduled", "bench"], + "bench_iters": 200, + "checks": [ + { "path": "statuses", "type": "array", "len": 100 }, + { "path": "statuses[0].lang", "type": "string", "value": "ja" }, + { "path": "statuses[0].user.screen_name", "type": "string", "value": "ayuu0123" }, + { "path": "search_metadata.count", "type": "number", "value": 100 } + ] + }, + { + "id": "wide_object", + "path": "tests/fixtures/data/wide_object.json", + "source": "qjson generated fixture (256 keys k00000..k00255, value i*2)", + "payload_type": "wide_object", + "format": "json", + "size_bytes": 3275, + "structural_density": "high", + "workloads": ["parse_access"], + "ci": ["pr", "bench"], + "bench_iters": 5000, + "checks": [ + { "path": "", "type": "object", "len": 256 }, + { "path": "k00000", "type": "number", "value": 0 }, + { "path": "k00128", "type": "number", "value": 256 }, + { "path": "k00255", "type": "number", "value": 510 } + ] + }, + { + "id": "deep_nesting", + "path": "tests/fixtures/data/deep_nesting.json", + "source": "qjson generated fixture (256 nested arrays, leaf 42)", + "payload_type": "deep_nesting", + "format": "json", + "size_bytes": 515, + "structural_density": "high", + "workloads": ["parse_access"], + "ci": ["pr"], + "checks": [ + { "path": "", "type": "array", "len": 1 }, + { "path": "[0]", "type": "array", "len": 1 }, + { "path": "[0][0][0][0][0]", "type": "array", "len": 1 } + ] + }, + { + "id": "ndjson_amazon", + "path": "tests/vendor/simdjson/jsonexamples/amazon_cellphones.ndjson", + "source": "simdjson jsonexamples", + "payload_type": "ndjson", + "format": "ndjson", + "size_bytes": 277673, + "structural_density": "medium", + "workloads": ["parse_access"], + "ci": ["scheduled", "bench"], + "bench_iters": 100, + "checks": [ + { "record": 0, "path": "", "type": "array", "len": 9 }, + { "record": 0, "path": "[0]", "type": "string", "value": "asin" }, + { "record": 1, "path": "[0]", "type": "string", "value": "B0000SX2UC" }, + { "record": 1, "path": "[1]", "type": "string", "value": "Nokia" }, + { "record": 1, "path": "[7]", "type": "number", "value": 14 }, + { "record": 2, "path": "[1]", "type": "string", "value": "Motorola" }, + { "record": 2, "path": "[8]", "type": "string", "value": "$49.95" } + ] + } + ] +} diff --git a/tests/manifest_fixtures.rs b/tests/manifest_fixtures.rs new file mode 100644 index 0000000..567d7b8 --- /dev/null +++ b/tests/manifest_fixtures.rs @@ -0,0 +1,192 @@ +//! Real-world fixture manifest correctness gate (issue #139). +//! +//! Reads `tests/fixtures/manifest.json`, the single source of truth shared with +//! the Lua benchmark harness, and validates every declared access path against +//! the fixture file. Each check is a `type` + optional `value` + optional `len` +//! triple resolved through the public FFI getters, exactly as a consumer would. + +use std::collections::BTreeSet; +use std::fs; +use std::os::raw::{c_char, c_int}; +use std::path::PathBuf; +use std::ptr; + +use qjson::error::qjson_err; +use qjson::ffi::*; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Manifest { + version: u32, + fixtures: Vec, +} + +#[derive(Deserialize)] +struct Fixture { + id: String, + path: String, + payload_type: String, + format: String, + #[serde(default)] + checks: Vec, +} + +#[derive(Deserialize)] +struct Check { + #[serde(default)] + record: Option, + path: String, + #[serde(rename = "type")] + ty: String, + #[serde(default)] + value: Option, + #[serde(default)] + len: Option, +} + +const OK: c_int = qjson_err::QJSON_OK as c_int; + +fn type_tag(name: &str) -> c_int { + match name { + "null" => 0, + "bool" => 1, + "number" => 2, + "string" => 3, + "array" => 4, + "object" => 5, + other => panic!("unknown check type {other:?}"), + } +} + +unsafe fn parse(buf: &[u8]) -> *mut qjson_doc { + let mut err = qjson_error::default(); + let d = qjson_parse(buf.as_ptr(), buf.len(), &mut err); + assert!(!d.is_null(), "parse failed (code={})", err.code); + d +} + +unsafe fn run_check(doc: *mut qjson_doc, ctx: &str, c: &Check) { + let p = c.path.as_ptr() as *const c_char; + let pl = c.path.len(); + + let mut ty: c_int = -1; + let rc = qjson_typeof(doc, p, pl, &mut ty); + assert_eq!(rc, OK, "{ctx}: typeof rc={rc}"); + assert_eq!(ty, type_tag(&c.ty), "{ctx}: type mismatch"); + + match c.ty.as_str() { + "string" => { + let mut sp: *const u8 = ptr::null(); + let mut sl: usize = 0; + let rc = qjson_get_str(doc, p, pl, &mut sp, &mut sl); + assert_eq!(rc, OK, "{ctx}: get_str rc={rc}"); + let s = std::slice::from_raw_parts(sp, sl); + if let Some(v) = &c.value { + let want = v.as_str().expect("string check `value` must be a string"); + assert_eq!(s, want.as_bytes(), "{ctx}: string value mismatch"); + } + if let Some(l) = c.len { + assert_eq!(sl as u64, l, "{ctx}: string byte-len mismatch"); + } + } + "number" => { + if let Some(v) = &c.value { + let mut out: f64 = 0.0; + let rc = qjson_get_f64(doc, p, pl, &mut out); + assert_eq!(rc, OK, "{ctx}: get_f64 rc={rc}"); + let want = v.as_f64().expect("number check `value` must be a number"); + assert_eq!(out, want, "{ctx}: number value mismatch"); + } + } + "bool" => { + if let Some(v) = &c.value { + let mut out: c_int = -1; + let rc = qjson_get_bool(doc, p, pl, &mut out); + assert_eq!(rc, OK, "{ctx}: get_bool rc={rc}"); + let want = v.as_bool().expect("bool check `value` must be a bool"); + assert_eq!(out != 0, want, "{ctx}: bool value mismatch"); + } + } + "null" => { + let mut out: c_int = -1; + let rc = qjson_is_null(doc, p, pl, &mut out); + assert_eq!(rc, OK, "{ctx}: is_null rc={rc}"); + assert_eq!(out, 1, "{ctx}: expected null"); + } + "object" | "array" => { + if let Some(l) = c.len { + let mut out: usize = 0; + let rc = qjson_len(doc, p, pl, &mut out); + assert_eq!(rc, OK, "{ctx}: len rc={rc}"); + assert_eq!(out as u64, l, "{ctx}: container len mismatch"); + } + } + _ => unreachable!(), + } +} + +/// Split an NDJSON buffer into non-empty records, trimming a trailing `\r`. +fn ndjson_records(buf: &[u8]) -> Vec<&[u8]> { + buf.split(|&b| b == b'\n') + .map(|l| { + if l.last() == Some(&b'\r') { + &l[..l.len() - 1] + } else { + l + } + }) + .filter(|l| !l.is_empty()) + .collect() +} + +#[test] +fn manifest_fixtures_validate() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let raw = fs::read(root.join("tests/fixtures/manifest.json")) + .unwrap_or_else(|e| panic!("read manifest: {e}")); + let manifest: Manifest = serde_json::from_slice(&raw).expect("parse manifest.json"); + + assert_eq!(manifest.version, 1, "unexpected manifest version"); + assert!( + manifest.fixtures.len() >= 5, + "manifest must declare at least 5 fixtures" + ); + + let payload_types: BTreeSet<&str> = manifest + .fixtures + .iter() + .map(|f| f.payload_type.as_str()) + .collect(); + assert!( + payload_types.len() >= 5, + "manifest must cover at least 5 payload types, got {payload_types:?}" + ); + + for f in &manifest.fixtures { + let buf = fs::read(root.join(&f.path)) + .unwrap_or_else(|e| panic!("read fixture {}: {e}", f.path)); + + if f.format == "ndjson" { + let records = ndjson_records(&buf); + for c in &f.checks { + let rec = c.record.unwrap_or(0); + let line = records + .get(rec) + .unwrap_or_else(|| panic!("{}: record {rec} out of range", f.id)); + unsafe { + let doc = parse(line); + run_check(doc, &format!("{}#rec{rec}:{}", f.id, c.path), c); + qjson_free(doc); + } + } + } else { + unsafe { + let doc = parse(&buf); + for c in &f.checks { + run_check(doc, &format!("{}:{}", f.id, c.path), c); + } + qjson_free(doc); + } + } + } +}