Skip to content

Commit d2f6581

Browse files
authored
test(config,range): pin validation boundaries in dedicated module (#83) (#85)
1 parent c7f9b05 commit d2f6581

2 files changed

Lines changed: 297 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
### Tests
10+
11+
- New `test/config_boundary_test.gleam` module pins the validation
12+
contracts of every `with_*` config builder (`with_runs`,
13+
`with_max_size`, `with_shrink_limit`, `with_max_edges`,
14+
`with_regression_file`) and every `range` constructor
15+
(`singleton`, `constant`, `linear`, `linear_from`,
16+
`exponential`) for negative, zero, smallest-valid, very-large,
17+
inverted-bounds, and singleton-pair inputs. Centralising these
18+
cases in one module makes it obvious which builder × boundary
19+
pairs are pinned and prevents the per-builder leakage pattern
20+
that produced #35 and #52. (#83)
21+
922
## [0.7.0] - 2026-05-11
1023

1124
### Documentation

test/config_boundary_test.gleam

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

Comments
 (0)