|
| 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