Skip to content

Commit 9f4ff36

Browse files
bobzhangclaude
andcommitted
test(strconv): add QuickCheck property coverage
Property tests for string<->number conversion: - parse_double bitwise-inverts Double::to_string for doubles built from arbitrary 64-bit patterns (subnormals, extreme exponents, infinities), and for uniform doubles in [0, 1]. Negative zero is exempted from the bitwise requirement because the ECMAScript-style to_string renders it as "0" on every backend. - Full-range 64-bit and 32-bit integer round-trips through decimal strings, plus agreement between the integer parsers and parse_double / to_double on the same string (correct rounding at 19-20 digits). - Radix round-trip in every base 2..=36 for Int64 and UInt64. - Underscores between digits never change the parsed value. - A stray non-numeric character (including whitespace) is always rejected, prepended or appended. - Structured [-]digits[.digits][e[-]digits] strings never panic: they parse to a value whose own rendering reparses to the same bits, or fail with a range error only for genuinely huge exponents. Integer inputs are assembled from arbitrary bytes because the built-in Arbitrary instances are size-bounded and never reach the interesting regions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cff52fe commit 9f4ff36

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

strconv/moon.pkg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ import {
77
"moonbitlang/core/debug",
88
}
99

10+
import {
11+
"moonbitlang/core/quickcheck",
12+
} for "test"
13+
1014
warnings = "-deprecated"

