|
| 1 | +//// Schema-contract tests for the JSON failure-report format. (#82) |
| 2 | +//// |
| 3 | +//// `README.md` §JSON output declares the schema is stable and lists 17 |
| 4 | +//// top-level keys: `mr_name`, `test_name`, `config_seed`, `runs_done`, |
| 5 | +//// `runs_total`, `shrinks_done`, `shrink_capped`, `source`, |
| 6 | +//// `morph_mode`, `relation`, `source_input`, `followup_input`, |
| 7 | +//// `source_output`, `followup_output`, `annotations`, `footnotes`, |
| 8 | +//// `coverage`. Downstream tooling (`jq` pipelines, GitHub Actions |
| 9 | +//// annotations, LLM analysis steps) depends on every one of those |
| 10 | +//// keys being present in every failure shape. |
| 11 | +//// |
| 12 | +//// `test/json_output_test.gleam` covers basic emission for `forall` and |
| 13 | +//// `forall_morph`, but only spot-checks five of the seventeen keys. |
| 14 | +//// This module triggers each public failure shape that emits JSON |
| 15 | +//// (`forall`, `forall_morph`, `forall_round_trip`) and asserts that |
| 16 | +//// **every** README-listed key appears, plus the documented value-type |
| 17 | +//// for the keys whose type is part of the contract (`config_seed`, |
| 18 | +//// `runs_done`, `runs_total`, `shrinks_done` → Int; `shrink_capped` → |
| 19 | +//// Bool; `annotations`, `footnotes` → Array; `coverage` → Object or |
| 20 | +//// null). |
| 21 | +//// |
| 22 | +//// Stateful failures (`metamon/stateful.assert_passed`) panic with |
| 23 | +//// their own text-only header and do not flow through the JSON |
| 24 | +//// formatter, so they are intentionally out of scope for this contract |
| 25 | +//// — the README §JSON output section similarly says nothing about a |
| 26 | +//// stateful JSON shape. |
| 27 | + |
| 28 | +import gleam/list |
| 29 | +import gleam/string |
| 30 | +import gleeunit/should |
| 31 | +import metamon |
| 32 | +import metamon/config |
| 33 | +import metamon/generator |
| 34 | +import metamon/generator/range |
| 35 | +import metamon/relation |
| 36 | +import metamon/transform |
| 37 | + |
| 38 | +// ---------- panic capture ---------- |
| 39 | + |
| 40 | +@external(erlang, "metamon_ffi", "capture_panic") |
| 41 | +@external(javascript, "./metamon_ffi.mjs", "capture_panic") |
| 42 | +fn capture_panic(thunk: fn() -> Nil) -> #(Bool, String) |
| 43 | + |
| 44 | +fn json_from_failure(thunk: fn() -> Nil) -> String { |
| 45 | + let #(panicked, message) = capture_panic(thunk) |
| 46 | + should.equal(panicked, True) |
| 47 | + let trimmed = string.trim(message) |
| 48 | + // Sanity: the failure path produced a JSON object, not the text |
| 49 | + // header. If this fires, a future runner refactor has stopped |
| 50 | + // routing failures through the JSON renderer for this shape. |
| 51 | + should.be_true(string.starts_with(trimmed, "{")) |
| 52 | + should.be_true(string.ends_with(trimmed, "}")) |
| 53 | + trimmed |
| 54 | +} |
| 55 | + |
| 56 | +// ---------- failure triggers (one per public JSON-emitting shape) ---------- |
| 57 | + |
| 58 | +fn json_for_forall_failure() -> String { |
| 59 | + let cfg = |
| 60 | + metamon.default_config() |
| 61 | + |> metamon.with_output_format(config.Json) |
| 62 | + json_from_failure(fn() { |
| 63 | + metamon.forall_with(cfg, generator.int(range.constant(0, 10)), fn(_n) { |
| 64 | + False |
| 65 | + }) |
| 66 | + }) |
| 67 | +} |
| 68 | + |
| 69 | +fn json_for_forall_morph_failure() -> String { |
| 70 | + let increment = transform.new("+1", fn(n: Int) { n + 1 }) |
| 71 | + let bad_mr = |
| 72 | + metamon.mr( |
| 73 | + name: "false_invariant", |
| 74 | + transform: increment, |
| 75 | + relation: relation.equal(), |
| 76 | + ) |
| 77 | + let cfg = |
| 78 | + metamon.default_config() |
| 79 | + |> metamon.with_output_format(config.Json) |
| 80 | + json_from_failure(fn() { |
| 81 | + metamon.forall_morph_with( |
| 82 | + cfg, |
| 83 | + generator.int(range.constant(1, 10)), |
| 84 | + bad_mr, |
| 85 | + fn(n) { n }, |
| 86 | + ) |
| 87 | + }) |
| 88 | +} |
| 89 | + |
| 90 | +fn json_for_forall_round_trip_failure() -> String { |
| 91 | + let cfg = |
| 92 | + metamon.default_config() |
| 93 | + |> metamon.with_output_format(config.Json) |
| 94 | + json_from_failure(fn() { |
| 95 | + // A trivially broken codec: encoder and decoder do not invert. |
| 96 | + metamon.forall_round_trip_with( |
| 97 | + cfg: cfg, |
| 98 | + gen: generator.int(range.constant(1, 10)), |
| 99 | + name: "broken_codec", |
| 100 | + encode: fn(n: Int) { n + 1 }, |
| 101 | + decode: fn(n: Int) { Ok(n) }, |
| 102 | + ) |
| 103 | + }) |
| 104 | +} |
| 105 | + |
| 106 | +// ---------- contract assertions ---------- |
| 107 | + |
| 108 | +const required_keys: List(String) = [ |
| 109 | + "mr_name", "test_name", "config_seed", "runs_done", "runs_total", |
| 110 | + "shrinks_done", "shrink_capped", "source", "morph_mode", "relation", |
| 111 | + "source_input", "followup_input", "source_output", "followup_output", |
| 112 | + "annotations", "footnotes", "coverage", |
| 113 | +] |
| 114 | + |
| 115 | +fn assert_key_present(json: String, key: String) -> Nil { |
| 116 | + // Schema keys appear as `"<key>":` in the output. Substring is |
| 117 | + // sufficient to assert presence; a future schema rename would fail |
| 118 | + // the test and force a coordinated README + impl + test update. |
| 119 | + let needle = "\"" <> key <> "\":" |
| 120 | + case string.contains(json, needle) { |
| 121 | + True -> Nil |
| 122 | + False -> { |
| 123 | + // Surface the missing key in the gleeunit failure output so a |
| 124 | + // schema drift is immediately diagnosable. |
| 125 | + let _ = key |
| 126 | + should.fail() |
| 127 | + } |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +fn assert_all_required_keys_present(json: String) -> Nil { |
| 132 | + list.each(required_keys, fn(key) { assert_key_present(json, key) }) |
| 133 | +} |
| 134 | + |
| 135 | +fn assert_int_typed(json: String, key: String) -> Nil { |
| 136 | + // An Int value renders as `"<key>":<digit>` (with a possible |
| 137 | + // leading `-`). A non-Int would render as `"key":"..."` (string), |
| 138 | + // `"key":[...]` (array), `"key":{...}` (object), `"key":true|false` |
| 139 | + // (bool), or `"key":null`. Asserting the next character after the |
| 140 | + // colon is `-` or a digit is the cheapest contract for "still Int". |
| 141 | + let needle = "\"" <> key <> "\":" |
| 142 | + let assert Ok(#(_, after)) = string.split_once(json, on: needle) |
| 143 | + let first_char = string.slice(after, at_index: 0, length: 1) |
| 144 | + let valid = |
| 145 | + first_char == "-" |
| 146 | + || first_char == "0" |
| 147 | + || first_char == "1" |
| 148 | + || first_char == "2" |
| 149 | + || first_char == "3" |
| 150 | + || first_char == "4" |
| 151 | + || first_char == "5" |
| 152 | + || first_char == "6" |
| 153 | + || first_char == "7" |
| 154 | + || first_char == "8" |
| 155 | + || first_char == "9" |
| 156 | + should.be_true(valid) |
| 157 | +} |
| 158 | + |
| 159 | +fn assert_bool_typed(json: String, key: String) -> Nil { |
| 160 | + let needle_true = "\"" <> key <> "\":true" |
| 161 | + let needle_false = "\"" <> key <> "\":false" |
| 162 | + should.be_true( |
| 163 | + string.contains(json, needle_true) || string.contains(json, needle_false), |
| 164 | + ) |
| 165 | +} |
| 166 | + |
| 167 | +fn assert_array_typed(json: String, key: String) -> Nil { |
| 168 | + let needle = "\"" <> key <> "\":[" |
| 169 | + should.be_true(string.contains(json, needle)) |
| 170 | +} |
| 171 | + |
| 172 | +fn assert_object_or_null_typed(json: String, key: String) -> Nil { |
| 173 | + // `coverage` is `Object` when a coverage snapshot exists and `null` |
| 174 | + // when none was recorded. Both are part of the documented contract. |
| 175 | + let needle_obj = "\"" <> key <> "\":{" |
| 176 | + let needle_null = "\"" <> key <> "\":null" |
| 177 | + should.be_true( |
| 178 | + string.contains(json, needle_obj) || string.contains(json, needle_null), |
| 179 | + ) |
| 180 | +} |
| 181 | + |
| 182 | +fn assert_full_contract(json: String) -> Nil { |
| 183 | + assert_all_required_keys_present(json) |
| 184 | + assert_int_typed(json, "config_seed") |
| 185 | + assert_int_typed(json, "runs_done") |
| 186 | + assert_int_typed(json, "runs_total") |
| 187 | + assert_int_typed(json, "shrinks_done") |
| 188 | + assert_bool_typed(json, "shrink_capped") |
| 189 | + assert_array_typed(json, "annotations") |
| 190 | + assert_array_typed(json, "footnotes") |
| 191 | + assert_object_or_null_typed(json, "coverage") |
| 192 | +} |
| 193 | + |
| 194 | +// ---------- schema contract per failure shape ---------- |
| 195 | + |
| 196 | +pub fn json_schema_contract_forall_test() { |
| 197 | + assert_full_contract(json_for_forall_failure()) |
| 198 | +} |
| 199 | + |
| 200 | +pub fn json_schema_contract_forall_morph_test() { |
| 201 | + assert_full_contract(json_for_forall_morph_failure()) |
| 202 | +} |
| 203 | + |
| 204 | +pub fn json_schema_contract_forall_round_trip_test() { |
| 205 | + assert_full_contract(json_for_forall_round_trip_failure()) |
| 206 | +} |
| 207 | + |
| 208 | +// ---------- value-shape spot checks ---------- |
| 209 | + |
| 210 | +pub fn json_source_is_object_with_kind_test() { |
| 211 | + // `source` is an object discriminating between random and edge |
| 212 | + // sources via a `kind` field. Pin both the wrapper shape and the |
| 213 | + // discriminator presence so a future flat / string encoding fails |
| 214 | + // loudly. Each failure shape is exercised so the contract can't |
| 215 | + // hold in one shape but drift in another. |
| 216 | + let json = json_for_forall_failure() |
| 217 | + should.be_true(string.contains(json, "\"source\":{")) |
| 218 | + should.be_true(string.contains(json, "\"kind\":")) |
| 219 | +} |
| 220 | + |
| 221 | +pub fn json_morph_mode_is_plain_string_for_forall_test() { |
| 222 | + // For `forall` (no MR), `morph_mode` renders as the literal string |
| 223 | + // "plain", not as an object. Pin the discriminator value so the |
| 224 | + // README's "morph_mode" key is unambiguous about the no-MR case. |
| 225 | + let json = json_for_forall_failure() |
| 226 | + should.be_true(string.contains(json, "\"morph_mode\":\"plain\"")) |
| 227 | +} |
| 228 | + |
| 229 | +pub fn json_morph_mode_is_object_for_forall_morph_test() { |
| 230 | + // For `forall_morph`, `morph_mode` is an object with `kind` and the |
| 231 | + // transform name(s). The plain-vs-equivariant discrimination lives |
| 232 | + // inside this object, so downstream consumers must be able to |
| 233 | + // detect both shapes — the contract is "string OR object", not |
| 234 | + // "always string". |
| 235 | + let json = json_for_forall_morph_failure() |
| 236 | + should.be_true(string.contains(json, "\"morph_mode\":{")) |
| 237 | + should.be_true(string.contains(json, "\"kind\":\"plain\"")) |
| 238 | +} |
0 commit comments