|
| 1 | +//// Centralised boundary tests for the validation interfaces of the |
| 2 | +//// `metamon/config` builders and the `metamon/generator/range` |
| 3 | +//// constructors. (#83) |
| 4 | +//// |
| 5 | +//// `with_*` config builders return `Result(Config, ConfigError)`, so |
| 6 | +//// boundary cases (zero, negative, very large) are pinned by inspecting |
| 7 | +//// the returned `Result`. `range.constant` / `linear` / `exponential` / |
| 8 | +//// `linear_from` panic on inverted bounds — those cases are pinned via |
| 9 | +//// the same `metamon_ffi:capture_panic` helper that `range_test.gleam` |
| 10 | +//// already uses for `linear_from`. |
| 11 | + |
| 12 | +import gleam/option.{None} |
| 13 | +import gleam/string |
| 14 | +import gleeunit/should |
| 15 | +import metamon/config |
| 16 | +import metamon/generator/range |
| 17 | + |
| 18 | +// ---------- shared panic-capture helper ---------- |
| 19 | + |
| 20 | +pub type PanicOutcome { |
| 21 | + PanickedWith(message: String) |
| 22 | + DidNotPanic |
| 23 | +} |
| 24 | + |
| 25 | +@external(erlang, "metamon_ffi", "capture_panic") |
| 26 | +@external(javascript, "./metamon_ffi.mjs", "capture_panic") |
| 27 | +fn capture_panic_raw(thunk: fn() -> Nil) -> #(Bool, String) |
| 28 | + |
| 29 | +fn capture_panic(thunk: fn() -> Nil) -> PanicOutcome { |
| 30 | + let #(panicked, message) = capture_panic_raw(thunk) |
| 31 | + case panicked { |
| 32 | + True -> PanickedWith(message: message) |
| 33 | + False -> DidNotPanic |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +fn assert_panic_contains(outcome: PanicOutcome, fragment: String) -> Nil { |
| 38 | + case outcome { |
| 39 | + PanickedWith(message) -> should.be_true(string.contains(message, fragment)) |
| 40 | + DidNotPanic -> should.fail() |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +// ---------- with_runs ---------- |
| 45 | + |
| 46 | +pub fn with_runs_negative_is_rejected_test() { |
| 47 | + let c = config.default_config() |
| 48 | + case config.with_runs(c, -1) { |
| 49 | + Error(config.NonPositive("runs", -1)) -> Nil |
| 50 | + _ -> should.fail() |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +pub fn with_runs_zero_is_rejected_test() { |
| 55 | + let c = config.default_config() |
| 56 | + case config.with_runs(c, 0) { |
| 57 | + Error(config.NonPositive("runs", 0)) -> Nil |
| 58 | + _ -> should.fail() |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +pub fn with_runs_one_is_smallest_valid_test() { |
| 63 | + let c = config.default_config() |
| 64 | + let assert Ok(c2) = config.with_runs(c, 1) |
| 65 | + should.equal(config.runs(c2), 1) |
| 66 | +} |
| 67 | + |
| 68 | +pub fn with_runs_very_large_is_accepted_test() { |
| 69 | + // Pin: the validator rejects only n <= 0; arbitrarily large n is |
| 70 | + // accepted and stored verbatim. Runtime overflow is not the |
| 71 | + // builder's responsibility. |
| 72 | + let c = config.default_config() |
| 73 | + let assert Ok(c2) = config.with_runs(c, 1_000_000_000) |
| 74 | + should.equal(config.runs(c2), 1_000_000_000) |
| 75 | +} |
| 76 | + |
| 77 | +// ---------- with_max_size ---------- |
| 78 | + |
| 79 | +pub fn with_max_size_negative_is_rejected_test() { |
| 80 | + let c = config.default_config() |
| 81 | + case config.with_max_size(c, -1) { |
| 82 | + Error(config.NonPositive("max_size", -1)) -> Nil |
| 83 | + _ -> should.fail() |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +pub fn with_max_size_zero_is_rejected_test() { |
| 88 | + let c = config.default_config() |
| 89 | + case config.with_max_size(c, 0) { |
| 90 | + Error(config.NonPositive("max_size", 0)) -> Nil |
| 91 | + _ -> should.fail() |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +pub fn with_max_size_one_is_smallest_valid_test() { |
| 96 | + let c = config.default_config() |
| 97 | + let assert Ok(c2) = config.with_max_size(c, 1) |
| 98 | + should.equal(config.max_size(c2), 1) |
| 99 | +} |
| 100 | + |
| 101 | +pub fn with_max_size_very_large_is_accepted_test() { |
| 102 | + let c = config.default_config() |
| 103 | + let assert Ok(c2) = config.with_max_size(c, 1_000_000_000) |
| 104 | + should.equal(config.max_size(c2), 1_000_000_000) |
| 105 | +} |
| 106 | + |
| 107 | +// ---------- with_shrink_limit ---------- |
| 108 | + |
| 109 | +pub fn with_shrink_limit_negative_is_rejected_test() { |
| 110 | + let c = config.default_config() |
| 111 | + case config.with_shrink_limit(c, -1) { |
| 112 | + Error(config.NonPositive("shrink_limit", -1)) -> Nil |
| 113 | + _ -> should.fail() |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +pub fn with_shrink_limit_zero_is_rejected_test() { |
| 118 | + // Pin: 0 is rejected (NonPositive). The issue #83 raises the |
| 119 | + // alternative "0 = no shrinking" semantics, but the implementation |
| 120 | + // chose the stricter Result contract — disabling shrinking is not |
| 121 | + // a documented use case, so callers should pass a positive bound. |
| 122 | + let c = config.default_config() |
| 123 | + case config.with_shrink_limit(c, 0) { |
| 124 | + Error(config.NonPositive("shrink_limit", 0)) -> Nil |
| 125 | + _ -> should.fail() |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +pub fn with_shrink_limit_one_is_smallest_valid_test() { |
| 130 | + let c = config.default_config() |
| 131 | + let assert Ok(c2) = config.with_shrink_limit(c, 1) |
| 132 | + should.equal(config.shrink_limit(c2), 1) |
| 133 | +} |
| 134 | + |
| 135 | +pub fn with_shrink_limit_very_large_is_accepted_test() { |
| 136 | + let c = config.default_config() |
| 137 | + let assert Ok(c2) = config.with_shrink_limit(c, 1_000_000_000) |
| 138 | + should.equal(config.shrink_limit(c2), 1_000_000_000) |
| 139 | +} |
| 140 | + |
| 141 | +// ---------- with_max_edges ---------- |
| 142 | + |
| 143 | +pub fn with_max_edges_negative_is_rejected_test() { |
| 144 | + let c = config.default_config() |
| 145 | + case config.with_max_edges(c, -1) { |
| 146 | + Error(config.NonPositive("max_edges", -1)) -> Nil |
| 147 | + _ -> should.fail() |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +pub fn with_max_edges_zero_is_rejected_test() { |
| 152 | + // Pin: 0 is rejected. The issue raises "0 = no edges considered", |
| 153 | + // but the implementation forces a positive cap so the runner always |
| 154 | + // has at least one edge slot. |
| 155 | + let c = config.default_config() |
| 156 | + case config.with_max_edges(c, 0) { |
| 157 | + Error(config.NonPositive("max_edges", 0)) -> Nil |
| 158 | + _ -> should.fail() |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +pub fn with_max_edges_one_is_smallest_valid_test() { |
| 163 | + let c = config.default_config() |
| 164 | + let assert Ok(c2) = config.with_max_edges(c, 1) |
| 165 | + should.equal(config.max_edges(c2), 1) |
| 166 | +} |
| 167 | + |
| 168 | +pub fn with_max_edges_very_large_is_accepted_test() { |
| 169 | + let c = config.default_config() |
| 170 | + let assert Ok(c2) = config.with_max_edges(c, 1_000_000_000) |
| 171 | + should.equal(config.max_edges(c2), 1_000_000_000) |
| 172 | +} |
| 173 | + |
| 174 | +// ---------- with_regression_file ---------- |
| 175 | + |
| 176 | +pub fn with_regression_file_empty_is_rejected_test() { |
| 177 | + let c = config.default_config() |
| 178 | + case config.with_regression_file(c, "") { |
| 179 | + Error(config.InvalidPath("", _)) -> Nil |
| 180 | + _ -> should.fail() |
| 181 | + } |
| 182 | +} |
| 183 | + |
| 184 | +pub fn with_regression_file_default_is_none_test() { |
| 185 | + let c = config.default_config() |
| 186 | + should.equal(config.regression_file(c), None) |
| 187 | +} |
| 188 | + |
| 189 | +// ---------- range.singleton ---------- |
| 190 | + |
| 191 | +pub fn range_singleton_origin_equals_value_test() { |
| 192 | + should.equal(range.origin(range.singleton(7)), 7) |
| 193 | +} |
| 194 | + |
| 195 | +pub fn range_singleton_min_int_test() { |
| 196 | + // Pin: arbitrarily small / negative values are accepted verbatim. |
| 197 | + let r = range.singleton(-1_000_000_000) |
| 198 | + should.equal(range.origin(r), -1_000_000_000) |
| 199 | + should.equal(range.bounds(r, 0, 99), #(-1_000_000_000, -1_000_000_000)) |
| 200 | +} |
| 201 | + |
| 202 | +// ---------- range.constant ---------- |
| 203 | + |
| 204 | +pub fn range_constant_singleton_pair_is_constant_test() { |
| 205 | + // Pin: a (n, n) pair behaves as a singleton at every size. |
| 206 | + let r = range.constant(5, 5) |
| 207 | + should.equal(range.origin(r), 5) |
| 208 | + should.equal(range.bounds(r, 0, 99), #(5, 5)) |
| 209 | + should.equal(range.bounds(r, 99, 99), #(5, 5)) |
| 210 | +} |
| 211 | + |
| 212 | +pub fn range_constant_inverted_bounds_panic_test() { |
| 213 | + // Pin: inverted bounds panic at construction. The implementation |
| 214 | + // chose "fail visibly" over "swap" or "Error" because a swapped |
| 215 | + // pair almost always indicates a caller bug, and silently |
| 216 | + // normalising would mask the misuse. |
| 217 | + let outcome = |
| 218 | + capture_panic(fn() { |
| 219 | + let _ = range.constant(10, 0) |
| 220 | + Nil |
| 221 | + }) |
| 222 | + assert_panic_contains(outcome, "lo must be <= hi") |
| 223 | +} |
| 224 | + |
| 225 | +pub fn range_constant_extreme_bounds_no_overflow_test() { |
| 226 | + // Pin: huge bounds compute without crash. `bounds` returns the |
| 227 | + // verbatim pair for `Const` regardless of size. |
| 228 | + let r = range.constant(-1_000_000_000, 1_000_000_000) |
| 229 | + should.equal(range.bounds(r, 0, 99), #(-1_000_000_000, 1_000_000_000)) |
| 230 | + should.equal(range.bounds(r, 99, 99), #(-1_000_000_000, 1_000_000_000)) |
| 231 | +} |
| 232 | + |
| 233 | +// ---------- range.linear ---------- |
| 234 | + |
| 235 | +pub fn range_linear_inverted_bounds_panic_test() { |
| 236 | + let outcome = |
| 237 | + capture_panic(fn() { |
| 238 | + let _ = range.linear(10, 0) |
| 239 | + Nil |
| 240 | + }) |
| 241 | + assert_panic_contains(outcome, "lo must be <= hi") |
| 242 | +} |
| 243 | + |
| 244 | +pub fn range_linear_extreme_bounds_no_overflow_at_size_zero_test() { |
| 245 | + // Pin: at size = 0 the bounds collapse to the chosen origin (0 |
| 246 | + // when 0 is inside the interval) without arithmetic crash. |
| 247 | + let r = range.linear(-1_000_000_000, 1_000_000_000) |
| 248 | + should.equal(range.origin(r), 0) |
| 249 | + should.equal(range.bounds(r, 0, 99), #(0, 0)) |
| 250 | +} |
| 251 | + |
| 252 | +// ---------- range.linear_from ---------- |
| 253 | + |
| 254 | +pub fn range_linear_from_inverted_bounds_panic_test() { |
| 255 | + // The existing range_test.gleam pins origin-out-of-range; this |
| 256 | + // pins the lo > hi sibling case (which is checked first and so |
| 257 | + // produces a different message). |
| 258 | + let outcome = |
| 259 | + capture_panic(fn() { |
| 260 | + let _ = range.linear_from(5, 10, 0) |
| 261 | + Nil |
| 262 | + }) |
| 263 | + assert_panic_contains(outcome, "lo must be <= hi") |
| 264 | +} |
| 265 | + |
| 266 | +// ---------- range.exponential ---------- |
| 267 | + |
| 268 | +pub fn range_exponential_inverted_bounds_panic_test() { |
| 269 | + let outcome = |
| 270 | + capture_panic(fn() { |
| 271 | + let _ = range.exponential(10, 0) |
| 272 | + Nil |
| 273 | + }) |
| 274 | + assert_panic_contains(outcome, "lo must be <= hi") |
| 275 | +} |
| 276 | + |
| 277 | +pub fn range_exponential_singleton_pair_is_constant_test() { |
| 278 | + // Pin: a (n, n) pair behaves as a singleton at every size — there |
| 279 | + // is nothing to scale exponentially when bounds collide. |
| 280 | + let r = range.exponential(5, 5) |
| 281 | + should.equal(range.bounds(r, 0, 99), #(5, 5)) |
| 282 | + should.equal(range.bounds(r, 50, 99), #(5, 5)) |
| 283 | + should.equal(range.bounds(r, 99, 99), #(5, 5)) |
| 284 | +} |
0 commit comments