@barefootjs/perl 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,509 @@
1
+ use Test2::V0;
2
+
3
+ # JS-compat helper coverage (#1189). Mirrors the Go runtime test
4
+ # surface so cross-adapter regressions stay symmetric.
5
+
6
+ use FindBin qw($Bin);
7
+ use lib "$Bin/../lib";
8
+
9
+ use BarefootJS;
10
+
11
+ # Pure-Perl backend (core JSON::PP only) so this engine-agnostic test runs
12
+ # with zero Mojo present — `BarefootJS` itself is Mojo-free; only the optional
13
+ # BarefootJS::Backend::Mojo (shipped by @barefootjs/mojolicious) pulls in Mojo.
14
+ {
15
+ package PureBackend;
16
+ use JSON::PP ();
17
+ my $J = JSON::PP->new->canonical->allow_nonref;
18
+ sub new { bless {}, shift }
19
+ sub encode_json { $J->encode($_[1]) }
20
+ sub mark_raw { $_[1] }
21
+ sub materialize { ref($_[1]) eq 'CODE' ? $_[1]->() : $_[1] }
22
+ sub render_named { '' }
23
+ }
24
+
25
+ # The JS-compat helpers are pure functions of `$self` + args; only `json`
26
+ # reaches the backend (for JSON encoding). A bare hash blessed into the
27
+ # package with an injected pure backend is enough for these unit tests.
28
+ my $bf = bless { c => undef, config => {}, backend => PureBackend->new }, 'BarefootJS';
29
+
30
+ subtest 'json — mirrors JS JSON.stringify (with documented undef divergence)' => sub {
31
+ is $bf->json({a => 1}), '{"a":1}', 'hash';
32
+ is $bf->json([1, 2, 3]), '[1,2,3]', 'array';
33
+ is $bf->json('hi'), '"hi"', 'string';
34
+ # Documented divergence from JS: JS `JSON.stringify(undefined)`
35
+ # returns the JS value `undefined` (not a string), while
36
+ # `JSON.stringify(null)` returns "null". Perl has no
37
+ # null/undefined distinction so both map to undef here, and
38
+ # we render "null" for SSR ergonomics. See `BarefootJS::json`.
39
+ is $bf->json(undef), 'null', 'undef → "null" (matches JS null; diverges from JS undefined)';
40
+ };
41
+
42
+ subtest 'string — JS String(v) mirror' => sub {
43
+ is $bf->string(42), '42', 'int';
44
+ is $bf->string('hi'), 'hi', 'string passthrough';
45
+ # Documented divergence from JS String(null) === "null".
46
+ is $bf->string(undef), '', 'undef → "" (intentional divergence)';
47
+ };
48
+
49
+ # Real numeric NaN is the only float for which `$x != $x` holds.
50
+ # Tests check for it directly rather than string-comparing against
51
+ # "NaN", which stringifies platform-dependently.
52
+ sub is_nan { my $n = shift; return $n != $n }
53
+
54
+ subtest 'number — JS Number(v) mirror; NaN on parse failure' => sub {
55
+ is $bf->number('3.14'), 3.14, 'numeric string';
56
+ is $bf->number(42), 42, 'integer passthrough';
57
+ ok is_nan($bf->number('not a num')), 'non-numeric → NaN';
58
+ ok is_nan($bf->number(undef)), 'undef → NaN';
59
+ };
60
+
61
+ subtest 'floor / ceil / round — Math.* mirrors; propagate NaN' => sub {
62
+ is $bf->floor(3.7), 3, '3.7 → 3';
63
+ is $bf->floor(-3.2), -4, '-3.2 → -4';
64
+ ok is_nan($bf->floor('not')), 'floor: NaN propagates';
65
+
66
+ is $bf->ceil(3.1), 4, '3.1 → 4';
67
+ is $bf->ceil(-3.7), -3, '-3.7 → -3';
68
+ ok is_nan($bf->ceil('not')), 'ceil: NaN propagates';
69
+
70
+ is $bf->round(3.5), 4, '3.5 → 4';
71
+ is $bf->round(3.4), 3, '3.4 → 3';
72
+ # JS `Math.round` ties go toward +Infinity, NOT away from zero —
73
+ # so -1.5 rounds to -1 (not -2). Pin both halves of the negative
74
+ # tie-break so a future POSIX::floor swap doesn't silently
75
+ # regress the JS-compat contract.
76
+ is $bf->round(-1.5), -1, '-1.5 → -1 (JS half-toward-+Inf, not half-away-from-zero)';
77
+ is $bf->round(-1.6), -2, '-1.6 → -2';
78
+ ok is_nan($bf->round('not')), 'round: NaN propagates';
79
+ };
80
+
81
+ # `Array.prototype.includes(x)` + `String.prototype.includes(sub)` lower
82
+ # to the same `$bf->includes($recv, $elem)` shape — see #1448 Tier A.
83
+ # The Perl helper dispatches on `ref()`: ARRAY ref scans elements with
84
+ # `eq`; scalar falls back to `index(..., ...) != -1`. Anything else
85
+ # (HASH ref, code ref) returns false to match the JS semantic that
86
+ # `.includes` is only defined on Array / TypedArray / String.
87
+ subtest 'includes — array + string + non-array/string dispatch' => sub {
88
+ # Array receiver: element-wise `eq` (handles defined/undef parity).
89
+ ok $bf->includes(['a', 'b', 'c'], 'b'), 'array contains element → 1';
90
+ ok !$bf->includes(['a', 'b', 'c'], 'z'), 'array does not contain → 0';
91
+ ok $bf->includes([1, 2, 3], 2), 'numeric element';
92
+ ok !$bf->includes([], 'a'), 'empty array → 0';
93
+ ok $bf->includes([undef, 'a'], undef), 'undef element matches undef needle';
94
+ ok !$bf->includes(['a', 'b'], undef), 'undef needle, no undef element → 0';
95
+
96
+ # String receiver: substring search.
97
+ ok $bf->includes('hello world', 'world'), 'substring present → 1';
98
+ ok !$bf->includes('hello world', 'earth'), 'substring absent → 0';
99
+ ok $bf->includes('hello', ''), 'empty needle → 1 (JS-compat)';
100
+ ok !$bf->includes('', 'x'), 'empty receiver, non-empty needle → 0';
101
+ ok !$bf->includes(undef, 'x'), 'undef receiver → 0';
102
+
103
+ # Anything else (HASH ref, code ref) → 0; pin so a future
104
+ # refactor doesn't accidentally match HASH keys.
105
+ ok !$bf->includes({a => 1}, 'a'), 'hash ref → 0 (.includes undefined on Object)';
106
+ ok !$bf->includes(sub {}, 'x'), 'code ref → 0';
107
+ };
108
+
109
+ # `Array.prototype.indexOf(x)` / `Array.prototype.lastIndexOf(x)`
110
+ # value-equality search (#1448 Tier A). Non-array receivers return -1.
111
+ # Duplicated-value coverage is the disambiguator between indexOf
112
+ # (forward) and lastIndexOf (backward); pinning a non-final last-match
113
+ # position makes a misdirected walk impossible to hide.
114
+ subtest 'index_of / last_index_of — array value-equality search' => sub {
115
+ my $arr = ['a', 'b', 'c', 'b', 'd'];
116
+
117
+ is $bf->index_of($arr, 'a'), 0, 'first element';
118
+ is $bf->index_of($arr, 'b'), 1, 'duplicated value: first match';
119
+ is $bf->index_of($arr, 'd'), 4, 'last element';
120
+ is $bf->index_of($arr, 'z'), -1, 'absent → -1';
121
+ is $bf->index_of([], 'a'), -1, 'empty array → -1';
122
+ is $bf->index_of('not an array', 'a'), -1, 'non-array → -1';
123
+
124
+ is $bf->last_index_of($arr, 'b'), 3, 'duplicated value: LAST match (non-final position)';
125
+ is $bf->last_index_of($arr, 'a'), 0, 'unique value still found';
126
+ is $bf->last_index_of($arr, 'z'), -1, 'absent → -1';
127
+ is $bf->last_index_of([], 'a'), -1, 'empty array → -1';
128
+
129
+ # undef parity matches the `includes` helper above.
130
+ is $bf->index_of([undef, 'x', undef], undef), 0, 'undef matches undef (forward)';
131
+ is $bf->last_index_of([undef, 'x', undef], undef), 2, 'undef matches undef (backward)';
132
+ };
133
+
134
+ # `Array.prototype.at(i)` — supports negative indices; out-of-bounds
135
+ # returns undef. Non-array receivers return undef. Mirrors the Go
136
+ # `bf_at` arithmetic so adapter output stays symmetric.
137
+ subtest 'at — array indexed access with negative-index support' => sub {
138
+ my $arr = ['a', 'b', 'c'];
139
+
140
+ is $bf->at($arr, 0), 'a', 'first element';
141
+ is $bf->at($arr, 2), 'c', 'last element via positive index';
142
+ is $bf->at($arr, -1), 'c', 'last element via -1';
143
+ is $bf->at($arr, -3), 'a', 'first element via -3 (length - 3)';
144
+
145
+ is $bf->at($arr, 3), undef, 'out of bounds (positive) → undef';
146
+ is $bf->at($arr, -4), undef, 'out of bounds (negative) → undef';
147
+ is $bf->at([], 0), undef, 'empty array → undef';
148
+ is $bf->at(undef, 0), undef, 'undef receiver → undef';
149
+ is $bf->at('not array', 0), undef, 'scalar receiver → undef';
150
+ is $bf->at({a => 1}, 0), undef, 'hash ref receiver → undef';
151
+ };
152
+
153
+ # `Array.prototype.concat(other)` — merges two arrays in order
154
+ # into a new ARRAY ref (#1448 Tier A). Non-array operands collapse
155
+ # to empty (matches the Go `bf_concat` semantic); the result must
156
+ # compose with `.join(...)` etc., hence the ARRAY ref return type.
157
+ subtest 'concat — merges two arrays into a new array ref' => sub {
158
+ is $bf->concat(['a','b'], ['c','d']), ['a','b','c','d'], 'two non-empty arrays';
159
+ is $bf->concat([], ['a']), ['a'], 'empty + non-empty';
160
+ is $bf->concat(['a'], []), ['a'], 'non-empty + empty';
161
+ is $bf->concat([], []), [], 'empty + empty';
162
+
163
+ is $bf->concat(undef, ['a']), ['a'], 'undef left → treats as empty';
164
+ is $bf->concat(['a'], undef), ['a'], 'undef right → treats as empty';
165
+ is $bf->concat('not an array', ['a']), ['a'], 'scalar left → treats as empty';
166
+ is $bf->concat({a=>1}, ['a']), ['a'], 'hash ref left → treats as empty';
167
+
168
+ # Mutation isolation: caller's source arrays must not be modified.
169
+ my $left = ['a', 'b'];
170
+ my $right = ['c', 'd'];
171
+ my $out = $bf->concat($left, $right);
172
+ push @$out, 'mutated';
173
+ is $left, ['a', 'b'], 'left source unchanged after mutating result';
174
+ is $right, ['c', 'd'], 'right source unchanged after mutating result';
175
+ };
176
+
177
+ # `Array.prototype.slice(start, end?)` — carves out a sub-range
178
+ # into a new ARRAY ref (#1448 Tier A). Mirrors the Go `bf_slice`
179
+ # JS-compat semantics: negative-index normalisation, out-of-bounds
180
+ # clamping, `start >= end` returns empty, undef `end` means "to
181
+ # length". Non-array receivers return an empty ARRAY ref.
182
+ subtest 'slice — array sub-range with negative-index + clamping' => sub {
183
+ my $arr = ['a', 'b', 'c', 'd', 'e'];
184
+
185
+ # 2-arg form.
186
+ is $bf->slice($arr, 1, 3), ['b', 'c'], 'start+end carves middle';
187
+
188
+ # 1-arg form (undef end = "to length").
189
+ is $bf->slice($arr, 2, undef), ['c', 'd', 'e'], 'undef end → to length';
190
+ is $bf->slice($arr, 0, undef), ['a', 'b', 'c', 'd', 'e'], 'start 0, undef end → full copy';
191
+
192
+ # Negative-index normalisation.
193
+ is $bf->slice($arr, -2, undef),['d', 'e'], '-2 start → last two';
194
+ is $bf->slice($arr, 0, -1), ['a', 'b', 'c', 'd'], '-1 end → drop last';
195
+ is $bf->slice($arr, -3, -1), ['c', 'd'], 'both negative';
196
+
197
+ # Clamping (out of bounds + start >= end).
198
+ is $bf->slice($arr, 100, undef), [], 'start past end → empty';
199
+ is $bf->slice($arr, 3, 1), [], 'start > end → empty';
200
+ is $bf->slice($arr, 0, 0), [], 'start == end → empty';
201
+
202
+ # Edge cases.
203
+ is $bf->slice([], 0, undef), [], 'empty array → empty';
204
+ is $bf->slice(undef, 0, undef), [], 'undef receiver → empty';
205
+ is $bf->slice('scalar', 0, undef), [], 'scalar receiver → empty';
206
+
207
+ # Mutation isolation.
208
+ my $src = ['a', 'b', 'c'];
209
+ my $out = $bf->slice($src, 0, 2);
210
+ push @$out, 'mutated';
211
+ is $src, ['a', 'b', 'c'], 'source unchanged after mutating slice result';
212
+ };
213
+
214
+ # `Array.prototype.reverse()` / `Array.prototype.toReversed()` —
215
+ # both shapes share the lowering (#1448 Tier A). SSR templates
216
+ # render a snapshot, so JS's mutate-vs-new distinction has no
217
+ # template-level meaning. Always returns a new ARRAY ref.
218
+ subtest 'reverse — new array ref in reverse order' => sub {
219
+ is $bf->reverse(['a', 'b', 'c']), ['c', 'b', 'a'], 'three elements';
220
+ is $bf->reverse([1, 2, 3, 4]), [4, 3, 2, 1], 'integers';
221
+ is $bf->reverse([]), [], 'empty array';
222
+ is $bf->reverse(['only']), ['only'], 'single element';
223
+
224
+ # Mutation isolation: input must survive.
225
+ my $src = ['a', 'b', 'c'];
226
+ my $out = $bf->reverse($src);
227
+ push @$out, 'mutated';
228
+ is $src, ['a', 'b', 'c'], 'source unchanged after mutating reverse result';
229
+
230
+ # Non-array receivers.
231
+ is $bf->reverse(undef), [], 'undef receiver → empty';
232
+ is $bf->reverse('not an array'),[], 'scalar receiver → empty';
233
+ is $bf->reverse({a => 1}), [], 'hash ref receiver → empty';
234
+ };
235
+
236
+ # `String.prototype.trim()` — strip leading + trailing whitespace
237
+ # (#1448 Tier A). Padding both sides of the test input so a
238
+ # trim-front-only or trim-back-only regression fails here.
239
+ subtest 'trim — strip leading + trailing whitespace' => sub {
240
+ is $bf->trim(' padded '), 'padded', 'leading + trailing spaces';
241
+ is $bf->trim("\t\nleading"), 'leading', 'leading tab + newline';
242
+ is $bf->trim('trailing '), 'trailing', 'trailing spaces only';
243
+ is $bf->trim('no-pad'), 'no-pad', 'no whitespace passthrough';
244
+ is $bf->trim(' '), '', 'all whitespace → empty';
245
+ is $bf->trim(''), '', 'empty input → empty';
246
+
247
+ # Inner whitespace is preserved — only the boundaries are stripped.
248
+ is $bf->trim(' hello world '), 'hello world', 'inner spaces preserved';
249
+
250
+ # Non-string receivers.
251
+ is $bf->trim(undef), '', 'undef receiver → empty';
252
+ is $bf->trim({a => 1}), '', 'hash ref receiver → empty';
253
+ is $bf->trim(['arr']), '', 'array ref receiver → empty';
254
+
255
+ # Numeric coercion: JS would stringify `42.trim()` first.
256
+ is $bf->trim(42), '42', 'numeric receiver stringifies';
257
+ };
258
+
259
+ # `String.prototype.split(sep)` — string → ARRAY ref (#1448 Tier B).
260
+ # Mirrors the Go `bf_split`: literal (quotemeta'd) separator, trailing
261
+ # empties preserved (the `-1` limit), empty-separator char split.
262
+ subtest 'split — string into array of substrings' => sub {
263
+ is $bf->split('a,b,c', ','), ['a', 'b', 'c'], 'comma separator';
264
+ is $bf->split('a-b-c', '-'), ['a', 'b', 'c'], 'dash separator';
265
+
266
+ # Separator is matched literally, not as a regex — '.' and '|'
267
+ # would otherwise match every position / alternate emptily.
268
+ is $bf->split('a.b.c', '.'), ['a', 'b', 'c'], 'dot is literal, not regex any-char';
269
+ is $bf->split('a|b', '|'), ['a', 'b'], 'pipe is literal, not regex alternation';
270
+
271
+ # Trailing empty fields survive (JS keeps them; Perl's bare split
272
+ # drops them — the -1 limit is what preserves parity).
273
+ is $bf->split('a,', ','), ['a', ''], 'trailing empty field preserved';
274
+ is $bf->split('a,,b', ','), ['a', '', 'b'], 'inner empty field preserved';
275
+ is $bf->split(',a', ','), ['', 'a'], 'leading empty field preserved';
276
+
277
+ # Empty separator → individual characters.
278
+ is $bf->split('abc', ''), ['a', 'b', 'c'], 'empty separator splits into chars';
279
+ is $bf->split('', ''), [], 'empty string, empty separator → empty';
280
+
281
+ # No match → single-element array (the whole string).
282
+ is $bf->split('abc', ','), ['abc'], 'no separator match → whole string';
283
+
284
+ # No separator at all → the whole string (JS `"x".split()`).
285
+ is $bf->split('a,b,c'), ['a,b,c'], 'no separator → whole string';
286
+
287
+ # Optional limit caps the pieces (JS `split(sep, limit)`); 0 → empty,
288
+ # negative / >= length → all (matches Go bf_split).
289
+ is $bf->split('a,b,c,d', ',', 2), ['a', 'b'], 'limit caps pieces';
290
+ is $bf->split('a,b', ',', 0), [], 'limit 0 → empty';
291
+ is $bf->split('a,b', ',', 9), ['a', 'b'], 'limit >= length → all';
292
+ is $bf->split('a,b', ',', -1),['a', 'b'], 'negative limit → all';
293
+
294
+ # Undef / non-string receivers render as the empty-string element.
295
+ is $bf->split(undef, ','), [''], 'undef receiver renders single empty-string element';
296
+ is $bf->split(42, ','), ['42'], 'numeric receiver stringifies';
297
+ };
298
+
299
+ # `String.prototype.startsWith` / `endsWith` — string → boolean (1/0)
300
+ # (#1448 Tier B). Literal substr-anchored comparison; mirrors Go's
301
+ # strings.HasPrefix / HasSuffix.
302
+ subtest 'starts_with / ends_with — boolean prefix/suffix tests' => sub {
303
+ ok $bf->starts_with('hello world', 'hello'), 'prefix matches';
304
+ ok !$bf->starts_with('hello world', 'world'), 'prefix mismatch';
305
+ ok $bf->starts_with('anything', ''), 'empty prefix is always true';
306
+ ok !$bf->starts_with('hi', 'longer-than-str'), 'prefix longer than string → false';
307
+
308
+ ok $bf->ends_with('hello world', 'world'), 'suffix matches';
309
+ ok !$bf->ends_with('hello world', 'hello'), 'suffix mismatch';
310
+ ok $bf->ends_with('anything', ''), 'empty suffix is always true';
311
+ ok !$bf->ends_with('hi', 'longer-than-str'), 'suffix longer than string → false';
312
+
313
+ # Separator/search string is matched literally, not as a regex.
314
+ ok $bf->starts_with('a.b.c', 'a.'), 'dot in prefix is literal';
315
+ ok $bf->ends_with('a.b.c', '.c'), 'dot in suffix is literal';
316
+
317
+ # Undef / non-string receivers coerce to empty string.
318
+ ok !$bf->starts_with(undef, 'x'), 'undef receiver, non-empty prefix → false';
319
+ ok $bf->starts_with(undef, ''), 'undef receiver, empty prefix → true';
320
+ is $bf->starts_with('hello', 'he'), 1, 'returns 1, not just truthy';
321
+ is $bf->ends_with('hello', 'xx'), 0, 'returns 0, not just falsey';
322
+
323
+ # Optional position / endPosition (JS `startsWith(p, pos)` /
324
+ # `endsWith(s, endPos)`), with clamping to [0, length].
325
+ ok $bf->starts_with('abc', 'b', 1), 'starts_with at position';
326
+ ok !$bf->starts_with('abc', 'a', 1), 'starts_with: wrong char at position → false';
327
+ ok !$bf->starts_with('abc', 'a', 99), 'starts_with: position past end → false (clamped)';
328
+ ok $bf->starts_with('abc', 'a', -5), 'starts_with: negative position → from 0 (clamped)';
329
+ ok $bf->ends_with('abc', 'b', 2), 'ends_with at endPosition';
330
+ ok !$bf->ends_with('abc', 'c', 2), 'ends_with: char beyond endPosition → false';
331
+ ok $bf->ends_with('abc', 'c', 99), 'ends_with: endPosition past end → true (clamped)';
332
+ ok !$bf->ends_with('abc', 'a', -1), 'ends_with: negative endPosition → empty (clamped)';
333
+ };
334
+
335
+ # `String.prototype.replace(pattern, replacement)` — string-pattern
336
+ # form, first occurrence only (#1448 Tier B). Literal splice (no s///),
337
+ # so both pattern and replacement are literal — mirrors Go's
338
+ # strings.Replace with n=1.
339
+ subtest 'replace — first-occurrence string-pattern swap' => sub {
340
+ is $bf->replace('hello world', 'o', '0'), 'hell0 world', 'first occurrence only';
341
+ is $bf->replace('aaa', 'a', 'b'), 'baa', 'leftmost of repeats';
342
+ is $bf->replace('abc', 'z', 'Z'), 'abc', 'no match → unchanged';
343
+ is $bf->replace('abc', 'b', ''), 'ac', 'empty replacement deletes';
344
+ is $bf->replace('abc', '', 'X'), 'Xabc', 'empty pattern inserts at front';
345
+
346
+ # Pattern is literal, not a regex — '.' matches a literal dot only.
347
+ is $bf->replace('a.b.c', '.', '-'), 'a-b.c', 'dot in pattern is literal';
348
+
349
+ # Replacement is literal — no $1 / $& interpolation.
350
+ is $bf->replace('ab', 'a', '$&'), '$&b', 'replacement $& is literal';
351
+ is $bf->replace('ab', 'a', '$1'), '$1b', 'replacement $1 is literal';
352
+
353
+ # Undef / non-string receivers coerce to empty string.
354
+ is $bf->replace(undef, 'a', 'b'), '', 'undef receiver → empty';
355
+ };
356
+
357
+ # `String.prototype.repeat(n)` — receiver concatenated n times
358
+ # (#1448 Tier B). Perl `x` operator; negative count clamps to "" (JS
359
+ # throws but SSR degrades), count truncated toward zero.
360
+ subtest 'repeat — string concatenated n times' => sub {
361
+ is $bf->repeat('ab', 3), 'ababab', 'three times';
362
+ is $bf->repeat('x', 1), 'x', 'once → unchanged';
363
+ is $bf->repeat('ab', 0), '', 'zero → empty';
364
+ is $bf->repeat('ab', -2), '', 'negative → empty (JS throws; SSR degrades)';
365
+ is $bf->repeat('ab', 2.9),'abab', 'fractional count truncates toward zero';
366
+ is $bf->repeat('', 5), '', 'empty receiver → empty';
367
+ is $bf->repeat(undef, 3), '', 'undef receiver → empty';
368
+ };
369
+
370
+ # `String.prototype.padStart` / `padEnd` (#1448 Tier B) — pad to a
371
+ # target width with a repeat-and-truncate fill (default pad = space).
372
+ # Mirrors Go's rune-based `bf_pad_*`.
373
+ subtest 'pad_start / pad_end — pad to target width' => sub {
374
+ is $bf->pad_start('42', 5, '0'), '00042', 'left-pad with 0';
375
+ is $bf->pad_end('42', 5, '.'), '42...', 'right-pad with .';
376
+
377
+ # Default pad is a single space when omitted.
378
+ is $bf->pad_start('42', 5), ' 42', 'default pad = space (start)';
379
+ is $bf->pad_end('42', 5), '42 ', 'default pad = space (end)';
380
+
381
+ # Multi-char pad repeated then truncated to fill.
382
+ is $bf->pad_start('x', 5, 'ab'), 'ababx', 'multi-char pad truncated (start)';
383
+ is $bf->pad_end('x', 5, 'ab'), 'xabab', 'multi-char pad truncated (end)';
384
+
385
+ # Already >= target, or empty pad → unchanged.
386
+ is $bf->pad_start('hello', 3, '0'), 'hello', 'already >= target → unchanged';
387
+ is $bf->pad_start('42', 5, ''), '42', 'empty pad → unchanged';
388
+
389
+ # Target truncates toward zero; undef receiver coerces to empty.
390
+ is $bf->pad_start('7', 4.9, '0'), '0007', 'fractional target truncates';
391
+ is $bf->pad_start(undef, 3, '0'), '000', 'undef receiver pads from empty';
392
+ };
393
+
394
+ # `Array.prototype.sort(cmp)` / `Array.prototype.toSorted(cmp)`
395
+ # lowering (#1448 Tier B). The opts hash-ref carries a `keys` list of
396
+ # the structured comparison keys the compiler extracted at parse time
397
+ # — adapter-side emit is a single call shape; the runtime walks the
398
+ # keys in priority order, branching on key_kind / compare_type /
399
+ # direction per key.
400
+ subtest 'sort — structured comparator dispatch' => sub {
401
+ my $items = [
402
+ { name => 'c', price => 30 },
403
+ { name => 'a', price => 10 },
404
+ { name => 'b', price => 20 },
405
+ ];
406
+
407
+ # Numeric field, ascending — the canonical struct-field case.
408
+ is $bf->sort($items, { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' }] }),
409
+ [ { name => 'a', price => 10 }, { name => 'b', price => 20 }, { name => 'c', price => 30 } ],
410
+ 'numeric field asc';
411
+
412
+ # Numeric field, descending — reverse direction.
413
+ is $bf->sort($items, { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'desc' }] }),
414
+ [ { name => 'c', price => 30 }, { name => 'b', price => 20 }, { name => 'a', price => 10 } ],
415
+ 'numeric field desc';
416
+
417
+ # Primitive numeric — `(a, b) => a - b` shape (key_kind=self).
418
+ is $bf->sort([3, 1, 2], { keys => [{ key_kind => 'self', compare_type => 'numeric', direction => 'asc' }] }),
419
+ [1, 2, 3],
420
+ 'numeric self asc';
421
+
422
+ # Primitive string — `(a, b) => a.localeCompare(b)`. The Perl
423
+ # helper falls back to plain `cmp` (byte-ordering); within the
424
+ # same case it matches lexicographic order.
425
+ is $bf->sort(['charlie', 'alice', 'bob'], { keys => [{ key_kind => 'self', compare_type => 'string', direction => 'asc' }] }),
426
+ ['alice', 'bob', 'charlie'],
427
+ 'string self asc';
428
+
429
+ is $bf->sort(['charlie', 'alice', 'bob'], { keys => [{ key_kind => 'self', compare_type => 'string', direction => 'desc' }] }),
430
+ ['charlie', 'bob', 'alice'],
431
+ 'string self desc';
432
+
433
+ # Stable sort: equal keys preserve relative order. Perl `sort`
434
+ # has been stable since 5.8; pinning the guarantee so a future
435
+ # `use sort` pragma swap surfaces here.
436
+ my $stable_input = [
437
+ { name => 'first', price => 10 },
438
+ { name => 'second', price => 10 },
439
+ { name => 'third', price => 10 },
440
+ ];
441
+ is $bf->sort($stable_input, { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' }] }),
442
+ $stable_input,
443
+ 'stable sort preserves equal-key order';
444
+
445
+ # Mutation isolation: caller's source array must survive.
446
+ my $src = [{ price => 3 }, { price => 1 }, { price => 2 }];
447
+ my $out = $bf->sort($src, { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' }] });
448
+ push @$out, { price => 99 };
449
+ is $src, [{ price => 3 }, { price => 1 }, { price => 2 }],
450
+ 'source unchanged after mutating sort result';
451
+
452
+ # Non-array receivers fall back to empty.
453
+ is $bf->sort(undef, { keys => [{ key_kind => 'self', compare_type => 'numeric', direction => 'asc' }] }), [], 'undef receiver → []';
454
+ is $bf->sort('not an array', { keys => [{ key_kind => 'self', compare_type => 'numeric', direction => 'asc' }] }), [], 'scalar receiver → []';
455
+ is $bf->sort({a => 1}, { keys => [{ key_kind => 'self', compare_type => 'numeric', direction => 'asc' }] }), [], 'hash ref receiver → []';
456
+
457
+ # Empty array short-circuits.
458
+ is $bf->sort([], { keys => [{ key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' }] }), [], 'empty array → []';
459
+ };
460
+
461
+ # Multi-key (`||`-chained) comparator: a tie on the primary key falls
462
+ # through to the next key. (#1448 Tier B follow-up.)
463
+ subtest 'sort — multi-key (||-chain) tie-breaks' => sub {
464
+ # `(a,b) => a.p - b.p || a.name.localeCompare(b.name)`.
465
+ my $items = [
466
+ { p => 1, name => 'b' },
467
+ { p => 1, name => 'a' },
468
+ { p => 0, name => 'c' },
469
+ ];
470
+ is $bf->sort($items, { keys => [
471
+ { key_kind => 'field', key => 'p', compare_type => 'numeric', direction => 'asc' },
472
+ { key_kind => 'field', key => 'name', compare_type => 'string', direction => 'asc' },
473
+ ] }),
474
+ [ { p => 0, name => 'c' }, { p => 1, name => 'a' }, { p => 1, name => 'b' } ],
475
+ 'tie on primary key broken by secondary asc';
476
+
477
+ # Descending secondary key: all primaries tie, so price desc orders.
478
+ my $items2 = [
479
+ { name => 'a', price => 10 },
480
+ { name => 'a', price => 30 },
481
+ { name => 'a', price => 20 },
482
+ ];
483
+ is $bf->sort($items2, { keys => [
484
+ { key_kind => 'field', key => 'name', compare_type => 'string', direction => 'asc' },
485
+ { key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'desc' },
486
+ ] }),
487
+ [ { name => 'a', price => 30 }, { name => 'a', price => 20 }, { name => 'a', price => 10 } ],
488
+ 'all primary tie → secondary price desc';
489
+ };
490
+
491
+ # `compare_type => 'auto'` (relational-ternary lowering): numeric when
492
+ # both keys look like numbers, else lexical. Mirrors Go's `bf_sort`.
493
+ subtest 'sort — auto compare (relational ternary)' => sub {
494
+ is $bf->sort([3, 1, 2], { keys => [{ key_kind => 'self', compare_type => 'auto', direction => 'asc' }] }),
495
+ [1, 2, 3],
496
+ 'auto numeric asc';
497
+
498
+ is $bf->sort(['charlie', 'alice', 'bob'], { keys => [{ key_kind => 'self', compare_type => 'auto', direction => 'asc' }] }),
499
+ ['alice', 'bob', 'charlie'],
500
+ 'auto non-numeric strings → lexical';
501
+
502
+ # Numeric strings parse as numbers under auto (Go/Perl parity):
503
+ # "10" sorts after "9", not lexically before it.
504
+ is $bf->sort(['10', '9', '100'], { keys => [{ key_kind => 'self', compare_type => 'auto', direction => 'asc' }] }),
505
+ ['9', '10', '100'],
506
+ 'auto numeric strings compare numerically';
507
+ };
508
+
509
+ done_testing;