// test_helpers.mbt — Shared test infrastructure for the moon-weblink suite.
//
// Everything here is deterministic. Property tests use a tiny fixed-seed
// PRNG (never the clock, never `random()`), and each test file seeds it with
// a distinct constant so the generated corpus is broad but perfectly
// reproducible on every platform and every run.
//
// Note: in MoonBit each `*_test.mbt` file is compiled as its own test unit,
// so helpers must be duplicated per file. The deterministic generators here
// are replicated (with a smaller surface) in the files that need them; the
// helpers that only depend on the public library API live in this file and
// are re-declared where used.
///|
/// Asserts that two strings are equal, printing both on failure.
fn assert_str_eq(actual : String, expected : String) -> Unit raise {
if actual != expected {
fail("strings differ.\n expected: \{expected}\n actual: \{actual}")
}
}
///|
/// Unwraps an Ok result, failing the test on error.
fn[T] unwrap_ok(r : Result[T, LinkError]) -> T raise {
match r {
Ok(v) => v
Err(e) => fail("expected Ok, got: \{e.to_display()}")
}
}
///|
/// Requires an `Err` with the exact `LinkErrorKind`, failing the test
/// otherwise.
fn[T] expect_err_kind(
r : Result[T, LinkError],
kind : LinkErrorKind,
) -> Unit raise {
match r {
Ok(_) => fail("expected error \{kind.to_string()}, got Ok")
Err(e) =>
if e.kind() == kind {
()
} else {
fail("expected kind \{kind.to_string()}, got \{e.to_display()}")
}
}
}
///|
/// Asserts that two `WebLink` arrays are semantically equivalent by comparing
/// their canonical serializations (deterministic by construction).
fn assert_links_eq(a : Array[WebLink], b : Array[WebLink]) -> Unit raise {
assert_str_eq(serialize_link_header(a), serialize_link_header(b))
}
// LinkError is a `suberror` and cannot be stored as a value, so the error
// introspection helpers below extract a plain value (or assert) instead of
// returning the error itself.
///|
/// The offset of the `Err`, failing the test on `Ok`.
fn[T] err_offset(r : Result[T, LinkError]) -> Int raise {
match r {
Ok(_) => fail("expected Err, got Ok")
Err(e) => e.offset()
}
}
///|
/// The kind of the `Err`, failing the test on `Ok`.
fn[T] err_kind(r : Result[T, LinkError]) -> LinkErrorKind raise {
match r {
Ok(_) => fail("expected Err, got Ok")
Err(e) => e.kind()
}
}
///|
/// The stage of the `Err`, failing the test on `Ok`.
fn[T] err_stage(r : Result[T, LinkError]) -> LinkErrorStage raise {
match r {
Ok(_) => fail("expected Err, got Ok")
Err(e) => e.stage()
}
}
///|
/// The context string of the `Err`, failing the test on `Ok`.
fn[T] err_context(r : Result[T, LinkError]) -> String raise {
match r {
Ok(_) => fail("expected Err, got Ok")
Err(e) => e.context()
}
}
///|
/// The display string of the `Err`, failing the test on `Ok`.
fn[T] err_display(r : Result[T, LinkError]) -> String raise {
match r {
Ok(_) => fail("expected Err, got Ok")
Err(e) => e.to_display()
}
}
// ---------------------------------------------------------------------------
// Deterministic PRNG
// ---------------------------------------------------------------------------
///|
/// A tiny linear-congruential PRNG. Same seed, same stream, always. All
/// values stay in [1, 2^31-1] so no overflow is possible on 64-bit `Int`.
priv struct Rng {
mut state : Int
}
///|
fn rng_new(seed : Int) -> Rng {
let s = (seed * 1013904223 + 12345) & 2147483647
{ state: if s == 0 { 1 } else { s } }
}
///|
fn Rng::next(self : Rng) -> Int {
let n = (self.state * 1103515245 + 12345) & 2147483647
self.state = n
n
}
///|
/// A random integer in `[lo, hi)`.
fn Rng::range(self : Rng, lo : Int, hi : Int) -> Int {
if hi <= lo {
lo
} else {
lo + self.next() % (hi - lo)
}
}
///|
fn Rng::flip(self : Rng) -> Bool {
self.next() % 2 == 0
}
// ---------------------------------------------------------------------------
// Random text / URI generators
// ---------------------------------------------------------------------------
///|
/// ASCII characters accepted by every predicate in this library in a token.
let token_chars : String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~"
///|
/// RFC 3986 unreserved characters only (safe in any URI position).
let uri_seg_chars : String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
///|
/// Printable ASCII plus space, quote and backslash. Every byte is safe in a
/// quoted-string and round-trips through the serializer.
let quoted_chars : String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
///|
/// A random string of `token_chars`, length in `[min_len, max_len]`.
fn Rng::token(self : Rng, min_len : Int, max_len : Int) -> String {
let len = self.range(min_len, max_len)
let chars = @utf8.encode(token_chars)
let sb = StringBuilder()
for _i = 0; _i < len; _i = _i + 1 {
let idx = self.range(0, chars.length())
sb.write_char(chars[idx].to_char())
}
sb.to_string()
}
///|
/// A random string of `uri_seg_chars` (unreserved only).
fn Rng::segment(self : Rng, min_len : Int, max_len : Int) -> String {
let len = self.range(min_len, max_len)
let chars = @utf8.encode(uri_seg_chars)
let sb = StringBuilder()
for _i = 0; _i < len; _i = _i + 1 {
let idx = self.range(0, chars.length())
sb.write_char(chars[idx].to_char())
}
sb.to_string()
}
///|
/// A random quoted-string payload (printable ASCII, space, `"`, `\`).
fn Rng::quoted(self : Rng) -> String {
let len = self.range(0, 14)
let chars = @utf8.encode(quoted_chars)
let sb = StringBuilder()
for _i = 0; _i < len; _i = _i + 1 {
let idx = self.range(0, chars.length())
sb.write_char(chars[idx].to_char())
}
sb.to_string()
}
///|
/// A random URI-reference that is strictly valid per RFC 3986.
fn Rng::uri(self : Rng) -> String {
let kind = self.range(0, 6)
let host = self.segment(1, 8)
let seg1 = self.segment(1, 10)
let seg2 = self.segment(1, 10)
match kind {
0 => "https://\{host}.example.com/\{seg1}"
1 => "http://\{host}:8080/\{seg1}?q=\{seg2}#frag"
2 => "/\{seg1}/\{seg2}"
3 => "\{seg1}/\{seg2}"
4 => "\{seg1}?v=\{seg2}"
5 => "#\{seg1}"
_ => "https://\{host}/"
}
}
///|
/// A random valid RFC 5646 language tag.
fn Rng::lang_tag(self : Rng) -> String {
let tags : Array[String] = [
"en", "en-US", "en-GB", "de", "de-AT", "fr", "fr-CA", "zh", "zh-Hans", "ja",
"pt-BR", "x-private", "sr-Latn-RS", "es-419",
]
tags[self.range(0, tags.length())]
}
///|
/// A random valid media type (RFC 6838, may carry parameters).
fn Rng::media_type(self : Rng) -> String {
let types : Array[String] = [
"text/html", "text/plain", "application/json", "image/png", "text/html; charset=utf-8",
"application/atom+xml; type=feed", "application/ld+json",
]
types[self.range(0, types.length())]
}
// ---------------------------------------------------------------------------
// Random model builder
// ---------------------------------------------------------------------------
///|
/// Well-known registered relation types, used as relation candidates.
let known_rels : Array[String] = [
"next", "prev", "canonical", "alternate", "author", "help", "license", "stylesheet",
"preconnect", "icon", "manifest", "edit", "self", "item",
]
///|
/// A random non-empty relation-type array (registered and extension forms).
fn Rng::relations(self : Rng) -> Array[RelationType] {
let n = self.range(1, 3)
let out = Array::new()
for _i = 0; _i < n; _i = _i + 1 {
if self.flip() {
out.push(Registered(known_rels[self.range(0, known_rels.length())]))
} else {
out.push(Extension("https://rel.example/" + self.segment(1, 8)))
}
}
out
}
///|
/// A random extension parameter list (0-3 entries, distinct names).
/// Names are generated from unreserved characters only so they never end in
/// `*` (a `name*` parameter is an RFC 8187 ext-value in the serializer, which
/// would not round-trip a plain token value).
fn Rng::extensions(self : Rng) -> Array[LinkParameter] {
let n = self.range(0, 3)
let out = Array::new()
for _i = 0; _i < n; _i = _i + 1 {
let name = "x-" + self.segment(1, 6)
let value = if self.flip() { self.token(1, 8) } else { self.quoted() }
out.push({ name, value: Some(value), quoted: false })
}
out
}
///|
/// A random `WebLink` model. All values are ASCII, so the serialized forms of
/// every supported format (header, linkset text, linkset JSON) round-trip.
fn Rng::web_link(self : Rng) -> WebLink {
{
target: self.uri(),
relations: self.relations(),
anchor: if self.flip() {
Some(self.uri())
} else {
None
},
hreflang: {
let out = Array::new()
let n = self.range(0, 3)
for _i = 0; _i < n; _i = _i + 1 {
out.push(self.lang_tag())
}
out
},
media: if self.flip() {
Some(self.quoted())
} else {
None
},
title: if self.flip() {
Some(self.quoted())
} else {
None
},
title_star: if self.flip() {
Some({
charset: "UTF-8",
language: if self.flip() {
Some(self.lang_tag())
} else {
None
},
value: self.quoted(),
})
} else {
None
},
media_type: if self.flip() {
Some(self.media_type())
} else {
None
},
extensions: self.extensions(),
}
}