strconv/quickcheck_test.mbt

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright 2026 International Digital Economy Academy
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// The built-in Arbitrary instances for the integer types are bounded by the
16+
// generator size, so they never reach interesting regions such as subnormal
17+
// doubles or 20-digit integers. Assembling values from arbitrary bytes gives
18+
// uniform coverage of the full 64-bit (or 32-bit) space instead.
19+
20+
///|
21+
fn u64_of_bytes(
22+
q : ((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte)),
23+
) -> UInt64 {
24+
let ((b7, b6, b5, b4), (b3, b2, b1, b0)) = q
25+
(b7.to_uint64() << 56) |
26+
(b6.to_uint64() << 48) |
27+
(b5.to_uint64() << 40) |
28+
(b4.to_uint64() << 32) |
29+
(b3.to_uint64() << 24) |
30+
(b2.to_uint64() << 16) |
31+
(b1.to_uint64() << 8) |
32+
b0.to_uint64()
33+
}
34+
35+
///|
36+
fn u32_of_bytes(q : (Byte, Byte, Byte, Byte)) -> UInt {
37+
let (b3, b2, b1, b0) = q
38+
(b3.to_uint() << 24) |
39+
(b2.to_uint() << 16) |
40+
(b1.to_uint() << 8) |
41+
b0.to_uint()
42+
}
43+
44+
///|
45+
fn insert_char(s : String, pos : Int, inserted : Char) -> String {
46+
let sb = StringBuilder::new()
47+
let mut i = 0
48+
for c in s {
49+
if i == pos {
50+
sb.write_char(inserted)
51+
}
52+
sb.write_char(c)
53+
i += 1
54+
}
55+
sb.to_string()
56+
}
57+
58+
///|
59+
/// The killer property: `Double::to_string` produces the shortest decimal
60+
/// representation that rounds back to the value, so `parse_double` must invert
61+
/// it *bitwise* for every double, including subnormals, extreme exponents and
62+
/// infinities. Arbitrary bit patterns reach all of those; NaN is excluded
63+
/// because it has many payloads and never compares equal.
64+
#warnings("-deprecated")
65+
test "quickcheck: parse_double inverts to_string for arbitrary bit patterns" {
66+
@quickcheck.check(
67+
(q : ((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte))) => {
68+
let d = u64_of_bytes(q).reinterpret_as_double()
69+
let s = d.to_string()
70+
let parsed = @strconv.parse_double(s)
71+
if parsed.reinterpret_as_int64() == d.reinterpret_as_int64() {
72+
true
73+
} else {
74+
// `Double::to_string` follows the ECMAScript number-to-string
75+
// algorithm on every backend, and that algorithm renders negative
76+
// zero as "0", so the sign bit cannot survive the trip through the
77+
// string. Parsing that "0" must still yield exactly +0.0. Every
78+
// other double must round-trip bit-for-bit.
79+
d.reinterpret_as_int64() == -0x8000000000000000L &&
80+
s == "0" &&
81+
parsed.reinterpret_as_int64() == 0L
82+
}
83+
},
84+
filter=q => !u64_of_bytes(q).reinterpret_as_double().is_nan(),
85+
count=500,
86+
)
87+
}
88+
89+
///|
90+
/// Same round-trip on uniformly distributed doubles in [0, 1]: dense mantissas
91+
/// with small exponents exercise the fast path a bit-pattern generator rarely
92+
/// hits.
93+
#warnings("-deprecated")
94+
test "quickcheck: parse_double inverts to_string for uniform doubles" {
95+
@quickcheck.check(
96+
(d : Double) => {
97+
@strconv.parse_double(d.to_string()).reinterpret_as_int64() ==
98+
d.reinterpret_as_int64()
99+
},
100+
count=500,
101+
)
102+
}
103+
104+
///|
105+
/// Decimal round-trip over the full signed/unsigned 64-bit range, and
106+
/// agreement with `to_double`: parsing the exact decimal string of an integer
107+
/// must give the same correctly-rounded double as converting the integer
108+
/// directly (19-20 significant digits exercise the slow path).
109+
#warnings("-deprecated")
110+
test "quickcheck: 64-bit integers round-trip through decimal strings" {
111+
@quickcheck.check(
112+
(q : ((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte))) => {
113+
let u = u64_of_bytes(q)
114+
let n = u.reinterpret_as_int64()
115+
@strconv.parse_uint64(u.to_string()) == u &&
116+
@strconv.parse_int64(n.to_string()) == n &&
117+
@strconv.parse_int64(n.to_string(), base=10) == n &&
118+
@strconv.parse_double(u.to_string()) == u.to_double() &&
119+
@strconv.parse_double(n.to_string()) == n.to_double()
120+
},
121+
count=500,
122+
)
123+
}
124+
125+
///|
126+
/// Radix round-trip: formatting in any base 2..=36 and parsing with the same
127+
/// explicit base is the identity, for both signed and unsigned 64-bit values.
128+
#warnings("-deprecated")
129+
test "quickcheck: radix formatting and parsing are inverse in every base" {
130+
@quickcheck.check(
131+
(input : (((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte)), UInt)) => {
132+
let (q, r) = input
133+
let base = 2 + (r % 35).reinterpret_as_int()
134+
let u = u64_of_bytes(q)
135+
let n = u.reinterpret_as_int64()
136+
@strconv.parse_uint64(u.to_string(radix=base), base~) == u &&
137+
@strconv.parse_int64(n.to_string(radix=base), base~) == n
138+
},
139+
count=500,
140+
)
141+
}
142+
143+
///|
144+
/// 32-bit round-trip, an explicit leading '+', and agreement between the
145+
/// integer and floating-point parsers on the same string (exact: every Int
146+
/// fits in a double).
147+
#warnings("-deprecated")
148+
test "quickcheck: 32-bit integers round-trip and parsers agree" {
149+
@quickcheck.check(
150+
(q : (Byte, Byte, Byte, Byte)) => {
151+
let u = u32_of_bytes(q)
152+
let n = u.reinterpret_as_int()
153+
let s = n.to_string()
154+
@strconv.parse_int(s) == n &&
155+
@strconv.parse_uint(u.to_string()) == u &&
156+
@strconv.parse_int64(s) == n.to_int64() &&
157+
@strconv.parse_double(s) == n.to_double() &&
158+
(n < 0 || @strconv.parse_int("+" + s) == n)
159+
},
160+
count=500,
161+
)
162+
}
163+
164+
///|
165+
/// Underscores between digits are documented as pure readability sugar: they
166+
/// must not change the parsed value, for integers and doubles alike.
167+
#warnings("-deprecated")
168+
test "quickcheck: an underscore between digits never changes the value" {
169+
@quickcheck.check(
170+
(input : (((Byte, Byte, Byte, Byte), (Byte, Byte, Byte, Byte)), UInt)) => {
171+
let (q, pos_seed) = input
172+
let u = u64_of_bytes(q)
173+
let s = u.to_string()
174+
guard s.length() >= 2 else { return true }
175+
// Any interior position sits between two digits.
176+
let pos = 1 +
177+
(pos_seed % (s.length() - 1).reinterpret_as_uint()).reinterpret_as_int()
178+
let with_underscore = insert_char(s, pos, '_')
179+
@strconv.parse_uint64(with_underscore) == u &&
180+
@strconv.parse_double(with_underscore) == u.to_double()
181+
},
182+
count=300,
183+
)
184+
}
185+
186+
///|
187+
/// The grammar accepts no decoration: a stray character that is not a digit,
188+
/// letter, sign, dot or underscore — including whitespace — must make parsing
189+
/// fail whether it is prepended or appended.
190+
#warnings("-deprecated")
191+
test "quickcheck: a stray non-numeric character is rejected" {
192+
@quickcheck.check(
193+
(input : (Int, Char)) => {
194+
let (n, c) = input
195+
let s = n.to_string()
196+
(try? @strconv.parse_int("\{c}\{s}")) is Err(_) &&
197+
(try? @strconv.parse_int("\{s}\{c}")) is Err(_) &&
198+
(try? @strconv.parse_double("\{c}\{s}")) is Err(_) &&
199+
(try? @strconv.parse_double("\{s}\{c}")) is Err(_)
200+
},
201+
filter=input => {
202+
!(input.1 is ('0'..='9' | 'a'..='z' | 'A'..='Z' | '_' | '+' | '-' | '.'))
203+
},
204+
count=300,
205+
)
206+
}
207+
208+
///|
209+
/// Small-grammar fuzz: every string of the shape
210+
/// `[-]digits[.digits][e[-]digits]` must either parse — and then the parsed
211+
/// value's own rendering must reparse to the same bits (a fixed point) — or
212+
/// fail with a range error, which is only legitimate for a genuinely huge
213+
/// exponent (overflow past Double::max_value; underflow flushes to zero
214+
/// silently and never errors).
215+
#warnings("-deprecated")
216+
test "quickcheck: structured numeric strings parse to a printing fixed point" {
217+
@quickcheck.check(
218+
(parts : (UInt, UInt, Int, (Bool, Bool, Bool))) => {
219+
let (int_part, frac_part, e, (neg, with_frac, with_exp)) = parts
220+
let exp = e * 4
221+
let sb = StringBuilder::new()
222+
if neg {
223+
sb.write_char('-')
224+
}
225+
sb.write_string(int_part.to_string())
226+
if with_frac {
227+
sb.write_char('.')
228+
sb.write_string(frac_part.to_string())
229+
}
230+
if with_exp {
231+
sb.write_char('e')
232+
sb.write_string(exp.to_string())
233+
}
234+
let s = sb.to_string()
235+
match (try? @strconv.parse_double(s)) {
236+
Ok(p) => {
237+
let reparsed = @strconv.parse_double(p.to_string())
238+
if p.reinterpret_as_int64() == -0x8000000000000000L {
239+
// "-0" parses to -0.0, but the ECMAScript-style to_string renders
240+
// negative zero as "0", so one reparse lands on +0.0.
241+
reparsed.reinterpret_as_int64() == 0L
242+
} else {
243+
reparsed.reinterpret_as_int64() == p.reinterpret_as_int64()
244+
}
245+
}
246+
Err(_) => with_exp && exp > 250
247+
}
248+
},
249+
count=500,
250+
)
251+
}
252+
253+
///|
254+
/// Booleans round-trip too.
255+
#warnings("-deprecated")
256+
test "quickcheck: parse_bool inverts to_string" {
257+
@quickcheck.check((b : Bool) => @strconv.parse_bool(b.to_string()) == b)
258+
}

0 commit comments

Comments
 (0)