@barefootjs/mojolicious 0.1.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,940 @@
1
+ /**
2
+ * MojoAdapter - Tests
3
+ *
4
+ * Conformance tests (shared across adapters) + Mojo-specific tests.
5
+ */
6
+
7
+ import { describe, test, expect } from 'bun:test'
8
+ import { MojoAdapter } from '../adapter/mojo-adapter'
9
+ import {
10
+ runAdapterConformanceTests,
11
+ TemplatePrimitiveCaseId,
12
+ } from '@barefootjs/adapter-tests'
13
+ import { renderMojoComponent, PerlNotAvailableError } from '../test-render'
14
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
15
+
16
+ runAdapterConformanceTests({
17
+ name: 'mojo',
18
+ factory: () => new MojoAdapter(),
19
+ render: renderMojoComponent,
20
+ // Dynamic style objects (non-static values) require Perl template
21
+ // interpolation support for JS object literals, not yet implemented.
22
+ // Mojo currently emits invalid Perl silently for this shape — the
23
+ // Go adapter records BF101 via `convertExpressionToGo()` for the
24
+ // same fixture (now contracted via `expectedDiagnostics`), but the
25
+ // Mojo adapter's expression gate doesn't yet lift the same
26
+ // failure into a `CompilerError`, so the fixture stays on `skipJsx`
27
+ // until that gate is extended (#1266 follow-up).
28
+ // `logical-or-jsx`, `nullish-coalescing-jsx`, `branch-map` reference
29
+ // a prop directly inside a conditional branch (`$label`, `$banner`,
30
+ // `$active`). The Mojo adapter emits these as bare Perl variables
31
+ // (`% if ($label) { ... }`) without a corresponding
32
+ // `my $label = ...;` declaration, so Perl rejects the template with
33
+ // "Global symbol requires explicit package name". Same class of
34
+ // Perl-scoping divergence that motivates the existing skips —
35
+ // out of scope for the #971 refactor.
36
+ // Return-position variants of the same divergence —
37
+ // `return-logical-or` / `return-nullish-coalescing` reference
38
+ // `$label` / `$banner` directly; `return-map` iterates over `$items`
39
+ // without a `my` declaration.
40
+ //
41
+ // `static-array-children` / `static-array-from-props` /
42
+ // `static-array-from-props-with-component` are no longer here —
43
+ // they're covered by `expectedDiagnostics` below, asserting that
44
+ // the adapter emits `BF103` / `BF104` at build time instead of
45
+ // silently emitting invalid Perl / unresolved cross-template
46
+ // references (#1266).
47
+ skipJsx: [
48
+ 'style-object-dynamic',
49
+ 'logical-or-jsx',
50
+ 'nullish-coalescing-jsx',
51
+ 'branch-map',
52
+ 'return-logical-or',
53
+ 'return-nullish-coalescing',
54
+ 'return-map',
55
+ // #1297 fixed the harness-side IR emission gate. The remaining
56
+ // gap is adapter-side: the Mojo adapter has no SSR context-
57
+ // propagation mechanism, so `<Ctx.Provider value="dark">` doesn't
58
+ // make `useContext(Ctx)` resolve to `"dark"` at template-eval
59
+ // time — the template emits `<%= $theme %>` against a hash that
60
+ // never receives a `theme` key. Provider SSR coverage on Mojo
61
+ // waits on that adapter feature; see #1297 follow-up.
62
+ 'context-provider',
63
+ // Multi-component fixtures still diverge because Mojo's child
64
+ // template emitter pins the child's `bf-s` to the literal
65
+ // `test_<sN>` (`_scope_id("test_$sid")` in `test-render.ts`)
66
+ // instead of `<ChildName>_<id>_<sN>` like Hono / CSR. Same family
67
+ // of test-harness scope-id plumbing the `componentName` option
68
+ // fixed on the Hono side. Separate follow-up.
69
+ 'toggle-shared',
70
+ 'reactive-props',
71
+ 'props-reactivity-comparison',
72
+ ],
73
+ // Per-fixture build-time contracts for shapes the Mojo adapter
74
+ // intentionally refuses to lower. Owned by this adapter test file
75
+ // (not by the shared fixtures) so adding a new adapter doesn't
76
+ // require touching any cross-adapter file.
77
+ expectedDiagnostics: {
78
+ // Sibling-imported child component in a loop body: Mojo emits
79
+ // a cross-template call that needs separate registration. BF103
80
+ // makes the requirement loud. (The barefoot CLI passes
81
+ // `siblingTemplatesRegistered: true` so CLI builds suppress it.)
82
+ 'static-array-children': [{ code: 'BF103', severity: 'error' }],
83
+ // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
84
+ // call it inside a keyed `.map`. Same BF103 surface as the
85
+ // synthetic `static-array-children` above — pinned at adapter
86
+ // level so the shared-component corpus stays adapter-neutral.
87
+ 'todo-app': [{ code: 'BF103', severity: 'error' }],
88
+ 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
89
+ // Array-destructure loop param (`([k, v]) => ...`) lowers to
90
+ // invalid Perl (`% my $[k, v] = $entries->[$_i];`).
91
+ 'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
92
+ // Both BF103 (imported child) and BF104 (destructure) fire.
93
+ 'static-array-from-props-with-component': [
94
+ { code: 'BF103', severity: 'error' },
95
+ { code: 'BF104', severity: 'error' },
96
+ ],
97
+ // #1310: rest destructure in .map() callback. Hono / CSR lower
98
+ // these via the inline residual-object accessor (#1309); the Mojo
99
+ // adapter's loop emitter raises the generic BF104 destructure
100
+ // refusal regardless of whether the binding is rest or plain.
101
+ // Pinning the contract here makes the limitation declarative.
102
+ 'rest-destructure-object-in-map': [{ code: 'BF104', severity: 'error' }],
103
+ // #1244 catalog: rest spread back onto the root element. Same
104
+ // refusal shape as the read-only variant above — `paramBindings`
105
+ // is non-empty so BF104 fires regardless of how `rest` is used.
106
+ 'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
107
+ 'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
108
+ 'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
109
+ // #1244 stress catalog #11 (#1322): JS object literal in an
110
+ // attribute value (`style={{ background: bg(), color: fg() }}`) has
111
+ // no idiomatic Mojo template form. `refuseUnsupportedAttrExpression`
112
+ // surfaces BF101 with a wrap-in-`/* @client */` suggestion, matching
113
+ // the Go adapter's behaviour.
114
+ 'style-3-signals': [{ code: 'BF101', severity: 'error' }],
115
+ // #1244 stress catalog #12 (#1323): tagged-template-literal call
116
+ // (`cn\`base \${tone()}\``) — same family as #1322 above and refused
117
+ // via the same gate.
118
+ 'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
119
+ // #1443: `[a, b].filter(Boolean).join(' ')` (the registry Slot's
120
+ // shape) now lowers to `join(' ', @{[grep { $_ } @{[$a, $b]}]})`.
121
+ // No BF101 expected — pinned positively via the
122
+ // `branch-local-filter-join` template-output test below.
123
+ //
124
+ // #1448 Tier A — JS Array / String methods that the Mojo adapter
125
+ // hasn't lowered yet. Each row drops once the corresponding
126
+ // method PR lands. Hono / CSR pass these out of the box (they
127
+ // evaluate JS at runtime) so the pin only applies here.
128
+ //
129
+ // `array-includes` / `string-includes` no longer pinned — both
130
+ // shapes lower via the shared `array-method` IR + `bf->includes`
131
+ // runtime dispatch (#1448 Tier A first PR).
132
+ // `array-indexOf` / `array-lastIndexOf` no longer pinned —
133
+ // value-equality `bf->index_of` / `bf->last_index_of` helpers
134
+ // handle the shape (#1448 Tier A second PR).
135
+ // `array-at` no longer pinned — `bf->at` (Mojo) / `bf_at` (Go)
136
+ // handle the negative-index lookup (#1448 Tier A third PR).
137
+ // `array-concat` no longer pinned — `bf->concat` (Mojo) /
138
+ // `bf_concat` (Go) merge two arrays into a new array
139
+ // (#1448 Tier A fourth PR).
140
+ // `array-slice` no longer pinned — `bf->slice` (Mojo) /
141
+ // `bf_slice` (Go) carve out a sub-range with JS-compat
142
+ // negative-index / out-of-bounds clamping (#1448 Tier A
143
+ // fifth PR).
144
+ // `array-reverse` / `array-toReversed` no longer pinned —
145
+ // both share the `bf->reverse` / `bf_reverse` helper since
146
+ // SSR templates render a snapshot and the JS mutate-vs-new
147
+ // distinction has no template-level meaning (#1448 Tier A
148
+ // sixth PR).
149
+ // `string-toLowerCase` / `string-toUpperCase` no longer pinned —
150
+ // Perl's native `lc` / `uc` (Mojo) and pre-existing
151
+ // `bf_lower` / `bf_upper` (Go) handle the JS method names
152
+ // (#1448 Tier A seventh + eighth PRs).
153
+ // `string-trim` no longer pinned — pre-existing `bf_trim`
154
+ // (Go) and new `bf->trim` helper (Mojo) handle the strip
155
+ // (#1448 Tier A ninth PR, closing out Tier A).
156
+ // #1448 catalog — `.find` / `.findIndex` have no Mojo lowering
157
+ // yet (no `array-method` IR variant, no emitter), so the
158
+ // Mojo-specific gate in `convertExpressionToPerl` refuses them
159
+ // up front. `.join` is NOT pinned here — it's lifted to the
160
+ // `array-method` IR by the parser and `renderArrayMethod`'s
161
+ // `case 'join'` emits `join(sep, @{arr})` correctly; the
162
+ // text-expression form is routed through the same AST path.
163
+ 'array-find': [{ code: 'BF101', severity: 'error' }],
164
+ 'array-findIndex': [{ code: 'BF101', severity: 'error' }],
165
+ },
166
+ // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
167
+ // via `MojoAdapter.templatePrimitives` (#1189). The two remaining
168
+ // cases stay skipped because the V1 registry is identifier-path-
169
+ // only and explicit:
170
+ // - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
171
+ // the registry and can't be rendered server-side without
172
+ // user-supplied helper mappings.
173
+ // - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
174
+ // too, same reason.
175
+ // Adding new entries to `templatePrimitives` should narrow this
176
+ // skip set; see `MOJO_TEMPLATE_PRIMITIVES` in `mojo-adapter.ts`
177
+ // for the full V1 surface.
178
+ skipTemplatePrimitives: new Set([
179
+ TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
180
+ TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
181
+ ]),
182
+ // Mojo `renderLoop` does not yet emit the `bf->comment("loop:<id>")`
183
+ // boundary markers when the loop is `@client` (Hono and Go both do).
184
+ // The client runtime relies on these markers to locate the insertion
185
+ // anchor when hydrating the array; without them, mapArray() resolves
186
+ // anchor = null and appends after sibling markers (#872 parity).
187
+ // Tracked as a follow-up; remove from this set when Mojo emits the
188
+ // boundary pair for clientOnly loops too.
189
+ skipMarkerConformance: new Set([
190
+ 'client-only',
191
+ 'client-only-loop-with-sibling-cond',
192
+ // Same as Hono: `/* @client */` markers on TodoApp's keyed `.map`
193
+ // intentionally elide a slot id from the SSR template that the IR
194
+ // still declares (s6). See hono-adapter.test for the contract.
195
+ 'todo-app',
196
+ ]),
197
+ onRenderError: (err, id) => {
198
+ if (err instanceof PerlNotAvailableError) {
199
+ console.log(`Skipping [${id}]: ${err.message}`)
200
+ return true
201
+ }
202
+ return false
203
+ },
204
+ })
205
+
206
+ // =============================================================================
207
+ // Helpers
208
+ // =============================================================================
209
+
210
+ function compileToIR(source: string, adapter?: MojoAdapter): ComponentIR {
211
+ const result = compileJSX(source.trimStart(), 'test.tsx', {
212
+ adapter: adapter ?? new MojoAdapter(),
213
+ outputIR: true,
214
+ })
215
+ const irFile = result.files.find(f => f.type === 'ir')
216
+ if (!irFile) throw new Error('No IR output')
217
+ return JSON.parse(irFile.content) as ComponentIR
218
+ }
219
+
220
+ function compileAndGenerate(source: string, adapter?: MojoAdapter) {
221
+ const a = adapter ?? new MojoAdapter()
222
+ const ir = compileToIR(source, a)
223
+ return a.generate(ir)
224
+ }
225
+
226
+ // =============================================================================
227
+ // Mojo-Specific Tests
228
+ // =============================================================================
229
+
230
+ describe('MojoAdapter - Template Generation', () => {
231
+ test('generates basic element with scope marker', () => {
232
+ const result = compileAndGenerate(`
233
+ export function Hello() {
234
+ return <div>Hello</div>
235
+ }
236
+ `)
237
+ expect(result.template).toContain('<div')
238
+ expect(result.template).toContain('Hello')
239
+ expect(result.template).toContain('bf-s=')
240
+ })
241
+
242
+ test('generates .html.ep extension', () => {
243
+ const adapter = new MojoAdapter()
244
+ expect(adapter.extension).toBe('.html.ep')
245
+ })
246
+
247
+ test('generates conditional with Perl if/else', () => {
248
+ const result = compileAndGenerate(`
249
+ "use client"
250
+ import { createSignal } from "@barefootjs/client"
251
+
252
+ export function Toggle() {
253
+ const [active, setActive] = createSignal(false)
254
+ return <div>{active() ? 'On' : 'Off'}</div>
255
+ }
256
+ `)
257
+ expect(result.template).toContain('% if')
258
+ expect(result.template).toContain('% }')
259
+ })
260
+
261
+ test('generates loop with Perl for', () => {
262
+ const result = compileAndGenerate(`
263
+ "use client"
264
+ import { createSignal } from "@barefootjs/client"
265
+
266
+ export function List() {
267
+ const [items, setItems] = createSignal<string[]>([])
268
+ return <ul>{items().map(item => <li>{item}</li>)}</ul>
269
+ }
270
+ `)
271
+ expect(result.template).toContain('% for my')
272
+ // Markers are scoped per-call-site (#1087): `bf->comment("loop:<id>")`.
273
+ expect(result.template).toMatch(/bf->comment\("loop:[^"]+"\)/)
274
+ expect(result.template).toMatch(/bf->comment\("\/loop:[^"]+"\)/)
275
+ })
276
+
277
+ test('generates script registration for client components', () => {
278
+ const result = compileAndGenerate(`
279
+ "use client"
280
+ import { createSignal } from "@barefootjs/client"
281
+
282
+ export function Counter() {
283
+ const [count, setCount] = createSignal(0)
284
+ return <div>{count()}</div>
285
+ }
286
+ `)
287
+ expect(result.template).toContain("bf->register_script")
288
+ expect(result.template).toContain('barefoot.js')
289
+ expect(result.template).toContain('Counter.client.js')
290
+ })
291
+
292
+ test('does not generate script registration for static components', () => {
293
+ const result = compileAndGenerate(`
294
+ export function Static() {
295
+ return <div>Static content</div>
296
+ }
297
+ `)
298
+ expect(result.template).not.toContain("bf->register_script")
299
+ })
300
+
301
+ test('forwards JSX children via begin/end capture (#1202)', () => {
302
+ const result = compileAndGenerate(`
303
+ 'use client'
304
+ export function Page() {
305
+ return <main><Card><span>hello</span><span>world</span></Card></main>
306
+ }
307
+ `)
308
+ // Capture lives in its own action so the inner `%>` can't close
309
+ // the outer render_child tag.
310
+ expect(result.template).toMatch(/<% my \$bf_children_\w+ = begin %>/)
311
+ expect(result.template).toContain('<span>hello</span><span>world</span>')
312
+ expect(result.template).toContain('<% end %>')
313
+ expect(result.template).toMatch(
314
+ /bf->render_child\('card'.*children => \$bf_children_\w+\)/,
315
+ )
316
+ })
317
+
318
+ test('omits children entry when component has no JSX children', () => {
319
+ const result = compileAndGenerate(`
320
+ 'use client'
321
+ export function Page() {
322
+ return <main><Card label="x" /></main>
323
+ }
324
+ `)
325
+ expect(result.template).not.toContain('begin %>')
326
+ expect(result.template).not.toContain('children =>')
327
+ })
328
+
329
+ describe('emits BF101 for JS-only filter / array patterns the Mojo adapter cannot lower to EP', () => {
330
+ // These patterns previously fell through Mojo's regex pipeline and
331
+ // emitted broken Embedded Perl silently (e.g. `$items->{filter}->[...]`
332
+ // for a destructured filter, `[grep {...}]->{length}` for nested
333
+ // higher-order). The Go adapter rejects them via its
334
+ // `convertExpressionToGo` / `renderFilterExpr` gates with BF101;
335
+ // these tests pin Mojo to the same contract so users on
336
+ // non-JS-runtime adapters see a compile error and can either
337
+ // rewrite or add `/* @client */`.
338
+ const wrap = (body: string) => `'use client'
339
+ import { createSignal } from '@barefootjs/client'
340
+ export function C() {
341
+ const [items] = createSignal<any[]>([])
342
+ return ${body}
343
+ }`
344
+
345
+ // #1443 follow-up: destructured filter param and function-keyword
346
+ // filter no longer fall in this BF101 group — they lower cleanly
347
+ // via parser-side normalisation (see `lowers .filter(({done}) =>
348
+ // done) ...` parser tests + the Mojo positive-output test below).
349
+ // The nested-higher-order-in-filter-predicate shape also lowers
350
+ // now (#1443 PR4) — moved to a positive-output test below.
351
+ const cases: { name: string; body: string; needle: string }[] = [
352
+ { name: 'reduce', body: `<div>{items().reduce((s, x) => s + x, 0)}</div>`, needle: '.reduce(' },
353
+ { name: 'forEach', body: `<ul>{items().forEach(x => x)}</ul>`, needle: '.forEach(' },
354
+ { name: 'flatMap', body: `<ul>{items().flatMap(x => x.tags).map(t => <li key={t}>{t}</li>)}</ul>`, needle: '.flatMap(' },
355
+ ]
356
+
357
+ for (const { name, body, needle } of cases) {
358
+ test(`${name} → BF101`, () => {
359
+ const adapter = new MojoAdapter()
360
+ const result = compileJSX(wrap(body), 'C.tsx', { adapter })
361
+ const bf101 = result.errors?.filter(e => e.code === 'BF101') ?? []
362
+ expect(bf101.length).toBeGreaterThan(0)
363
+ expect(bf101.some(e => e.message.includes(needle))).toBe(true)
364
+ })
365
+
366
+ test(`${name} + /* @client */ suppresses BF101`, () => {
367
+ const adapter = new MojoAdapter()
368
+ const wrappedBody = body.replace(/\{(?!\/\* @client \*\/)/, '{/* @client */ ')
369
+ const result = compileJSX(wrap(wrappedBody), 'C.tsx', { adapter })
370
+ const bf101 = result.errors?.filter(e => e.code === 'BF101') ?? []
371
+ expect(bf101).toEqual([])
372
+ })
373
+ }
374
+ })
375
+
376
+ test('lowers .filter(({done}) => done).map(...) — destructured filter param (#1443)', () => {
377
+ // Pre-#1443 the destructured arrow rejected at the parser and the
378
+ // surrounding `.map()` loop fell back to a BF101 path. With the
379
+ // parser rewriting `({done}) => done` to `_t => _t.done`, the
380
+ // adapter's existing `IRLoop.filterPredicate` path renders the
381
+ // chain as a Perl `for` over `grep { $_->{done} } @{$items}`.
382
+ const adapter = new MojoAdapter()
383
+ const result = compileJSX(`'use client'
384
+ import { createSignal } from '@barefootjs/client'
385
+ export function C() {
386
+ const [items] = createSignal<any[]>([])
387
+ return <ul>{items().filter(({done}) => done).map(t => <li key={t.id}>{t.name}</li>)}</ul>
388
+ }`, 'C.tsx', { adapter })
389
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
390
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
391
+ expect(template).toContain('grep { $_->{done} } @{$items}')
392
+ })
393
+
394
+ test('lowers nested .filter(...).length > 0 in outer filter predicate (#1443 PR4)', () => {
395
+ // Pre-#1443 PR4: the predicate `x => x.tags.filter(t => t.active).length > 0`
396
+ // emitted `[grep { ... } ...]->{length}` — a hash-key lookup on
397
+ // an anonymous array ref, undef at runtime. The
398
+ // `containsHigherOrder` gate refused this outright with BF101.
399
+ // PR4 fixes the `member` emit for `.length` on higher-order
400
+ // objects to produce `scalar(@{...})` and removes the gate, so
401
+ // the canonical "tags have at least one active" shape lowers
402
+ // to valid EP.
403
+ const adapter = new MojoAdapter()
404
+ const result = compileJSX(`'use client'
405
+ import { createSignal } from '@barefootjs/client'
406
+ export function C() {
407
+ const [items] = createSignal<any[]>([])
408
+ return <ul>{items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => <li key={t.id}>{t.name}</li>)}</ul>
409
+ }`, 'C.tsx', { adapter })
410
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
411
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
412
+ expect(template).toContain('scalar(@{[grep { $_->{active} } @{$t->{tags}}]})')
413
+ })
414
+
415
+ test('lowers .filter(function (x) { return x.done }).map(...) — function-keyword filter (#1443)', () => {
416
+ // Function expressions with a single `return <expr>` body normalise
417
+ // to the arrow-fn IR shape at parse time, so the higher-order
418
+ // detector + adapter lowering paths fire alongside their arrow
419
+ // counterparts.
420
+ const adapter = new MojoAdapter()
421
+ const result = compileJSX(`'use client'
422
+ import { createSignal } from '@barefootjs/client'
423
+ export function C() {
424
+ const [items] = createSignal<any[]>([])
425
+ return <ul>{items().filter(function (x) { return x.done }).map(t => <li key={t.id}>{t.name}</li>)}</ul>
426
+ }`, 'C.tsx', { adapter })
427
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
428
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
429
+ expect(template).toContain('grep { $_->{done} } @{$items}')
430
+ })
431
+
432
+ test('lowers the registry Slot\'s [a, b].filter(Boolean).join(\' \') chain (#1443)', () => {
433
+ // The registry `<Slot>` builds its merged className via
434
+ // `[className, childClass].filter(Boolean).join(' ')`. Pre-#1443
435
+ // each link in the chain (array literal, `Boolean` callable
436
+ // filter, `.join`) hit a separate refusal gate and the chain
437
+ // emitted BF101 — making the scaffold `<Button>` / `<Card>`
438
+ // unusable on Mojo. The fix lowers all three to Embedded Perl
439
+ // (`join(' ', @{[grep { $_ } @{[...]}]})`), unblocking the
440
+ // registry surface. The #1421 recursion guard stays in place
441
+ // as defence in depth for other unsupported shapes, but this
442
+ // specific chain no longer reaches the loop because the parser
443
+ // succeeds.
444
+ const adapter = new MojoAdapter()
445
+ const result = compileJSX(
446
+ `
447
+ "use client"
448
+ function Slot({ children, className }: { children?: unknown; className?: string }) {
449
+ if (children) {
450
+ const merged = [className].filter(Boolean).join(' ')
451
+ return <div className={merged}>x</div>
452
+ }
453
+ return <div>fallback</div>
454
+ }
455
+ export { Slot }
456
+ `.trimStart(),
457
+ 'slot.tsx',
458
+ { adapter },
459
+ )
460
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
461
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
462
+ expect(template).toContain(`join(' ', @{[grep { $_ } @{[$className]}]})`)
463
+ })
464
+
465
+ test('lowers .includes(x) on an array prop via bf->includes(...) (#1448 Tier A)', () => {
466
+ // Pre-#1448: `items.includes(target)` rejected at the parser
467
+ // (`UNSUPPORTED_METHODS`) and surfaced as BF101. The lowering
468
+ // now routes through the shared `array-method` IR + the
469
+ // `bf->includes` helper, which inspects `ref()` to dispatch
470
+ // between ARRAY-ref element search and scalar substring search.
471
+ //
472
+ // The bare `bf->` form (no `$` prefix) matches every other
473
+ // helper emit in this adapter; the standalone Mojo::Template
474
+ // test render in `test-render.ts` rewrites it to `$bf->` so
475
+ // both render paths stay consistent.
476
+ const adapter = new MojoAdapter()
477
+ const result = compileJSX(`'use client'
478
+ import { createSignal } from '@barefootjs/client'
479
+ export function C() {
480
+ const [items] = createSignal<string[]>([])
481
+ const [target] = createSignal('x')
482
+ return <div>{items().includes(target()) ? 'yes' : 'no'}</div>
483
+ }`, 'C.tsx', { adapter })
484
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
485
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
486
+ expect(template).toContain('bf->includes($items, $target)')
487
+ // Defensive pin: no leaked `$bf->` (would survive the test-render
488
+ // patch as `$$bf->` and crash perl with "Not a SCALAR reference").
489
+ expect(template).not.toContain('$bf->includes')
490
+ })
491
+
492
+ test('lowers .includes(sub) on a string prop via bf->includes(...) (#1448 Tier A)', () => {
493
+ // String receiver shares the IR node with the array form; the
494
+ // helper's `ref() ne 'ARRAY'` branch falls through to
495
+ // `index(...) != -1`. Pinning the emit shape — same emitter
496
+ // surface, different runtime behaviour.
497
+ const adapter = new MojoAdapter()
498
+ const result = compileJSX(`'use client'
499
+ import { createSignal } from '@barefootjs/client'
500
+ export function C() {
501
+ const [value] = createSignal('hello world')
502
+ const [needle] = createSignal('world')
503
+ return <div>{value().includes(needle()) ? 'yes' : 'no'}</div>
504
+ }`, 'C.tsx', { adapter })
505
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
506
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
507
+ expect(template).toContain('bf->includes($value, $needle)')
508
+ expect(template).not.toContain('$bf->includes')
509
+ })
510
+
511
+ test('lowers .indexOf(x) on an array prop via bf->index_of(...) (#1448 Tier A)', () => {
512
+ // Value-equality search. Mojo's `bf->index_of` walks the array
513
+ // forward and returns the first matching index (or -1). The
514
+ // existing `.find` lowering uses Perl `grep` for struct-field
515
+ // find — disjoint surface, disjoint helpers.
516
+ const adapter = new MojoAdapter()
517
+ const result = compileJSX(`'use client'
518
+ import { createSignal } from '@barefootjs/client'
519
+ export function C() {
520
+ const [items] = createSignal<string[]>([])
521
+ const [target] = createSignal('x')
522
+ return <div>idx: {items().indexOf(target())}</div>
523
+ }`, 'C.tsx', { adapter })
524
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
525
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
526
+ expect(template).toContain('bf->index_of($items, $target)')
527
+ expect(template).not.toContain('$bf->index_of')
528
+ })
529
+
530
+ test('lowers .lastIndexOf(x) on an array prop via bf->last_index_of(...) (#1448 Tier A)', () => {
531
+ // Backward-walk variant. Sharing a helper module with index_of
532
+ // keeps the dispatch trivial (`_array_index_of(..., $reverse)`)
533
+ // and the per-direction emit a one-liner.
534
+ const adapter = new MojoAdapter()
535
+ const result = compileJSX(`'use client'
536
+ import { createSignal } from '@barefootjs/client'
537
+ export function C() {
538
+ const [items] = createSignal<string[]>([])
539
+ const [target] = createSignal('x')
540
+ return <div>last: {items().lastIndexOf(target())}</div>
541
+ }`, 'C.tsx', { adapter })
542
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
543
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
544
+ expect(template).toContain('bf->last_index_of($items, $target)')
545
+ })
546
+
547
+ test('lowers .at(-1) on an array prop via bf->at(...) (#1448 Tier A)', () => {
548
+ // Negative indices are the canonical reason an author reaches
549
+ // for `.at` over `[i]`; pinning `.at(-1)` (last element) — a
550
+ // positive-only lowering would still pass `.at(0)` but fail
551
+ // here.
552
+ const adapter = new MojoAdapter()
553
+ const result = compileJSX(`function A({ items }: { items: string[] }) {
554
+ return <div>last: {items.at(-1)}</div>
555
+ }
556
+ export { A }`, 'A.tsx', { adapter })
557
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
558
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
559
+ expect(template).toContain('bf->at($items, -1)')
560
+ expect(template).not.toContain('$bf->at(')
561
+ })
562
+
563
+ test('lowers .toLowerCase() via Perl native lc (#1448 Tier A)', () => {
564
+ // Perl's `lc` is the native lowering — no helper needed.
565
+ // Defensive: must not emit a `$lc(...)` form (which the
566
+ // test-render patch would mangle); emit must be the bare
567
+ // `lc(...)` call so it stays well-formed in both the
568
+ // standalone test renderer and real Mojolicious.
569
+ const adapter = new MojoAdapter()
570
+ const result = compileJSX(`function A({ value }: { value: string }) {
571
+ return <div>{value.toLowerCase()}</div>
572
+ }
573
+ export { A }`, 'A.tsx', { adapter })
574
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
575
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
576
+ expect(template).toContain('lc($value)')
577
+ expect(template).not.toContain('$lc(')
578
+ })
579
+
580
+ test('lowers .toUpperCase() via Perl native uc (#1448 Tier A)', () => {
581
+ // Mirrors toLowerCase — Perl's `uc` builtin, no helper.
582
+ const adapter = new MojoAdapter()
583
+ const result = compileJSX(`function A({ value }: { value: string }) {
584
+ return <div>{value.toUpperCase()}</div>
585
+ }
586
+ export { A }`, 'A.tsx', { adapter })
587
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
588
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
589
+ expect(template).toContain('uc($value)')
590
+ expect(template).not.toContain('$uc(')
591
+ })
592
+
593
+ test('lowers .trim() via bf->trim helper (#1448 Tier A)', () => {
594
+ // No native Perl `trim`; the helper wraps a single regex so an
595
+ // undef receiver (common for missing-prop case) doesn't trigger
596
+ // a substitution-on-undef warning.
597
+ const adapter = new MojoAdapter()
598
+ const result = compileJSX(`function A({ value }: { value: string }) {
599
+ return <div>[{value.trim()}]</div>
600
+ }
601
+ export { A }`, 'A.tsx', { adapter })
602
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
603
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
604
+ expect(template).toContain('bf->trim($value)')
605
+ expect(template).not.toContain('$bf->trim')
606
+ })
607
+
608
+ test('lowers .reverse().join(\' \') via bf->reverse + join (#1448 Tier A)', () => {
609
+ // SSR templates render a snapshot, so `.reverse` and
610
+ // `.toReversed` share a Mojo lowering — both return a new
611
+ // ARRAY ref so downstream `.join(...)` composes naturally.
612
+ const adapter = new MojoAdapter()
613
+ const result = compileJSX(`function A({ items }: { items: string[] }) {
614
+ return <div>{items.reverse().join(' ')}</div>
615
+ }
616
+ export { A }`, 'A.tsx', { adapter })
617
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
618
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
619
+ expect(template).toContain("join(' ', @{bf->reverse($items)})")
620
+ expect(template).not.toContain('$bf->reverse')
621
+ })
622
+
623
+ test('lowers .toReversed().join(\' \') via the same bf->reverse helper', () => {
624
+ const adapter = new MojoAdapter()
625
+ const result = compileJSX(`function A({ items }: { items: string[] }) {
626
+ return <div>{items.toReversed().join(' ')}</div>
627
+ }
628
+ export { A }`, 'A.tsx', { adapter })
629
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
630
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
631
+ expect(template).toContain("join(' ', @{bf->reverse($items)})")
632
+ })
633
+
634
+ test('lowers .slice(start, end).join(\' \') via bf->slice + join (#1448 Tier A)', () => {
635
+ // 2-arg form. Canonical Tier A fixture pins the start+end shape.
636
+ const adapter = new MojoAdapter()
637
+ const result = compileJSX(`function A({ items }: { items: string[] }) {
638
+ return <div>{items.slice(1, 3).join(' ')}</div>
639
+ }
640
+ export { A }`, 'A.tsx', { adapter })
641
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
642
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
643
+ expect(template).toContain("join(' ', @{bf->slice($items, 1, 3)})")
644
+ expect(template).not.toContain('$bf->slice')
645
+ })
646
+
647
+ test('lowers .slice(start) (1-arg) via bf->slice with end=undef', () => {
648
+ // 1-arg form. The Perl helper treats undef `end` as
649
+ // "to length", matching the Go variadic-arg-absent case.
650
+ const adapter = new MojoAdapter()
651
+ const result = compileJSX(`function A({ items }: { items: string[] }) {
652
+ return <div>{items.slice(2).join(' ')}</div>
653
+ }
654
+ export { A }`, 'A.tsx', { adapter })
655
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
656
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
657
+ expect(template).toContain("join(' ', @{bf->slice($items, 2, undef)})")
658
+ })
659
+
660
+ test('lowers .concat(other).join(\' \') via bf->concat + join (#1448 Tier A)', () => {
661
+ // Composition pin: the canonical Tier A fixture
662
+ // (`packages/adapter-tests/fixtures/methods/array-concat.ts`)
663
+ // chains `.concat(...).join(' ')`. The Mojo helper returns an
664
+ // ARRAY ref so the downstream `@{...}` dereference in `join(...)`
665
+ // works without an extra coercion.
666
+ const adapter = new MojoAdapter()
667
+ const result = compileJSX(`function A({ left, right }: { left: string[]; right: string[] }) {
668
+ return <div>{left.concat(right).join(' ')}</div>
669
+ }
670
+ export { A }`, 'A.tsx', { adapter })
671
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
672
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
673
+ expect(template).toContain("join(' ', @{bf->concat($left, $right)})")
674
+ expect(template).not.toContain('$bf->concat')
675
+ })
676
+
677
+ test('does not leak module-level export statements into the .html.ep template', () => {
678
+ // Regression: trailing `export { Name }` / `export type { ... }` lines
679
+ // were concatenated into the single-component template content, so
680
+ // Mojolicious rendered them as visible HTML text (the create-barefootjs
681
+ // scaffold's registry Button has this shape).
682
+ const result = compileJSX(
683
+ `
684
+ type ButtonVariant = 'default' | 'secondary'
685
+
686
+ function Button(props: { variant?: ButtonVariant, children?: unknown }) {
687
+ return <button className={props.variant ?? 'default'}>{props.children}</button>
688
+ }
689
+
690
+ export { Button }
691
+ export type { ButtonVariant }
692
+ `.trimStart(),
693
+ 'button.tsx',
694
+ { adapter: new MojoAdapter() },
695
+ )
696
+ const template = result.files.find(f => f.type === 'markedTemplate')
697
+ expect(template).toBeDefined()
698
+ expect(template!.content).not.toContain('export {')
699
+ expect(template!.content).not.toContain('export type')
700
+ })
701
+ })
702
+
703
+ describe('MojoAdapter - templatePrimitives (#1189)', () => {
704
+ // The registry fires when the call appears DIRECTLY in a JSX
705
+ // expression position. Chained-const usage (`const j =
706
+ // JSON.stringify(...); <div data-x={j}>`) routes through the
707
+ // adapter's own const-resolution path; the conformance test for
708
+ // that shape inspects the CLIENT JS, where the call IS inlined
709
+ // (relocate accepts via the registry's boolean-acceptance side).
710
+
711
+ test('JSON.stringify(props.x) emits bf->json($x) in SSR template', () => {
712
+ const result = compileAndGenerate(`
713
+ 'use client'
714
+ export function Foo(props: { config: object }) {
715
+ return <div data-config={JSON.stringify(props.config)}>hi</div>
716
+ }
717
+ `)
718
+ expect(result.template).toContain('bf->json($config)')
719
+ expect(result.template).not.toContain('JSON.stringify')
720
+ })
721
+
722
+ test('Math.floor(props.score) emits bf->floor($score) in SSR template', () => {
723
+ const result = compileAndGenerate(`
724
+ 'use client'
725
+ export function Foo(props: { score: number }) {
726
+ return <div data-rounded={Math.floor(props.score)}>hi</div>
727
+ }
728
+ `)
729
+ expect(result.template).toContain('bf->floor($score)')
730
+ expect(result.template).not.toContain('Math.floor')
731
+ })
732
+
733
+ test('Math.ceil / Math.round map to bf->ceil / bf->round', () => {
734
+ const ceilResult = compileAndGenerate(`
735
+ 'use client'
736
+ export function Foo(props: { v: number }) {
737
+ return <div data-x={Math.ceil(props.v)}>hi</div>
738
+ }
739
+ `)
740
+ expect(ceilResult.template).toContain('bf->ceil($v)')
741
+
742
+ const roundResult = compileAndGenerate(`
743
+ 'use client'
744
+ export function Foo(props: { v: number }) {
745
+ return <div data-x={Math.round(props.v)}>hi</div>
746
+ }
747
+ `)
748
+ expect(roundResult.template).toContain('bf->round($v)')
749
+ })
750
+
751
+ test('String(props.x) and Number(props.x) emit bf->string / bf->number', () => {
752
+ const stringResult = compileAndGenerate(`
753
+ 'use client'
754
+ export function Foo(props: { v: number }) {
755
+ return <div data-x={String(props.v)}>hi</div>
756
+ }
757
+ `)
758
+ expect(stringResult.template).toContain('bf->string($v)')
759
+
760
+ const numberResult = compileAndGenerate(`
761
+ 'use client'
762
+ export function Foo(props: { v: string }) {
763
+ return <div data-x={Number(props.v)}>hi</div>
764
+ }
765
+ `)
766
+ expect(numberResult.template).toContain('bf->number($v)')
767
+ })
768
+
769
+ test('nested primitive call (Math.floor(Number(props.x))) chains correctly', () => {
770
+ const result = compileAndGenerate(`
771
+ 'use client'
772
+ export function Foo(props: { v: string }) {
773
+ return <div data-x={Math.floor(Number(props.v))}>hi</div>
774
+ }
775
+ `)
776
+ expect(result.template).toContain('bf->floor(bf->number($v))')
777
+ })
778
+
779
+ test('registry exposes the V1 callee surface', () => {
780
+ // Pin the V1 surface so a future refactor doesn't accidentally
781
+ // drop a primitive. New entries are additive — extend this
782
+ // list rather than replace.
783
+ const a = new MojoAdapter()
784
+ const keys = Object.keys(a.templatePrimitives ?? {}).sort()
785
+ expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.round', 'Number', 'String'])
786
+ })
787
+
788
+ test('unregistered identifier-path callee is NOT accepted', () => {
789
+ const a = new MojoAdapter()
790
+ expect(a.templatePrimitives?.['customSerialize']).toBeUndefined()
791
+ })
792
+
793
+ test('wrong-arity primitive call falls back instead of emitting invalid Perl', () => {
794
+ // V1 emit fns expect 1 arg. A 2-arg `JSON.stringify(x, replacer)`
795
+ // must not produce `bf->json($x, $replacer)` (which Perl would
796
+ // accept silently) — the arity gate records BF101 and leaves
797
+ // the call un-substituted.
798
+ const result = compileAndGenerate(`
799
+ 'use client'
800
+ export function Foo(props: { config: object; replacer: any }) {
801
+ return <div data-x={JSON.stringify(props.config, props.replacer)}>hi</div>
802
+ }
803
+ `)
804
+ expect(result.template).not.toContain('bf->json')
805
+ })
806
+ })
807
+
808
+ describe('MojoAdapter - render_child template-parts dispatch (#1275)', () => {
809
+ // The IR producer collapses a structured `template` AttrValue into
810
+ // `expression` for component props (so the value can flow through
811
+ // runtime hydration), but it keeps the parsed parts on
812
+ // `ExpressionAttr.parts`. The Mojo adapter must dispatch to
813
+ // `convertTemplateLiteralPartsToPerl` when those parts are present
814
+ // — otherwise the bare JS source leaks into the Perl template (the
815
+ // original #1275 failure: a `({...})[key]` Perl parse error and the
816
+ // scaffold's Button rendering with no `class` attribute end-to-end).
817
+ test('record-index-lookup via child prop emits Perl hash lookup, not raw JS', () => {
818
+ const result = compileAndGenerate(`
819
+ import { Slot } from './slot'
820
+ export function V({ variant }: { variant: 'a' | 'b' }) {
821
+ const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
822
+ return <Slot className={\`base \${classes[variant]}\`}>hi</Slot>
823
+ }
824
+ `)
825
+ // The Perl hash form means the parts dispatch fired.
826
+ expect(result.template).toContain("'a' => 'class-a'")
827
+ expect(result.template).toContain("'b' => 'class-b'")
828
+ expect(result.template).toContain("->{$variant}")
829
+ // Negative pin: the raw JS object-literal shape must NOT survive
830
+ // into the Mojo template. The original bug emitted
831
+ // `({"a": "class-a", "b": "class-b"})[variant]` directly into the
832
+ // `render_child` argument string.
833
+ expect(result.template).not.toContain('{"a":')
834
+ expect(result.template).not.toContain('"a": "class-a"')
835
+ })
836
+
837
+ test('intermediate-const composition (Button shape) carries through', () => {
838
+ const result = compileAndGenerate(`
839
+ import { Slot } from './slot'
840
+ export function V({ variant }: { variant: 'a' | 'b' }) {
841
+ const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
842
+ const composed = \`base \${classes[variant]}\`
843
+ return <Slot className={composed}>hi</Slot>
844
+ }
845
+ `)
846
+ expect(result.template).toContain("'a' => 'class-a'")
847
+ expect(result.template).toContain("->{$variant}")
848
+ })
849
+ })
850
+
851
+ // =============================================================================
852
+ // #1448 Tier A — fixture-driven lowering pins
853
+ // =============================================================================
854
+ //
855
+ // The conformance test suite (runAdapterConformanceTests above) renders
856
+ // every fixture end-to-end through perl + Mojolicious and compares HTML —
857
+ // the strongest possible signal — but it short-circuits with
858
+ // `PerlNotAvailableError` on hosts without Mojolicious installed (CI ARM
859
+ // runners, contributor laptops without `cpanm Mojolicious`, the sandbox
860
+ // each Tier A PR was developed in). Those skips mean a lowering can
861
+ // silently regress to BF101 / wrong helper-call shape and the conformance
862
+ // run still passes "green" on those hosts.
863
+ //
864
+ // This block compiles each Tier A fixture's `source` through the
865
+ // adapter and pins the emitted helper-call substring directly on the
866
+ // template string. No perl needed; runs on every host. The expected
867
+ // substring uses the same `$prop` form the fixture's prop bindings
868
+ // produce — same lowering path the conformance runner exercises when
869
+ // Mojolicious IS present, just with the assertion staged one step
870
+ // earlier (template-string rather than rendered HTML).
871
+ //
872
+ // One row per Tier A method fixture from
873
+ // packages/adapter-tests/fixtures/methods/. Each PR in the Tier A
874
+ // stack appends its rows as the corresponding lowering lands —
875
+ // keeping the block in sync with the `expectedDiagnostics` drops
876
+ // above.
877
+
878
+ import { fixture as arrayIncludesFixture } from '../../../adapter-tests/fixtures/methods/array-includes'
879
+ import { fixture as stringIncludesFixture } from '../../../adapter-tests/fixtures/methods/string-includes'
880
+ import { fixture as arrayIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-indexOf'
881
+ import { fixture as arrayLastIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-lastIndexOf'
882
+ import { fixture as arrayAtFixture } from '../../../adapter-tests/fixtures/methods/array-at'
883
+ import { fixture as arrayConcatFixture } from '../../../adapter-tests/fixtures/methods/array-concat'
884
+ import { fixture as arraySliceFixture } from '../../../adapter-tests/fixtures/methods/array-slice'
885
+ import { fixture as arrayReverseFixture } from '../../../adapter-tests/fixtures/methods/array-reverse'
886
+ import { fixture as arrayToReversedFixture } from '../../../adapter-tests/fixtures/methods/array-toReversed'
887
+ import { fixture as stringToLowerCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toLowerCase'
888
+ import { fixture as stringToUpperCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toUpperCase'
889
+ import { fixture as stringTrimFixture } from '../../../adapter-tests/fixtures/methods/string-trim'
890
+ // #1448 Tier B — .sort / .toSorted fixtures (loop-chained + standalone).
891
+ import { fixture as arraySortFieldAscFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-asc'
892
+ import { fixture as arraySortFieldDescFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-desc'
893
+ import { fixture as arraySortPrimitiveFixture } from '../../../adapter-tests/fixtures/methods/array-sort-primitive'
894
+ import { fixture as arraySortLocaleFixture } from '../../../adapter-tests/fixtures/methods/array-sort-locale'
895
+ import { fixture as arrayToSortedFixture } from '../../../adapter-tests/fixtures/methods/array-toSorted'
896
+
897
+ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
898
+ const cases = [
899
+ { fixture: arrayIncludesFixture, expect: 'bf->includes($items, $target)' },
900
+ { fixture: stringIncludesFixture, expect: 'bf->includes($value, $needle)' },
901
+ { fixture: arrayIndexOfFixture, expect: 'bf->index_of($items, $target)' },
902
+ { fixture: arrayLastIndexOfFixture, expect: 'bf->last_index_of($items, $target)' },
903
+ { fixture: arrayAtFixture, expect: 'bf->at($items, -1)' },
904
+ { fixture: arrayConcatFixture, expect: 'bf->concat($left, $right)' },
905
+ { fixture: arraySliceFixture, expect: 'bf->slice($items, 1, 3)' },
906
+ { fixture: arrayReverseFixture, expect: 'bf->reverse($items)' },
907
+ // .toReversed shares the helper with .reverse — pinning both
908
+ // routings catches a future divergence between them.
909
+ { fixture: arrayToReversedFixture, expect: 'bf->reverse($items)' },
910
+ { fixture: stringToLowerCaseFixture,expect: 'lc($value)' },
911
+ { fixture: stringToUpperCaseFixture,expect: 'uc($value)' },
912
+ { fixture: stringTrimFixture, expect: 'bf->trim($value)' },
913
+ // #1448 Tier B — sort / toSorted. The loop-chained field cases
914
+ // hoist into a `my $bf_iter_lN = bf->sort(...)` local; the
915
+ // standalone primitive cases inline the call.
916
+ { fixture: arraySortFieldAscFixture, expect: `bf->sort($items, { key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'asc' })` },
917
+ { fixture: arraySortFieldDescFixture, expect: `bf->sort($items, { key_kind => 'field', key => 'price', compare_type => 'numeric', direction => 'desc' })` },
918
+ { fixture: arraySortPrimitiveFixture, expect: `bf->sort($nums, { key_kind => 'self', compare_type => 'numeric', direction => 'asc' })` },
919
+ { fixture: arraySortLocaleFixture, expect: `bf->sort($names, { key_kind => 'self', compare_type => 'string', direction => 'asc' })` },
920
+ { fixture: arrayToSortedFixture, expect: `bf->sort($nums, { key_kind => 'self', compare_type => 'numeric', direction => 'asc' })` },
921
+ ]
922
+
923
+ for (const { fixture, expect: expectedHelper } of cases) {
924
+ test(`[${fixture.id}] lowers to \`${expectedHelper}\``, () => {
925
+ const adapter = new MojoAdapter()
926
+ const result = compileJSX(fixture.source, `${fixture.id}.tsx`, { adapter })
927
+ // No BF101 — the parser arm + adapter case took the call.
928
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
929
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
930
+ expect(template).toContain(expectedHelper)
931
+ // Defensive pin against the `$bf->...` form that the
932
+ // test-render `bf->` → `$bf->` patch would mangle to
933
+ // `$$bf->...` (crashes perl with "Not a SCALAR reference"
934
+ // — see the first-PR fix commit in this stack).
935
+ if (expectedHelper.startsWith('bf->')) {
936
+ expect(template).not.toContain(`$${expectedHelper}`)
937
+ }
938
+ })
939
+ }
940
+ })