@barefootjs/go-template 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,1757 @@
1
+ /**
2
+ * GoTemplateAdapter - Tests
3
+ *
4
+ * Conformance tests (shared across adapters) + Go-template-specific tests.
5
+ */
6
+
7
+ import { describe, test, expect } from 'bun:test'
8
+ import { GoTemplateAdapter } from '../adapter/go-template-adapter'
9
+ import {
10
+ runAdapterConformanceTests,
11
+ TemplatePrimitiveCaseId,
12
+ } from '@barefootjs/adapter-tests'
13
+ import { renderGoTemplateComponent, GoNotAvailableError } from '@barefootjs/go-template/test-render'
14
+ import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
15
+
16
+ runAdapterConformanceTests({
17
+ name: 'go-template',
18
+ factory: () => new GoTemplateAdapter(),
19
+ render: renderGoTemplateComponent,
20
+ // `branch-self-closing` no longer needs a skip — the conditional-
21
+ // marker divergence (Hono `bf-c="sN"` attribute vs Go
22
+ // `<!--bf-cond-start:sN-->` / `<!--bf-cond-end:sN-->` comment pairs)
23
+ // is now collapsed by `normalizeHTML` in adapter-tests (#1266).
24
+ //
25
+ // `nullish-coalescing-jsx` / `return-nullish-coalescing` have a
26
+ // separate semantic divergence: the Go template's `{{if ne .Banner
27
+ // ""}}` condition treats an unset `Banner` (Go nil) as `!= ""` and
28
+ // takes the truthy branch with empty content, while Hono's JS
29
+ // `??` operator falls through to the JSX default. That's a Go-
30
+ // adapter branch-selection bug — fixing it is out of scope for
31
+ // #1266.
32
+ //
33
+ // `return-map` uses a `data-key` serialisation shape that differs
34
+ // between Hono (runtime helper) and Go (template variable) in a
35
+ // way that isn't structural — leaving it on `skipJsx` until a
36
+ // normaliser for the `data-key` shape lands or the fixture splits
37
+ // into per-adapter `expectedHtml`.
38
+ //
39
+ // `style-object-dynamic`, `static-array-children`,
40
+ // `static-array-from-props`, and `static-array-from-props-with-component`
41
+ // are no longer here — they're covered by `expectedDiagnostics`
42
+ // below, asserting that the adapter emits `BF101` / `BF103` /
43
+ // `BF104` at build time instead of silently emitting invalid
44
+ // template syntax (#1266).
45
+ skipJsx: [
46
+ 'nullish-coalescing-jsx',
47
+ 'return-nullish-coalescing',
48
+ 'return-map',
49
+ // #1297 fixed the harness-side IR emission gate (multi-component
50
+ // sources now emit one `ir` file per component, and the harness
51
+ // picks the entry-point IR). The remaining gap is adapter-side:
52
+ // the go-template adapter has no SSR context-propagation
53
+ // mechanism, so `<Ctx.Provider value="dark">` doesn't make
54
+ // `useContext(Ctx)` resolve to `"dark"` at template-eval time —
55
+ // the template emits `.Theme` against a struct that has no
56
+ // `Theme` field. Provider SSR coverage on go-template waits on
57
+ // that adapter feature; see #1297 follow-up.
58
+ 'context-provider',
59
+ // #1244 stress catalog (#1326): `children={<span/>}` — the IR
60
+ // hoists the span with `needsScope: true` so the Hono reference
61
+ // emits `bf-s` on the inner `<span>`. The Go adapter renders the
62
+ // span up front as a compile-time HTML fragment containing the
63
+ // `{{bfScopeAttr .}}` action, then passes it via `template.HTML`
64
+ // through the parent's `{{.Children}}` interpolation — but
65
+ // `template.HTML` is marked-as-safe-output, not recursively
66
+ // parsed, so the action survives as literal text in the rendered
67
+ // HTML. Fixing this requires either (a) re-emitting the inner
68
+ // span as its own named template definition the outer template
69
+ // can pass its struct to, or (b) embedding the resolved scope ID
70
+ // at compile time. Neither lands in this PR; the Mojo sibling
71
+ // case is handled by routing the hoisted JSX through the same
72
+ // `begin %>…<% end` capture as nested children (see #1326 fix).
73
+ 'children-jsx-expression',
74
+ // #1335: fragment-wrapped form of the same shape. Now that the IR
75
+ // unwraps `<><span/></>` into the bare-element form, the Go adapter
76
+ // hits the identical `template.HTML` interpolation gap as
77
+ // `children-jsx-expression` above.
78
+ 'fragment-wrapped-children-jsx-expression',
79
+ // Shared-component multi-component fixtures (#1466). Boolean
80
+ // attribute divergence is now collapsed by `normalizeHTML`, so
81
+ // single-root variants (`conditional-return-*`, `form`, `portal`,
82
+ // `reactive-props`) participate again. These two still diverge
83
+ // because the harness's child renderer pins child `bf-s` to a
84
+ // `test_<sN>` literal rather than `<ChildName>_<id>_<sN>`. Same
85
+ // class of test-harness scope-id plumbing the `componentName`
86
+ // option fixed on the Hono side. Separate follow-up.
87
+ 'toggle-shared',
88
+ 'props-reactivity-comparison',
89
+ ],
90
+ // Per-fixture build-time contracts for shapes the Go template
91
+ // adapter intentionally refuses to lower. Lives here (not on the
92
+ // shared fixtures) so adding a new adapter doesn't require touching
93
+ // any cross-adapter file — every adapter declares its own
94
+ // refusal set against the canonical fixture corpus.
95
+ expectedDiagnostics: {
96
+ // JS object literal in attribute position: `convertExpressionToGo`
97
+ // can't lower into Go template syntax — surfaces as BF101 with an
98
+ // @client suggestion.
99
+ 'style-object-dynamic': [{ code: 'BF101', severity: 'error' }],
100
+ // Sibling-imported child component inside a loop body: the adapter
101
+ // emits `{{template "X" .}}` which only resolves if the user has
102
+ // compiled the sibling file and registered the template on the
103
+ // same instance. BF103 makes that requirement loud. (The barefoot
104
+ // CLI passes `siblingTemplatesRegistered: true` so CLI builds
105
+ // suppress the diagnostic — see compileJSX `siblingTemplatesRegistered`.)
106
+ 'static-array-children': [{ code: 'BF103', severity: 'error' }],
107
+ // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
108
+ // call it inside a keyed `.map`. Same BF103 surface as
109
+ // `static-array-children` above — pinned at adapter level so the
110
+ // shared-component corpus stays adapter-neutral.
111
+ 'todo-app': [{ code: 'BF103', severity: 'error' }],
112
+ 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
113
+ // Array-destructure loop param (`([k, v]) => ...`): Go's `{{range
114
+ // $a, $b := ...}}` only supports single-name bindings, so the
115
+ // adapter would otherwise emit invalid template syntax.
116
+ 'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
117
+ // Same destructure shape with a child component body — fires both
118
+ // BF103 (imported child in loop) and BF104 (destructure param).
119
+ 'static-array-from-props-with-component': [
120
+ { code: 'BF103', severity: 'error' },
121
+ { code: 'BF104', severity: 'error' },
122
+ ],
123
+ // #1244 stress catalog: same `convertExpressionToGo` refusal shape
124
+ // as `style-object-dynamic` above — a JS object literal in
125
+ // attribute position can't lower into Go template syntax, so the
126
+ // adapter surfaces BF101 instead of emitting invalid template.
127
+ 'style-3-signals': [{ code: 'BF101', severity: 'error' }],
128
+ // #1244 stress catalog: tagged-template-literal callees
129
+ // (`cn\`base \${tone()}\``) likewise can't lower into Go template
130
+ // syntax — same BF101 refusal.
131
+ 'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
132
+ // #1310: rest destructure in .map() callback. Hono / CSR lower
133
+ // these via the inline residual-object accessor (#1309), but the
134
+ // Go template adapter has no analogous lowering — `paramBindings`
135
+ // is non-empty so the generic destructure-refusal at
136
+ // `go-template-adapter.ts` fires BF104 regardless of whether the
137
+ // binding is rest or plain. Pinning the contract here makes the
138
+ // limitation declarative: when the Go adapter grows a native
139
+ // rest-lowering, dropping these entries flips the contract on.
140
+ 'rest-destructure-object-in-map': [{ code: 'BF104', severity: 'error' }],
141
+ // #1244 catalog: rest spread back onto the root element. Same
142
+ // refusal shape as the read-only variant above — `paramBindings`
143
+ // is non-empty so BF104 fires regardless of how `rest` is used.
144
+ 'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
145
+ 'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
146
+ 'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
147
+ // #1443: `[a, b].filter(Boolean).join(' ')` (registry Slot) now
148
+ // lowers to `bf_join (bf_filter_truthy (bf_arr ...)) " "`. No
149
+ // BF101 expected — pinned positively by the
150
+ // `branch-local-filter-join-go` template-output test below.
151
+ //
152
+ // #1448 Tier A — JS Array / String methods that the Go template
153
+ // adapter hasn't lowered yet. Each row drops once the
154
+ // corresponding method PR lands. Hono / CSR pass these out of
155
+ // the box (they evaluate JS at runtime) so the pin only applies
156
+ // here.
157
+ //
158
+ // `array-includes` / `string-includes` no longer pinned — both
159
+ // shapes lower via the shared `array-method` IR + the polymorphic
160
+ // `bf_includes` runtime helper that dispatches on
161
+ // `reflect.Kind()` (slice/array → element search, string →
162
+ // substring search). The condition-position lowering picks up
163
+ // the same emit through the `array-method` arm of
164
+ // `renderConditionExpr` (#1448 Tier A first PR).
165
+ //
166
+ // Remaining fixtures land at expression position and surface BF101
167
+ // via `convertExpressionToGo`. Distinct codes for the two paths is
168
+ // pre-existing adapter behaviour, not something this catalog
169
+ // should paper over — pinned literally here.
170
+ // `array-indexOf` / `array-lastIndexOf` no longer pinned —
171
+ // value-equality `bf_index_of` / `bf_last_index_of` Go runtime
172
+ // helpers handle the shape (#1448 Tier A second PR).
173
+ // `array-at` no longer pinned — the pre-existing `bf_at` runtime
174
+ // helper now lowers `.at(i)` (#1448 Tier A third PR).
175
+ // `array-concat` no longer pinned — the new `bf_concat` runtime
176
+ // helper merges two arrays into a single `[]any` (#1448 Tier A
177
+ // fourth PR).
178
+ // `array-slice` no longer pinned — the new `bf_slice` runtime
179
+ // helper carves out a sub-range with JS-compat clamping
180
+ // (#1448 Tier A fifth PR).
181
+ // `array-reverse` / `array-toReversed` no longer pinned —
182
+ // both share the `bf_reverse` helper since SSR templates
183
+ // render a snapshot and the JS mutate-vs-new distinction has
184
+ // no template-level meaning (#1448 Tier A sixth PR).
185
+ // `string-toLowerCase` / `string-toUpperCase` no longer pinned —
186
+ // pre-existing `bf_lower` / `bf_upper` runtime helpers wire to
187
+ // the JS method names at the adapter layer (#1448 Tier A
188
+ // seventh + eighth PRs).
189
+ // `string-trim` no longer pinned — pre-existing `bf_trim`
190
+ // (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
191
+ // ninth PR, closing out Tier A).
192
+ },
193
+ // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
194
+ // via `GoTemplateAdapter.templatePrimitives` (#1188). The two
195
+ // remaining cases stay skipped because the V1 registry is
196
+ // identifier-path-only and explicit:
197
+ // - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
198
+ // the registry and can't be rendered server-side without
199
+ // user-supplied template-fn mappings.
200
+ // - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
201
+ // too, same reason.
202
+ // Adding new entries to `templatePrimitives` should narrow this
203
+ // skip set; see `templatePrimitives` declaration in
204
+ // `go-template-adapter.ts` for the full V1 surface.
205
+ skipTemplatePrimitives: new Set([
206
+ TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
207
+ TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
208
+ ]),
209
+ skipMarkerConformance: new Set<string>([
210
+ // Same as Hono / Mojo: `/* @client */` markers on TodoApp's keyed
211
+ // `.map` intentionally elide a slot id from the SSR template that
212
+ // the IR still declares (s6). See hono-adapter.test for the
213
+ // contract.
214
+ 'todo-app',
215
+ ]),
216
+ onRenderError: (err, id) => {
217
+ if (err instanceof GoNotAvailableError) {
218
+ console.log(`Skipping [${id}]: ${err.message}`)
219
+ return true
220
+ }
221
+ return false
222
+ },
223
+ })
224
+
225
+ // =============================================================================
226
+ // Helpers
227
+ // =============================================================================
228
+
229
+ /**
230
+ * Compile JSX source to ComponentIR using the GoTemplateAdapter.
231
+ */
232
+ function compileToIR(source: string, adapter?: GoTemplateAdapter): ComponentIR {
233
+ const result = compileJSX(source.trimStart(), 'test.tsx', {
234
+ adapter: adapter ?? new GoTemplateAdapter(),
235
+ outputIR: true,
236
+ })
237
+ const irFile = result.files.find(f => f.type === 'ir')
238
+ if (!irFile) throw new Error('No IR output')
239
+ return JSON.parse(irFile.content) as ComponentIR
240
+ }
241
+
242
+ /**
243
+ * Compile JSX source and return the generated template output.
244
+ */
245
+ function compileAndGenerate(source: string, adapter?: GoTemplateAdapter) {
246
+ const a = adapter ?? new GoTemplateAdapter()
247
+ const ir = compileToIR(source, a)
248
+ return a.generate(ir)
249
+ }
250
+
251
+ // =============================================================================
252
+ // Go-Template-Specific Tests
253
+ // =============================================================================
254
+
255
+ describe('GoTemplateAdapter - Adapter Specific', () => {
256
+ describe('generate - Go struct types', () => {
257
+ test('deduplicates struct field when signal name matches prop name (#461)', () => {
258
+ const adapter = new GoTemplateAdapter()
259
+ const ir = compileToIR(`
260
+ "use client"
261
+ import { createSignal } from "@barefootjs/client"
262
+
263
+ export function Example(props: { label?: string }) {
264
+ const [label, setLabel] = createSignal(props.label ?? 'Default')
265
+ return <div>{label()}</div>
266
+ }
267
+ `)
268
+ const result = adapter.generate(ir)
269
+
270
+ expect(result.types).toBeDefined()
271
+ // Should have exactly one Label field, not two
272
+ const labelFields = result.types!.match(/\bLabel\b.*`json:"label"`/g) ?? []
273
+ expect(labelFields.length).toBe(1)
274
+
275
+ // NewExampleProps should have exactly one Label assignment
276
+ const labelAssignments = result.types!.match(/Label:/g) ?? []
277
+ expect(labelAssignments.length).toBe(1)
278
+ })
279
+
280
+ test('generates Go struct types', () => {
281
+ const adapter = new GoTemplateAdapter()
282
+ const ir = compileToIR(`
283
+ "use client"
284
+ import { createSignal } from "@barefootjs/client"
285
+
286
+ export function Counter(props: { initial?: number }) {
287
+ const [count, setCount] = createSignal(props.initial ?? 0)
288
+ return <div>{count()}</div>
289
+ }
290
+ `)
291
+ const result = adapter.generate(ir)
292
+
293
+ expect(result.types).toBeDefined()
294
+ expect(result.types).toContain('package components')
295
+ expect(result.types).toContain('type CounterProps struct')
296
+ expect(result.types).toContain('ScopeID string')
297
+ expect(result.types).toContain('Initial int')
298
+ expect(result.types).toContain('Count int')
299
+ })
300
+
301
+ test('dynamic loop with child component → NewXxxProps carries a populate-this-slice doc comment (#1442 echo TodoApp repro)', () => {
302
+ // Regression: a `todos().map(t => <TodoItem todo={t} />)` loop with a
303
+ // dynamic array (signal getter, not a static prop) declares
304
+ // `TodoItems []TodoItemProps` on the Props struct, but
305
+ // `NewTodoAppProps` returned it empty — and the SSR template
306
+ // iterated over the empty slice into a blank list with no signal
307
+ // anywhere. The "you must populate this in your handler" rule was
308
+ // pure tribal knowledge.
309
+ //
310
+ // Now `NewXxxProps`'s doc comment carries a concrete example for
311
+ // every dynamic loop child, including the field name, the child's
312
+ // Input/Props names, and the slot id needed for bf-h / bf-m.
313
+ // Authors land on the comment as soon as they read the generated
314
+ // file and see exactly what to do.
315
+ const adapter = new GoTemplateAdapter()
316
+ const todoItemIR = compileToIR(`
317
+ "use client"
318
+ type Todo = { id: number; text: string; done: boolean }
319
+ export function TodoItem(props: { todo: Todo }) {
320
+ return <li>{props.todo.text}</li>
321
+ }
322
+ `)
323
+ // Sanity: TodoItem alone produces a Props struct; no dynamic loop
324
+ // inside it, so no extra comment.
325
+ const todoItemResult = adapter.generate(todoItemIR)
326
+ expect(todoItemResult.types).not.toContain('NOTE: `')
327
+
328
+ const todoAppIR = compileToIR(`
329
+ "use client"
330
+ import { createSignal } from "@barefootjs/client"
331
+ import { TodoItem } from './TodoItem'
332
+
333
+ type Todo = { id: number; text: string; done: boolean }
334
+
335
+ export function TodoApp(props: { initial?: Todo[] }) {
336
+ const [todos, setTodos] = createSignal<Todo[]>(props.initial ?? [])
337
+ return (
338
+ <ul>
339
+ {todos().map(todo => (
340
+ <TodoItem key={todo.id} todo={todo} />
341
+ ))}
342
+ </ul>
343
+ )
344
+ }
345
+ `)
346
+ const result = adapter.generate(todoAppIR)
347
+ // Doc-comment carries the per-child concrete example, naming the
348
+ // populated field, the child's Input/Props types, and the bf-h /
349
+ // bf-m wiring the SSR template relies on.
350
+ expect(result.types).toContain('NOTE: `TodoItems`')
351
+ expect(result.types).toContain('props.TodoItems = make([]TodoItemProps, len(items))')
352
+ expect(result.types).toContain('NewTodoItemProps(TodoItemInput{')
353
+ expect(result.types).toContain('props.TodoItems[i].BfParent = props.ScopeID')
354
+ expect(result.types).toContain('props.TodoItems[i].BfMount =')
355
+ })
356
+
357
+ test('signal initialized via `(props.X ?? []).length` lands as int, not []T (#1442 echo TodoApp repro)', () => {
358
+ // Regression: `extractPropNameFromInitialValue` greedily matched
359
+ // any `(props.X ?? Y).<something>` shape and propagated the prop's
360
+ // Go type ([]Todo) to the signal field, even when the trailing
361
+ // accessor (`.length`) transformed the expression to a number.
362
+ // The signal was `Seq []Todo` instead of `Seq int`, which is
363
+ // technically a Go compile error if the field is consumed
364
+ // arithmetically — runtime behaviour was a silent wrong-type
365
+ // initialisation.
366
+ const adapter = new GoTemplateAdapter()
367
+ const ir = compileToIR(`
368
+ "use client"
369
+ import { createSignal, createMemo } from "@barefootjs/client"
370
+
371
+ type Todo = { id: number; text: string; done: boolean }
372
+
373
+ export function TodoApp(props: { initial?: Todo[] }) {
374
+ const [todos] = createSignal<Todo[]>(props.initial ?? [])
375
+ const [seq] = createSignal((props.initial ?? []).length)
376
+ const remaining = createMemo(() => todos().filter(t => !t.done).length)
377
+ const total = createMemo(() => todos().length)
378
+ return <div>{seq()} {remaining()} / {total()}</div>
379
+ }
380
+ `)
381
+ const result = adapter.generate(ir)
382
+ // Signal seeded from `.length` of an array → number → Go int.
383
+ expect(result.types).toContain('Seq int')
384
+ // Memos whose body is a `.length` chain → also int (analyzer
385
+ // now runs inferTypeFromValue on the arrow body for memos).
386
+ expect(result.types).toContain('Remaining int')
387
+ expect(result.types).toContain('Total int')
388
+ // Underlying prop / array signal still uses the array type.
389
+ expect(result.types).toContain('Initial []Todo')
390
+ expect(result.types).toContain('Todos []Todo')
391
+ // The misclassified shapes from the original repro must not
392
+ // resurface — even as `interface{}` (the fallback before the
393
+ // analyzer recognised `.length`).
394
+ expect(result.types).not.toContain('Seq []Todo')
395
+ expect(result.types).not.toContain('Remaining interface{}')
396
+ expect(result.types).not.toContain('Total interface{}')
397
+ })
398
+
399
+ test('hoists signal-time `props.X ?? N` fallback into shared local var (#1423)', () => {
400
+ // Mirrors the Mojo manifest-defaults coverage (#1419): when the
401
+ // signal default lives on a `??` against a bare prop access, the
402
+ // generator hoists the fallback so the signal, the prop field,
403
+ // and any derived memo share one fallback-applied value.
404
+ const adapter = new GoTemplateAdapter()
405
+ const ir = compileToIR(`
406
+ "use client"
407
+ import { createSignal, createMemo } from "@barefootjs/client"
408
+
409
+ export function Counter(props: { initial?: number }) {
410
+ const [count, setCount] = createSignal(props.initial ?? 99)
411
+ const doubled = createMemo(() => count() * 2)
412
+ return <div>{count()} {doubled()}</div>
413
+ }
414
+ `)
415
+ const result = adapter.generate(ir)
416
+ expect(result.types).toBeDefined()
417
+ const types = result.types!
418
+
419
+ // Hoist: `initial := in.Initial` + zero-check + fallback assign.
420
+ expect(types).toContain('initial := in.Initial')
421
+ expect(types).toMatch(/if initial == 0 \{\s*initial = 99\s*\}/)
422
+
423
+ // Prop, signal, and memo all reference the hoisted variable.
424
+ expect(types).toContain('Initial: initial,')
425
+ expect(types).toContain('Count: initial,')
426
+ expect(types).toContain('Doubled: initial * 2,')
427
+
428
+ // Pre-fix output is no longer present.
429
+ expect(types).not.toContain('Count: in.Initial,')
430
+ expect(types).not.toContain('Doubled: in.Initial * 2,')
431
+ })
432
+
433
+ test('zero-fallback (`?? 0`) leaves NewXxxProps unchanged (#1423)', () => {
434
+ // The hoist is a no-op when the fallback is the Go zero value;
435
+ // emitting `if initial == 0 { initial = 0 }` would be noise.
436
+ const adapter = new GoTemplateAdapter()
437
+ const ir = compileToIR(`
438
+ "use client"
439
+ import { createSignal } from "@barefootjs/client"
440
+
441
+ export function Counter(props: { initial?: number }) {
442
+ const [count, setCount] = createSignal(props.initial ?? 0)
443
+ return <div>{count()}</div>
444
+ }
445
+ `)
446
+ const result = adapter.generate(ir)
447
+ const types = result.types!
448
+ expect(types).not.toContain('initial := in.Initial')
449
+ expect(types).toContain('Initial: in.Initial,')
450
+ expect(types).toContain('Count: in.Initial,')
451
+ })
452
+
453
+ test('hoists string fallback for `props.X ?? "default"` (#1423)', () => {
454
+ const adapter = new GoTemplateAdapter()
455
+ const ir = compileToIR(`
456
+ "use client"
457
+ import { createSignal } from "@barefootjs/client"
458
+
459
+ export function Label(props: { label?: string }) {
460
+ const [text, setText] = createSignal(props.label ?? 'Default')
461
+ return <div>{text()}</div>
462
+ }
463
+ `)
464
+ const result = adapter.generate(ir)
465
+ const types = result.types!
466
+ expect(types).toContain('label := in.Label')
467
+ expect(types).toMatch(/if label == ""\s*\{\s*label = "Default"\s*\}/)
468
+ expect(types).toContain('Label: label,')
469
+ // Signal name `text` differs from prop name `label`, so the
470
+ // signal field gets its own entry that resolves through the
471
+ // hoisted var.
472
+ expect(types).toContain('Text: label,')
473
+ })
474
+
475
+ test('hoists `props.X ?? true` against the bool zero (#1423 review)', () => {
476
+ // Bool-true falls through the same hoist path as int / string —
477
+ // the asymmetry is documented (caller can't thread "explicit
478
+ // false" through because Go's bool zero IS false), but emitting
479
+ // a hoisted local matches the int case's shape so a derived
480
+ // memo can inherit it.
481
+ const adapter = new GoTemplateAdapter()
482
+ const ir = compileToIR(`
483
+ "use client"
484
+ import { createSignal } from "@barefootjs/client"
485
+
486
+ export function Check(props: { checked?: boolean }) {
487
+ const [c, setC] = createSignal(props.checked ?? true)
488
+ return <div>{c() ? 'on' : 'off'}</div>
489
+ }
490
+ `)
491
+ const result = adapter.generate(ir)
492
+ const types = result.types!
493
+ expect(types).toContain('checked := in.Checked')
494
+ expect(types).toMatch(/if checked == false\s*\{\s*checked = true\s*\}/)
495
+ expect(types).toContain('Checked: checked,')
496
+ expect(types).toContain('C: checked,')
497
+ })
498
+
499
+ test('skips hoist for zero-equivalent string and float fallbacks (#1423 review)', () => {
500
+ // The skip predicate compares the Go fallback against the
501
+ // type's zero literal — covers `?? ''` (string) and `?? 0.0`
502
+ // (numeric spelling that parses to zero), not just the bare
503
+ // `?? 0` / `?? false` literals.
504
+ const adapter = new GoTemplateAdapter()
505
+ const emptyStringIr = compileToIR(`
506
+ "use client"
507
+ import { createSignal } from "@barefootjs/client"
508
+
509
+ export function Label(props: { label?: string }) {
510
+ const [text, setText] = createSignal(props.label ?? '')
511
+ return <div>{text()}</div>
512
+ }
513
+ `)
514
+ const emptyStringTypes = adapter.generate(emptyStringIr).types!
515
+ expect(emptyStringTypes).not.toContain('label := in.Label')
516
+ expect(emptyStringTypes).toContain('Label: in.Label,')
517
+
518
+ const floatIr = compileToIR(`
519
+ "use client"
520
+ import { createSignal } from "@barefootjs/client"
521
+
522
+ export function Score(props: { value?: number }) {
523
+ const [v, setV] = createSignal(props.value ?? 0.0)
524
+ return <div>{v()}</div>
525
+ }
526
+ `)
527
+ const floatTypes = adapter.generate(floatIr).types!
528
+ expect(floatTypes).not.toContain('value := in.Value')
529
+ expect(floatTypes).toContain('Value: in.Value,')
530
+ })
531
+ })
532
+
533
+ describe('JSX children forwarding (#1203)', () => {
534
+ test('forwards element children via Children: template.HTML(...)', () => {
535
+ const adapter = new GoTemplateAdapter()
536
+ const ir = compileToIR(`
537
+ 'use client'
538
+ import { createSignal } from "@barefootjs/client"
539
+
540
+ export function Page() {
541
+ const [x] = createSignal(0)
542
+ return (
543
+ <main data-x={x()}>
544
+ <Card>
545
+ <span>hello</span>
546
+ <span>world</span>
547
+ </Card>
548
+ </main>
549
+ )
550
+ }
551
+ `)
552
+ const types = adapter.generateTypes(ir)!
553
+ expect(types).toContain('"html/template"')
554
+ expect(types).toContain(
555
+ 'Children: template.HTML("<span>hello</span><span>world</span>")',
556
+ )
557
+ })
558
+
559
+ test('text-only children stay on the plain string path (#461 carry-over)', () => {
560
+ const adapter = new GoTemplateAdapter()
561
+ const ir = compileToIR(`
562
+ 'use client'
563
+ import { createSignal } from "@barefootjs/client"
564
+
565
+ export function Page() {
566
+ const [x] = createSignal(0)
567
+ return <main data-x={x()}><Button>+1</Button></main>
568
+ }
569
+ `)
570
+ const types = adapter.generateTypes(ir)!
571
+ expect(types).toContain('Children: "+1"')
572
+ expect(types).not.toContain('template.HTML')
573
+ expect(types).not.toContain('"html/template"')
574
+ })
575
+
576
+ test('omits Children entry when component has no JSX children', () => {
577
+ const adapter = new GoTemplateAdapter()
578
+ const ir = compileToIR(`
579
+ 'use client'
580
+ import { createSignal } from "@barefootjs/client"
581
+
582
+ export function Page() {
583
+ const [x] = createSignal(0)
584
+ return <main data-x={x()}><Card label="x" /></main>
585
+ }
586
+ `)
587
+ const types = adapter.generateTypes(ir)!
588
+ expect(types).not.toContain('Children:')
589
+ expect(types).not.toContain('template.HTML')
590
+ })
591
+
592
+ test('drops dynamic children that would emit Go template actions', () => {
593
+ // V1 limitation: a `template.HTML` value isn't re-parsed by the
594
+ // parent's `{{.Children}}` pipeline, so any `{{...}}` inside the
595
+ // rendered fragment would output literally. Dynamic-expression /
596
+ // nested-component / conditional children stay on the existing
597
+ // drop path (same as before this issue) until a re-evaluation
598
+ // hook lands.
599
+ const cases = [
600
+ // signal expression inside child
601
+ `'use client'
602
+ import { createSignal } from "@barefootjs/client"
603
+ export function Page() {
604
+ const [c] = createSignal(0)
605
+ return <main><Card><span>{c()}</span></Card></main>
606
+ }`,
607
+ // nested component child
608
+ `'use client'
609
+ import { createSignal } from "@barefootjs/client"
610
+ export function Page() {
611
+ const [x] = createSignal(0)
612
+ return <main data-x={x()}><Card><Button/></Card></main>
613
+ }`,
614
+ // conditional child
615
+ `'use client'
616
+ import { createSignal } from "@barefootjs/client"
617
+ export function Page() {
618
+ const [v] = createSignal(true)
619
+ return <main><Card>{v() ? <span>a</span> : <span>b</span>}</Card></main>
620
+ }`,
621
+ ]
622
+ for (const src of cases) {
623
+ const adapter = new GoTemplateAdapter()
624
+ const ir = compileToIR(src, adapter)
625
+ const types = adapter.generateTypes(ir)!
626
+ expect(types).not.toContain('Children:')
627
+ expect(types).not.toContain('template.HTML')
628
+ }
629
+ })
630
+ })
631
+
632
+ describe('generateTypes', () => {
633
+ test('generates types with custom package name', () => {
634
+ const customAdapter = new GoTemplateAdapter({ packageName: 'views' })
635
+ const ir = compileToIR(`
636
+ export function Button(props: { label: string }) {
637
+ return <button>{props.label}</button>
638
+ }
639
+ `, customAdapter)
640
+
641
+ const types = customAdapter.generateTypes(ir)
642
+
643
+ expect(types).toContain('package views')
644
+ expect(types).toContain('type ButtonProps struct')
645
+ expect(types).toContain('Label string')
646
+ })
647
+
648
+ test('generates fields for multiple static child components with slotId', () => {
649
+ const adapter = new GoTemplateAdapter()
650
+ // ReactiveChild is not defined here — only referenced as a child component.
651
+ // The compiler creates IRComponent nodes for any PascalCase JSX element.
652
+ const ir = compileToIR(`
653
+ "use client"
654
+ import { createSignal, createMemo } from "@barefootjs/client"
655
+
656
+ export default function ReactiveProps() {
657
+ const [count, setCount] = createSignal(0)
658
+ const doubled = createMemo(() => count() * 2)
659
+ return (
660
+ <div>
661
+ <ReactiveChild value={count()} label="Child A" />
662
+ <ReactiveChild value={doubled()} label="Child B (doubled)" />
663
+ </div>
664
+ )
665
+ }
666
+ `)
667
+
668
+ const types = adapter.generateTypes(ir)!
669
+
670
+ // Two ReactiveChild instances should produce two distinct Props fields
671
+ const fieldMatches = types.match(/ReactiveChild\w+ ReactiveChildProps/g) ?? []
672
+ expect(fieldMatches.length).toBe(2)
673
+
674
+ // Each instance should have a NewReactiveChildProps initializer
675
+ const initMatches = types.match(/NewReactiveChildProps\(ReactiveChildInput\{/g) ?? []
676
+ expect(initMatches.length).toBe(2)
677
+
678
+ // Each should have its own ScopeID derived from parent
679
+ const scopeMatches = types.match(/ScopeID: scopeID \+ "_/g) ?? []
680
+ expect(scopeMatches.length).toBe(2)
681
+
682
+ // Label values should be present
683
+ expect(types).toContain('Label: "Child A"')
684
+ expect(types).toContain('Label: "Child B (doubled)"')
685
+ })
686
+ })
687
+
688
+ describe('Portal component handling', () => {
689
+ test('renders Portal component with children as portal collection', () => {
690
+ // Portal is referenced as a child component (not defined in file).
691
+ const result = compileAndGenerate(`
692
+ "use client"
693
+ import { createSignal } from "@barefootjs/client"
694
+
695
+ export function DialogDemo() {
696
+ const [open, setOpen] = createSignal(false)
697
+ return (
698
+ <div>
699
+ <Portal>
700
+ <div data-slot="dialog-overlay"></div>
701
+ </Portal>
702
+ </div>
703
+ )
704
+ }
705
+ `)
706
+ expect(result.template).toContain('.Portals.Add')
707
+ expect(result.template).toContain('data-slot=\\"dialog-overlay\\"')
708
+ })
709
+
710
+ test('renders Portal with dynamic attribute in children', () => {
711
+ const result = compileAndGenerate(`
712
+ "use client"
713
+ import { createSignal } from "@barefootjs/client"
714
+
715
+ export function DialogDemo() {
716
+ const [open, setOpen] = createSignal(false)
717
+ return (
718
+ <div>
719
+ <Portal>
720
+ <div data-slot="dialog-overlay" data-state={open() ? 'open' : 'closed'}></div>
721
+ </Portal>
722
+ </div>
723
+ )
724
+ }
725
+ `)
726
+ expect(result.template).toContain('.Portals.Add')
727
+ expect(result.template).toContain('bfPortalHTML')
728
+ expect(result.template).toContain('data-state')
729
+ })
730
+
731
+ test('Portal without children renders empty portal add', () => {
732
+ const result = compileAndGenerate(`
733
+ "use client"
734
+ import { createSignal } from "@barefootjs/client"
735
+
736
+ export function DialogDemo() {
737
+ const [open, setOpen] = createSignal(false)
738
+ return (
739
+ <div>
740
+ <Portal />
741
+ </div>
742
+ )
743
+ }
744
+ `)
745
+ expect(result.template).toContain('.Portals.Add')
746
+ })
747
+
748
+ test('non-Portal component renders normally', () => {
749
+ // DialogTrigger is referenced but not defined — compiler creates IRComponent.
750
+ const result = compileAndGenerate(`
751
+ "use client"
752
+ import { createSignal } from "@barefootjs/client"
753
+
754
+ export function DialogDemo() {
755
+ const [open, setOpen] = createSignal(false)
756
+ return (
757
+ <div>
758
+ <DialogTrigger />
759
+ </div>
760
+ )
761
+ }
762
+ `)
763
+ expect(result.template).toContain('{{template "DialogTrigger"')
764
+ expect(result.template).not.toContain('.Portals.Add')
765
+ })
766
+ })
767
+
768
+ describe('block body filter rendering', () => {
769
+ test('renders loop with simple block body filter', () => {
770
+ const result = compileAndGenerate(`
771
+ "use client"
772
+ import { createSignal } from "@barefootjs/client"
773
+
774
+ type Todo = { text: string; done: boolean }
775
+
776
+ export function TodoList() {
777
+ const [todos, setTodos] = createSignal<Todo[]>([])
778
+ return (
779
+ <ul>
780
+ {todos().filter(t => { return !t.done }).map(todo => (
781
+ <li>Item</li>
782
+ ))}
783
+ </ul>
784
+ )
785
+ }
786
+ `)
787
+ expect(result.template).toContain('{{range')
788
+ expect(result.template).toContain('not .Done')
789
+ expect(result.template).toContain('Item')
790
+ })
791
+
792
+ test('renders loop with variable declaration and simple if', () => {
793
+ const result = compileAndGenerate(`
794
+ "use client"
795
+ import { createSignal } from "@barefootjs/client"
796
+
797
+ type Todo = { text: string; done: boolean }
798
+
799
+ export function TodoList() {
800
+ const [todos, setTodos] = createSignal<Todo[]>([])
801
+ const [filter, setFilter] = createSignal('all')
802
+ return (
803
+ <ul>
804
+ {todos().filter(t => {
805
+ const f = filter()
806
+ if (f === 'active') return !t.done
807
+ return true
808
+ }).map(todo => (
809
+ <li>TodoItem</li>
810
+ ))}
811
+ </ul>
812
+ )
813
+ }
814
+ `)
815
+ expect(result.template).toContain('{{range')
816
+ expect(result.template).toContain('{{if')
817
+ expect(result.template).toContain('$.Filter')
818
+ expect(result.template).toContain('TodoItem')
819
+ })
820
+
821
+ test('renders loop with TodoApp filter pattern', () => {
822
+ const result = compileAndGenerate(`
823
+ "use client"
824
+ import { createSignal } from "@barefootjs/client"
825
+
826
+ type Todo = { text: string; done: boolean }
827
+
828
+ export function TodoApp() {
829
+ const [todos, setTodos] = createSignal<Todo[]>([])
830
+ const [filter, setFilter] = createSignal('all')
831
+ return (
832
+ <ul>
833
+ {todos().filter(t => {
834
+ const f = filter()
835
+ if (f === 'active') return !t.done
836
+ if (f === 'completed') return t.done
837
+ return true
838
+ }).map(todo => (
839
+ <li>TodoItem</li>
840
+ ))}
841
+ </ul>
842
+ )
843
+ }
844
+ `)
845
+ expect(result.template).toContain('{{range')
846
+ expect(result.template).toContain('{{if')
847
+ expect(result.template).toContain('$.Filter')
848
+ expect(result.template).toContain('active')
849
+ expect(result.template).toContain('completed')
850
+ expect(result.template).toContain('TodoItem')
851
+ })
852
+
853
+ test('loop-param member access stays scoped to range dot (#1442 echo TodoApp repro)', () => {
854
+ // Regression: `todo.done` inside `{{range $_, $todo := .Todos}}`
855
+ // used to lower to `.Todo.Done` (a non-existent field) instead of
856
+ // `.Done` (the range dot's field). The bug only surfaced through
857
+ // the condition-expression rendering path — boolean attributes
858
+ // (`checked={todo.done}`) and `style` ternaries (`todo.done ? ...`)
859
+ // both route through `renderConditionExpr`, which had its own
860
+ // member-handling path that skipped the loop-param normalization
861
+ // applied by the main `ParsedExprEmitter`. Result was a silent
862
+ // failure: Go's html/template expanded the bogus field to "" and
863
+ // aborted the surrounding `{{if}}`, Echo returned HTTP 200 with a
864
+ // truncated body, and the user saw a blank list with no console
865
+ // signal.
866
+ const result = compileAndGenerate(`
867
+ "use client"
868
+ import { createSignal } from "@barefootjs/client"
869
+
870
+ type Todo = { id: number; text: string; done: boolean }
871
+
872
+ export function TodoApp() {
873
+ const [todos] = createSignal<Todo[]>([])
874
+ return (
875
+ <ul>
876
+ {todos().map(todo => (
877
+ <li key={todo.id}>
878
+ <input type="checkbox" checked={todo.done} />
879
+ <span style={todo.done ? 'text-decoration: line-through' : ''}>{todo.text}</span>
880
+ </li>
881
+ ))}
882
+ </ul>
883
+ )
884
+ }
885
+ `)
886
+ // The range form is unchanged — still `{{range $_, $todo := .Todos}}`.
887
+ expect(result.template).toContain('{{range $_, $todo := .Todos}}')
888
+ // Boolean attribute condition: `.Done`, NOT `.Todo.Done`.
889
+ expect(result.template).toContain('{{if .Done}}checked{{end}}')
890
+ expect(result.template).not.toContain('.Todo.Done')
891
+ // Ternary inside `style="..."` uses the same condition path —
892
+ // also `.Done`.
893
+ expect(result.template).toContain('{{if .Done}}text-decoration: line-through')
894
+ })
895
+ })
896
+
897
+ describe('higher-order methods - regression', () => {
898
+ test('simple every(t => t.done) uses bf_every', () => {
899
+ const result = compileAndGenerate(`
900
+ "use client"
901
+ import { createSignal } from "@barefootjs/client"
902
+
903
+ type Todo = { text: string; done: boolean }
904
+
905
+ export function TodoStatus() {
906
+ const [todos, setTodos] = createSignal<Todo[]>([])
907
+ return <div>{todos().every(t => t.done)}</div>
908
+ }
909
+ `)
910
+ expect(result.template).toContain('bf_every .Todos "Done"')
911
+ })
912
+
913
+ test('nested .filter(...).length in filter predicate lowers via len (bf_filter ...) (#1443 PR4)', () => {
914
+ // Pre-#1443 PR4: `renderFilterExpr` fell through to the
915
+ // `default` arm for the inner `.filter()` and pushed BF101.
916
+ // PR4 reuses the top-level `renderFilterLengthExpr` path
917
+ // (`len (bf_filter <arr> "<field>" <value>)`) inside the filter
918
+ // predicate emitter, wrapped in parens so the outer `gt` /
919
+ // `eq` / etc. parses as a single operand. The canonical
920
+ // "tags has at least one active" shape now renders cleanly.
921
+ const adapter = new GoTemplateAdapter()
922
+ const result = compileAndGenerate(`
923
+ "use client"
924
+ import { createSignal } from "@barefootjs/client"
925
+
926
+ type Todo = { id: number; name: string; tags: { active: boolean }[] }
927
+
928
+ export function TodoList() {
929
+ const [items, setItems] = createSignal<Todo[]>([])
930
+ return (
931
+ <ul>
932
+ {items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => (
933
+ <li key={t.id}>{t.name}</li>
934
+ ))}
935
+ </ul>
936
+ )
937
+ }
938
+ `, adapter)
939
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
940
+ expect(result.template).toContain('gt (len (bf_filter .Tags "Active" true)) 0')
941
+ // No degenerate fallbacks
942
+ expect(result.template).not.toContain('{{if false}}')
943
+ expect(result.template).not.toContain('[UNSUPPORTED-FILTER-EXPR]')
944
+ expect(result.template).not.toContain('false.Length')
945
+ })
946
+
947
+ test('state does not bleed between two nested-filter expressions in the same component', () => {
948
+ // Both filters now lower (one nested + one supported). Pin
949
+ // both to make sure the depth-scoped state reset
950
+ // (`filterExprUnsupported`) doesn't accidentally smother a
951
+ // sibling predicate when a transient default-arm hit DOES
952
+ // occur (e.g. for the still-unsupported `flatMap` / `reduce`
953
+ // shapes elsewhere in the same component).
954
+ const adapter = new GoTemplateAdapter()
955
+ const result = compileAndGenerate(`
956
+ "use client"
957
+ import { createSignal } from "@barefootjs/client"
958
+
959
+ type Todo = { id: number; name: string; done: boolean; tags: { active: boolean }[] }
960
+
961
+ export function TodoList() {
962
+ const [items, setItems] = createSignal<Todo[]>([])
963
+ return (
964
+ <div>
965
+ <ul>{items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => <li key={t.id}>{t.name}</li>)}</ul>
966
+ <ul>{items().filter(t => !t.done).map(t => <li key={t.id}>{t.name}</li>)}</ul>
967
+ </div>
968
+ )
969
+ }
970
+ `, adapter)
971
+ expect(result.template).toContain('gt (len (bf_filter .Tags "Active" true)) 0')
972
+ expect(result.template).toContain('{{if not .Done}}')
973
+ })
974
+
975
+ test('nested higher-order in filter predicate + /* @client */ suppresses BF101', () => {
976
+ const adapter = new GoTemplateAdapter()
977
+ const ir = compileToIR(`
978
+ "use client"
979
+ import { createSignal } from "@barefootjs/client"
980
+
981
+ type Todo = { id: number; name: string; tags: { active: boolean }[] }
982
+
983
+ export function TodoList() {
984
+ const [items, setItems] = createSignal<Todo[]>([])
985
+ return (
986
+ <ul>
987
+ {/* @client */ items().filter(x => x.tags.filter(t => t.active).length > 0).map(t => (
988
+ <li key={t.id}>{t.name}</li>
989
+ ))}
990
+ </ul>
991
+ )
992
+ }
993
+ `, adapter)
994
+ adapter.generate(ir)
995
+ const bf101 = adapter.errors.filter(e => e.code === 'BF101')
996
+ expect(bf101).toEqual([])
997
+ })
998
+ })
999
+
1000
+ describe('find/findIndex - adapter specific', () => {
1001
+ test('renders find() with equality + comparison mixed predicate', () => {
1002
+ const result = compileAndGenerate(`
1003
+ "use client"
1004
+ import { createSignal } from "@barefootjs/client"
1005
+
1006
+ type Item = { price: number; category: string }
1007
+
1008
+ export function ItemFinder() {
1009
+ const [items, setItems] = createSignal<Item[]>([])
1010
+ const [type, setType] = createSignal('')
1011
+ return <div>{items().find(t => t.price > 100 && t.category === type())}</div>
1012
+ }
1013
+ `)
1014
+ expect(result.template).toContain('{{range')
1015
+ expect(result.template).toContain('gt .Price 100')
1016
+ expect(result.template).toContain('eq .Category $.Type')
1017
+ expect(result.template).toContain('{{break}}')
1018
+ })
1019
+
1020
+ test('renders find() in condition', () => {
1021
+ const result = compileAndGenerate(`
1022
+ "use client"
1023
+ import { createSignal } from "@barefootjs/client"
1024
+
1025
+ type Item = { name: string; done: boolean }
1026
+
1027
+ export function ItemChecker() {
1028
+ const [items, setItems] = createSignal<Item[]>([])
1029
+ return <div>{items().find(t => t.done) ? 'Found' : 'Not found'}</div>
1030
+ }
1031
+ `)
1032
+ expect(result.template).toContain('bf_find .Items "Done" true')
1033
+ expect(result.template).toContain('Found')
1034
+ })
1035
+ })
1036
+
1037
+ describe('component root scope comment propagation', () => {
1038
+ test('component root in client component outputs bfScopeComment', () => {
1039
+ const result = compileAndGenerate(`
1040
+ "use client"
1041
+ import { createSignal } from "@barefootjs/client"
1042
+
1043
+ export function Wrapper() {
1044
+ const [active, setActive] = createSignal(false)
1045
+ return <Badge active={active()} />
1046
+ }
1047
+ `)
1048
+ // Component root should have scope comment for hydration boundary
1049
+ expect(result.template).toContain('{{bfScopeComment .}}')
1050
+ expect(result.template).toContain('{{template "Badge"')
1051
+ })
1052
+
1053
+ test('element root in client component does NOT output bfScopeComment', () => {
1054
+ // Element roots use bf-s attribute directly, not scope comments
1055
+ const result = compileAndGenerate(`
1056
+ "use client"
1057
+ import { createSignal } from "@barefootjs/client"
1058
+
1059
+ export function Counter() {
1060
+ const [count, setCount] = createSignal(0)
1061
+ return <div>{count()}</div>
1062
+ }
1063
+ `)
1064
+ expect(result.template).not.toContain('{{bfScopeComment .}}')
1065
+ expect(result.template).toContain('<div')
1066
+ })
1067
+
1068
+ test('if-statement root with component branches outputs bfScopeComment', () => {
1069
+ const result = compileAndGenerate(`
1070
+ "use client"
1071
+ import { createSignal } from "@barefootjs/client"
1072
+
1073
+ export function ConditionalComponent(props: { variant: string }) {
1074
+ const [active, setActive] = createSignal(false)
1075
+ if (props.variant === 'primary') {
1076
+ return <PrimaryBadge active={active()} />
1077
+ }
1078
+ return <DefaultBadge active={active()} />
1079
+ }
1080
+ `)
1081
+ // Both branches should have scope comments
1082
+ const template = result.template
1083
+ const scopeCommentCount = (template.match(/\{\{bfScopeComment \.\}\}/g) ?? []).length
1084
+ expect(scopeCommentCount).toBeGreaterThanOrEqual(2)
1085
+ })
1086
+ })
1087
+
1088
+ describe('script registration - asset paths', () => {
1089
+ const source = `
1090
+ "use client"
1091
+ import { createSignal } from "@barefootjs/client"
1092
+
1093
+ export function Counter() {
1094
+ const [count, setCount] = createSignal(0)
1095
+ return <div>{count()}</div>
1096
+ }
1097
+ `
1098
+
1099
+ test('defaults to /static/client/', () => {
1100
+ const result = compileAndGenerate(source)
1101
+ expect(result.template).toContain('{{.Scripts.Register "/static/client/barefoot.js"}}')
1102
+ expect(result.template).toContain('{{.Scripts.Register "/static/client/Counter.client.js"}}')
1103
+ })
1104
+
1105
+ test('honors clientJsBasePath and barefootJsPath options', () => {
1106
+ const adapter = new GoTemplateAdapter({
1107
+ clientJsBasePath: '/examples/echo/client/',
1108
+ barefootJsPath: '/examples/echo/client/barefoot.js',
1109
+ })
1110
+ const result = compileAndGenerate(source, adapter)
1111
+ expect(result.template).toContain('{{.Scripts.Register "/examples/echo/client/barefoot.js"}}')
1112
+ expect(result.template).toContain('{{.Scripts.Register "/examples/echo/client/Counter.client.js"}}')
1113
+ expect(result.template).not.toContain('/static/client/')
1114
+ })
1115
+ })
1116
+
1117
+ describe('cva-style class derivation (#1177)', () => {
1118
+ // The registry <Button> uses
1119
+ // const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`
1120
+ // Make sure jsx-to-ir's const resolution + the adapter's
1121
+ // template-literal renderer agree on every piece of that.
1122
+ const variantSource = `
1123
+ "use client"
1124
+
1125
+ const baseClasses = 'inline-flex items-center'
1126
+ const variantClasses: Record<string, string> = {
1127
+ default: 'bg-primary',
1128
+ secondary: 'bg-secondary',
1129
+ }
1130
+
1131
+ export function Tag(props: { variant?: 'default' | 'secondary'; className?: string }) {
1132
+ const classes = \`\${baseClasses} \${variantClasses[props.variant ?? 'default']} \${props.className ?? ''}\`
1133
+ return <span className={classes}>tag</span>
1134
+ }
1135
+ `
1136
+
1137
+ test('inlines string-literal const + emits switch for Record lookup', () => {
1138
+ const result = compileAndGenerate(variantSource)
1139
+ const tpl = result.template
1140
+ // baseClasses substituted as static text:
1141
+ expect(tpl).toContain('inline-flex items-center')
1142
+ // variantClasses[...] became a Go switch with both cases:
1143
+ expect(tpl).toMatch(/\{\{if eq [^}]+ "default"\}\}bg-primary/)
1144
+ expect(tpl).toMatch(/\{\{else if eq [^}]+ "secondary"\}\}bg-secondary/)
1145
+ expect(tpl).toContain('{{end}}')
1146
+ })
1147
+
1148
+ test('html-escapes UnoCSS arbitrary-value classes inside attribute values', () => {
1149
+ const escapingSource = `
1150
+ "use client"
1151
+
1152
+ const baseClasses = '[class*="size-"]:size-4'
1153
+
1154
+ export function Tagged(props: { className?: string }) {
1155
+ const classes = \`\${baseClasses} \${props.className ?? ''}\`
1156
+ return <span className={classes}>x</span>
1157
+ }
1158
+ `
1159
+ const tpl = compileAndGenerate(escapingSource).template
1160
+ // The literal `"` in `[class*="size-"]` would otherwise terminate
1161
+ // the wrapping attribute. Must be entity-escaped.
1162
+ expect(tpl).toContain('[class*=&quot;size-&quot;]:size-4')
1163
+ // And no raw `"` should appear inside the class= value (any
1164
+ // remaining `"` must be an attribute boundary, not literal).
1165
+ const classAttrMatch = tpl.match(/class="([^"]*)/)
1166
+ expect(classAttrMatch).not.toBeNull()
1167
+ expect(classAttrMatch![1]).not.toContain('"')
1168
+ })
1169
+ })
1170
+
1171
+ describe('templatePrimitives — JS-compat callees (#1188)', () => {
1172
+ // The registry fires when the call appears DIRECTLY in a JSX
1173
+ // expression position (`<div data-x={JSON.stringify(...)}>`).
1174
+ // Chained-const usage (`const j = JSON.stringify(...); <div
1175
+ // data-x={j}>`) routes through the Go adapter's struct-field
1176
+ // lift today and doesn't invoke the registry — that's a
1177
+ // separate limitation not addressed here. The conformance
1178
+ // test for the via-const shape inspects the CLIENT JS, where
1179
+ // the call IS inlined (relocate accepts it via the registry's
1180
+ // boolean-acceptance side).
1181
+
1182
+ test('JSON.stringify(props.x) emits bf_json in SSR template (inline)', () => {
1183
+ const result = compileAndGenerate(`
1184
+ 'use client'
1185
+ export function Foo(props: { config: object }) {
1186
+ return <div data-config={JSON.stringify(props.config)}>hi</div>
1187
+ }
1188
+ `)
1189
+ expect(result.template).toContain('bf_json .Config')
1190
+ // No raw JS leaked into the Go template.
1191
+ expect(result.template).not.toContain('JSON.stringify')
1192
+ })
1193
+
1194
+ test('Math.floor(props.score) emits bf_floor in SSR template (inline)', () => {
1195
+ const result = compileAndGenerate(`
1196
+ 'use client'
1197
+ export function Foo(props: { score: number }) {
1198
+ return <div data-rounded={Math.floor(props.score)}>hi</div>
1199
+ }
1200
+ `)
1201
+ expect(result.template).toContain('bf_floor .Score')
1202
+ expect(result.template).not.toContain('Math.floor')
1203
+ })
1204
+
1205
+ test('Math.ceil / Math.round both map to their bf_* equivalents', () => {
1206
+ const ceilResult = compileAndGenerate(`
1207
+ 'use client'
1208
+ export function Foo(props: { v: number }) {
1209
+ return <div data-x={Math.ceil(props.v)}>hi</div>
1210
+ }
1211
+ `)
1212
+ expect(ceilResult.template).toContain('bf_ceil .V')
1213
+
1214
+ const roundResult = compileAndGenerate(`
1215
+ 'use client'
1216
+ export function Foo(props: { v: number }) {
1217
+ return <div data-x={Math.round(props.v)}>hi</div>
1218
+ }
1219
+ `)
1220
+ expect(roundResult.template).toContain('bf_round .V')
1221
+ })
1222
+
1223
+ test('String(props.x) and Number(props.x) emit bf_string / bf_number', () => {
1224
+ const stringResult = compileAndGenerate(`
1225
+ 'use client'
1226
+ export function Foo(props: { v: number }) {
1227
+ return <div data-x={String(props.v)}>hi</div>
1228
+ }
1229
+ `)
1230
+ expect(stringResult.template).toContain('bf_string .V')
1231
+
1232
+ const numberResult = compileAndGenerate(`
1233
+ 'use client'
1234
+ export function Foo(props: { v: string }) {
1235
+ return <div data-x={Number(props.v)}>hi</div>
1236
+ }
1237
+ `)
1238
+ expect(numberResult.template).toContain('bf_number .V')
1239
+ })
1240
+
1241
+ test('registry exposes the expected V1 callees', () => {
1242
+ // Pin the V1 surface so a future refactor doesn't accidentally
1243
+ // drop a primitive. New entries are additive — extend this
1244
+ // list rather than replace.
1245
+ const a = new GoTemplateAdapter()
1246
+ const keys = Object.keys(a.templatePrimitives ?? {}).sort()
1247
+ expect(keys).toEqual(['JSON.stringify', 'Math.ceil', 'Math.floor', 'Math.round', 'Number', 'String'])
1248
+ })
1249
+
1250
+ test('unregistered identifier-path callee is NOT accepted by the registry', () => {
1251
+ // The registry is identifier-path-only and explicit. A
1252
+ // user-import like `customSerialize` is NOT registered, so
1253
+ // the Go adapter can't render it server-side. Pin so a
1254
+ // future refactor doesn't accidentally start accepting
1255
+ // arbitrary identifier-paths via this map.
1256
+ const a = new GoTemplateAdapter()
1257
+ expect(a.templatePrimitives?.['customSerialize']).toBeUndefined()
1258
+ })
1259
+
1260
+ test('wrong-arity primitive call falls back to BF101 instead of emitting invalid template', () => {
1261
+ // V1 emit fns blindly read `args[0]`. The arity gate must
1262
+ // reject 0-arg / 2-arg shapes so we don't ship invalid Go
1263
+ // template syntax (`bf_json` with no operand) or silently
1264
+ // drop extra args.
1265
+ const result = compileAndGenerate(`
1266
+ 'use client'
1267
+ export function Foo(props: { config: object; replacer: any }) {
1268
+ return <div data-x={JSON.stringify(props.config, props.replacer)}>hi</div>
1269
+ }
1270
+ `)
1271
+ // The substituted form must NOT appear with a stray second
1272
+ // arg; the call falls through and surfaces an error
1273
+ // instead.
1274
+ expect(result.template).not.toContain('bf_json')
1275
+ })
1276
+
1277
+ test('computed-member callee does NOT match a string-keyed registry path', () => {
1278
+ // `arr[0](x)` parses as a call whose callee is a
1279
+ // computed-member. `identifierPath` must return null for
1280
+ // computed members so a same-named primitive in the
1281
+ // registry can't be triggered through array indexing.
1282
+ // Pin: the substitution path doesn't fire here.
1283
+ const result = compileAndGenerate(`
1284
+ 'use client'
1285
+ export function Foo(props: { fns: ((x: any) => string)[]; v: any }) {
1286
+ return <div data-x={props.fns[0](props.v)}>hi</div>
1287
+ }
1288
+ `)
1289
+ expect(result.template).not.toContain('bf_json')
1290
+ expect(result.template).not.toContain('bf_string')
1291
+ })
1292
+
1293
+ test('two-tier source-of-truth keeps emit + arity in sync', () => {
1294
+ // Regression for the previous parallel-map shape (#1200
1295
+ // review): ensure every `templatePrimitives` key has a
1296
+ // matching arity entry, so a registry-only addition can't
1297
+ // silently bypass the arity gate.
1298
+ const a = new GoTemplateAdapter()
1299
+ const arities = (a as unknown as { templatePrimitiveArities: Record<string, number> }).templatePrimitiveArities
1300
+ for (const key of Object.keys(a.templatePrimitives ?? {})) {
1301
+ expect(arities[key]).toBeGreaterThan(0)
1302
+ }
1303
+ })
1304
+
1305
+ test('Math.floor(Number(...)) end-to-end via go run produces the expected rendered HTML', async () => {
1306
+ // The other tests assert template emission strings; this one
1307
+ // closes the loop by actually running `go run` against the
1308
+ // generated template + Go runtime helpers, so a regression in
1309
+ // the Go-side `Floor`/`Number` funcs surfaces here. Also
1310
+ // exercises chained-primitive composition (`bf_floor
1311
+ // (bf_number .Score)`) which the inline-direct emit path
1312
+ // produces for nested calls.
1313
+ //
1314
+ // The prop is typed `string` rather than `number` because
1315
+ // generateTypes maps TS `number` to Go `int`, and an `int`
1316
+ // field can't hold the fractional value we need to actually
1317
+ // exercise floor's rounding behaviour. Coercing through
1318
+ // `Number(props.score)` keeps the Go field as `string` and
1319
+ // shifts the float arithmetic into the runtime helpers.
1320
+ // Skipped on hosts without Go ≥ 1.25 (existing harness
1321
+ // GoNotAvailableError path).
1322
+ try {
1323
+ const html = await renderGoTemplateComponent({
1324
+ source: `
1325
+ 'use client'
1326
+ export function Foo(props: { score: string }) {
1327
+ return <div data-rounded={Math.floor(Number(props.score))}>hi</div>
1328
+ }
1329
+ `,
1330
+ adapter: new GoTemplateAdapter(),
1331
+ props: { score: '3.7' },
1332
+ })
1333
+ // Math.floor(Number("3.7")) === 3. Go float64 with integer
1334
+ // value formats as "3" via %v.
1335
+ expect(html).toContain('data-rounded="3"')
1336
+ } catch (err) {
1337
+ if (err instanceof GoNotAvailableError) {
1338
+ console.log('Skipping Math.floor e2e: go command not found')
1339
+ return
1340
+ }
1341
+ throw err
1342
+ }
1343
+ })
1344
+
1345
+ test('JSON.stringify end-to-end via go run produces the expected rendered HTML', async () => {
1346
+ try {
1347
+ const html = await renderGoTemplateComponent({
1348
+ source: `
1349
+ 'use client'
1350
+ export function Foo(props: { name: string }) {
1351
+ return <div data-config={JSON.stringify(props.name)}>hi</div>
1352
+ }
1353
+ `,
1354
+ adapter: new GoTemplateAdapter(),
1355
+ props: { name: 'alice' },
1356
+ })
1357
+ // `JSON.stringify("alice")` → `"alice"` (with quotes).
1358
+ // The template interpolates into an attribute value, so the
1359
+ // outer quotes get HTML-entity escaped.
1360
+ expect(html).toContain('&#34;alice&#34;')
1361
+ } catch (err) {
1362
+ if (err instanceof GoNotAvailableError) {
1363
+ console.log('Skipping JSON.stringify e2e: go command not found')
1364
+ return
1365
+ }
1366
+ throw err
1367
+ }
1368
+ })
1369
+ })
1370
+
1371
+ describe('NewXxxProps template-parts dispatch (#1275)', () => {
1372
+ // Companion to the Mojo / Hono unit tests for the
1373
+ // `record-index-lookup-via-child-prop` conformance fixture. The IR
1374
+ // producer collapses `template` → `expression` for component props
1375
+ // but preserves the parts on `ExpressionAttr.parts`; the Go adapter
1376
+ // must read those parts and emit an IIFE (`switch`-based) in the
1377
+ // generated `NewXxxProps` so the variant class is materialised at
1378
+ // SSR time. The previous behaviour silently dropped the prop —
1379
+ // visible end-to-end as `class=""` on the scaffold's Button.
1380
+ test('record-index-lookup via child prop emits a Go switch IIFE, not a dropped field', () => {
1381
+ const adapter = new GoTemplateAdapter()
1382
+ const ir = compileToIR(`
1383
+ import { Slot } from './slot'
1384
+ export function V({ variant }: { variant: 'a' | 'b' }) {
1385
+ const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
1386
+ return <Slot className={\`base \${classes[variant]}\`}>hi</Slot>
1387
+ }
1388
+ `, adapter)
1389
+ const out = adapter.generate(ir)
1390
+ const goCode = out.types ?? ''
1391
+ // The ClassName field MUST be set on the SlotInput literal.
1392
+ expect(goCode).toContain('ClassName:')
1393
+ // The IIFE shape: a self-invoking func that switches on the
1394
+ // variant key and returns the matching case.
1395
+ expect(goCode).toContain('func() string {')
1396
+ expect(goCode).toContain('in.Variant.(string)')
1397
+ expect(goCode).toContain('case "a": return "class-a"')
1398
+ expect(goCode).toContain('case "b": return "class-b"')
1399
+ })
1400
+
1401
+ test('intermediate-const composition (Button shape) carries through', () => {
1402
+ const adapter = new GoTemplateAdapter()
1403
+ const ir = compileToIR(`
1404
+ import { Slot } from './slot'
1405
+ export function V({ variant }: { variant: 'a' | 'b' }) {
1406
+ const classes: Record<'a' | 'b', string> = { a: 'class-a', b: 'class-b' }
1407
+ const composed = \`base \${classes[variant]}\`
1408
+ return <Slot className={composed}>hi</Slot>
1409
+ }
1410
+ `, adapter)
1411
+ const out = adapter.generate(ir)
1412
+ const goCode = out.types ?? ''
1413
+ expect(goCode).toContain('ClassName:')
1414
+ expect(goCode).toContain('case "a": return "class-a"')
1415
+ })
1416
+ })
1417
+
1418
+ describe('destructured / function-keyword filter shapes (#1443)', () => {
1419
+ test('.filter(({done}) => done).map(...) lowers cleanly', () => {
1420
+ // Pre-#1443 the destructured arrow rejected at the parser and the
1421
+ // surrounding `.map()` loop fell back to a BF101 path. With the
1422
+ // parser rewriting `({done}) => done` to `_t => _t.done`, the
1423
+ // adapter's existing IRLoop.filterPredicate path renders the
1424
+ // chain as `bf_filter .Items "Done" true`.
1425
+ const result = compileAndGenerate(`'use client'
1426
+ import { createSignal } from '@barefootjs/client'
1427
+ export function C() {
1428
+ const [items] = createSignal<any[]>([])
1429
+ return <ul>{items().filter(({done}) => done).map(t => <li key={t.id}>{t.name}</li>)}</ul>
1430
+ }`)
1431
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
1432
+ expect(result.template).toContain('bf_filter .Items "Done" true')
1433
+ })
1434
+
1435
+ test('.filter(function (x) { return x.done }).map(...) lowers cleanly', () => {
1436
+ // Function expressions with a single `return <expr>` body
1437
+ // normalise to the arrow-fn IR shape at parse time.
1438
+ const result = compileAndGenerate(`'use client'
1439
+ import { createSignal } from '@barefootjs/client'
1440
+ export function C() {
1441
+ const [items] = createSignal<any[]>([])
1442
+ return <ul>{items().filter(function (x) { return x.done }).map(t => <li key={t.id}>{t.name}</li>)}</ul>
1443
+ }`)
1444
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
1445
+ expect(result.template).toContain('bf_filter .Items "Done" true')
1446
+ })
1447
+ })
1448
+
1449
+ describe('registry Slot class-merge chain (#1443)', () => {
1450
+ test('[a, b].filter(Boolean).join(\' \') lowers to bf_join (bf_filter_truthy (bf_arr ...)) " "', () => {
1451
+ // The registry `<Slot>` merges className via
1452
+ // `[className, childClass].filter(Boolean).join(' ')`. Pre-#1443
1453
+ // each link in the chain (array literal, `Boolean` callable
1454
+ // filter, `.join`) hit a Go-side refusal gate and the chain
1455
+ // emitted BF101 — making the scaffold `<Button>` / `<Card>`
1456
+ // unusable on Go templates. The fix lowers all three:
1457
+ //
1458
+ // - `[a, b]` → `bf_arr a b` (variadic helper)
1459
+ // - `.filter(Boolean)` → `bf_filter_truthy <arr>`
1460
+ // - `.join(sep)` → `bf_join <arr> <sep>`
1461
+ //
1462
+ // Composing through paren-wrapped function calls keeps Go
1463
+ // template's prefix-call precedence well-formed.
1464
+ const result = compileAndGenerate(`
1465
+ "use client"
1466
+ function Slot({ children, className }: { children?: unknown; className?: string }) {
1467
+ if (children) {
1468
+ const merged = [className].filter(Boolean).join(' ')
1469
+ return <div className={merged}>x</div>
1470
+ }
1471
+ return <div>fallback</div>
1472
+ }
1473
+ export { Slot }
1474
+ `.trimStart())
1475
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
1476
+ expect(result.template).toContain('bf_join (bf_filter_truthy (bf_arr .ClassName)) " "')
1477
+ })
1478
+ })
1479
+
1480
+ describe('.includes lowering (#1448 Tier A)', () => {
1481
+ test('items.includes(target) emits `bf_includes .Items .Target` in condition position', () => {
1482
+ // Pre-#1448: BF102 ("Condition not supported") because the
1483
+ // `array-method` IR variant didn't include `includes`, so
1484
+ // `isSupported` refused. Now the parser produces an
1485
+ // `array-method` node and the condition-position dispatcher
1486
+ // (`renderConditionExpr`'s `array-method` arm) delegates to
1487
+ // the same `arrayMethod` emit as expression position, so the
1488
+ // `{{if bf_includes ...}}` shape works.
1489
+ const result = compileAndGenerate(`'use client'
1490
+ import { createSignal } from '@barefootjs/client'
1491
+ export function C() {
1492
+ const [items] = createSignal<string[]>([])
1493
+ const [target] = createSignal('x')
1494
+ return <div>{items().includes(target()) ? 'yes' : 'no'}</div>
1495
+ }`)
1496
+ expect(result.template).toContain('{{if bf_includes .Items .Target}}')
1497
+ })
1498
+
1499
+ test('value.includes(needle) emits the same bf_includes form (runtime dispatches on receiver)', () => {
1500
+ // String and array `.includes` share the parser surface; the
1501
+ // adapter emits the same `bf_includes` call and the Go
1502
+ // runtime helper inspects `reflect.Kind()` at evaluation time.
1503
+ const result = compileAndGenerate(`'use client'
1504
+ import { createSignal } from '@barefootjs/client'
1505
+ export function C() {
1506
+ const [value] = createSignal('hello world')
1507
+ const [needle] = createSignal('world')
1508
+ return <div>{value().includes(needle()) ? 'yes' : 'no'}</div>
1509
+ }`)
1510
+ expect(result.template).toContain('{{if bf_includes .Value .Needle}}')
1511
+ })
1512
+ })
1513
+
1514
+ describe('.indexOf / .lastIndexOf lowering (#1448 Tier A)', () => {
1515
+ test('items.indexOf(target) emits `bf_index_of .Items .Target`', () => {
1516
+ // Pre-#1448: parser refused `.indexOf` via `UNSUPPORTED_METHODS`
1517
+ // and surfaced BF101. The new `array-method` arm + the
1518
+ // `bf_index_of` runtime helper give it value-equality semantics
1519
+ // (DeepEqual against scalars / structs), disjoint from the
1520
+ // struct-field `bf_find_index` used by `.find`.
1521
+ const result = compileAndGenerate(`'use client'
1522
+ import { createSignal } from '@barefootjs/client'
1523
+ export function C() {
1524
+ const [items] = createSignal<string[]>([])
1525
+ const [target] = createSignal('x')
1526
+ return <div>idx: {items().indexOf(target())}</div>
1527
+ }`)
1528
+ expect(result.template).toContain('bf_index_of .Items .Target')
1529
+ // Defensive: must not route through the struct-field helper.
1530
+ expect(result.template).not.toContain('bf_find_index')
1531
+ })
1532
+
1533
+ test('items.lastIndexOf(target) emits `bf_last_index_of .Items .Target`', () => {
1534
+ // Backward-walk variant of indexOf — disambiguating the
1535
+ // duplicated-value case is the canonical reason an author
1536
+ // reaches for `.lastIndexOf` over `.indexOf`.
1537
+ const result = compileAndGenerate(`'use client'
1538
+ import { createSignal } from '@barefootjs/client'
1539
+ export function C() {
1540
+ const [items] = createSignal<string[]>([])
1541
+ const [target] = createSignal('x')
1542
+ return <div>last: {items().lastIndexOf(target())}</div>
1543
+ }`)
1544
+ expect(result.template).toContain('bf_last_index_of .Items .Target')
1545
+ })
1546
+ })
1547
+
1548
+ describe('.at lowering (#1448 Tier A)', () => {
1549
+ test('items.at(-1) emits `bf_at .Items (bf_neg 1)` (negative-index pass-through)', () => {
1550
+ // The Go runtime's `At` already handles negative indices —
1551
+ // `bf_at .Items -1` returns the last element. Go template
1552
+ // syntax doesn't accept literal negative numbers in prefix-
1553
+ // call positions, so the adapter routes the unary minus
1554
+ // through `bf_neg`; the runtime is unchanged.
1555
+ const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
1556
+ return <div>last: {items.at(-1)}</div>
1557
+ }
1558
+ export { A }`)
1559
+ expect(result.template).toContain('bf_at .Items (bf_neg 1)')
1560
+ })
1561
+
1562
+ test('items.at(i) with a signal index emits `bf_at .Items .I`', () => {
1563
+ // Non-literal index — the inner expression goes through the
1564
+ // standard `emit(arg)` path, so any supported expression form
1565
+ // composes (signal call, prop access, arithmetic, etc.).
1566
+ const result = compileAndGenerate(`'use client'
1567
+ import { createSignal } from '@barefootjs/client'
1568
+ export function C() {
1569
+ const [items] = createSignal<string[]>([])
1570
+ const [i] = createSignal(0)
1571
+ return <div>el: {items().at(i())}</div>
1572
+ }`)
1573
+ expect(result.template).toContain('bf_at .Items .I')
1574
+ })
1575
+ })
1576
+
1577
+ describe('.slice lowering (#1448 Tier A)', () => {
1578
+ test('items.slice(1, 3).join(\' \') chains through bf_slice → bf_join', () => {
1579
+ // 2-arg form. The canonical Tier A fixture pins the two-arg
1580
+ // start-and-end shape since a lowering that only handled
1581
+ // single-arg `.slice(start)` would still pass `.slice(1)`
1582
+ // but fail here.
1583
+ const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
1584
+ return <div>{items.slice(1, 3).join(' ')}</div>
1585
+ }
1586
+ export { A }`)
1587
+ expect(result.template).toContain('bf_join (bf_slice .Items 1 3) " "')
1588
+ })
1589
+
1590
+ test('items.slice(start) emits the 1-arg form (no `end`)', () => {
1591
+ // 1-arg form. The Go helper's variadic `end ...int` parameter
1592
+ // distinguishes "absent" from "0"; the absent case slices to
1593
+ // length, the explicit `0` case slices to empty.
1594
+ const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
1595
+ return <div>{items.slice(2).join(' ')}</div>
1596
+ }
1597
+ export { A }`)
1598
+ expect(result.template).toContain('bf_join (bf_slice .Items 2) " "')
1599
+ })
1600
+ })
1601
+
1602
+ describe('.toLowerCase / .toUpperCase lowering (#1448 Tier A)', () => {
1603
+ test('value.toLowerCase() emits `bf_lower .Value`', () => {
1604
+ // Pre-#1448: parser refused `.toLowerCase` via
1605
+ // `UNSUPPORTED_METHODS` and surfaced BF101. The runtime's
1606
+ // `bf_lower` helper has been registered from a prior code
1607
+ // path; this PR wires the JS method name to it.
1608
+ const result = compileAndGenerate(`function A({ value }: { value: string }) {
1609
+ return <div>{value.toLowerCase()}</div>
1610
+ }
1611
+ export { A }`)
1612
+ expect(result.template).toContain('bf_lower .Value')
1613
+ })
1614
+
1615
+ test('value.toUpperCase() emits `bf_upper .Value`', () => {
1616
+ // Mirrors toLowerCase — pre-existing `bf_upper` runtime
1617
+ // helper, JS method name wired at the adapter layer.
1618
+ const result = compileAndGenerate(`function A({ value }: { value: string }) {
1619
+ return <div>{value.toUpperCase()}</div>
1620
+ }
1621
+ export { A }`)
1622
+ expect(result.template).toContain('bf_upper .Value')
1623
+ })
1624
+
1625
+ test('value.trim() emits `bf_trim .Value`', () => {
1626
+ // Pre-existing `bf_trim` (wraps `strings.TrimSpace`); only
1627
+ // adapter wiring is new.
1628
+ const result = compileAndGenerate(`function A({ value }: { value: string }) {
1629
+ return <div>[{value.trim()}]</div>
1630
+ }
1631
+ export { A }`)
1632
+ expect(result.template).toContain('bf_trim .Value')
1633
+ })
1634
+ })
1635
+
1636
+ describe('.reverse / .toReversed lowering (#1448 Tier A)', () => {
1637
+ test('items.reverse().join(\' \') chains through bf_reverse → bf_join', () => {
1638
+ // `Array.prototype.reverse()` mutates the receiver in JS, but
1639
+ // in SSR template context the receiver is never observed,
1640
+ // so the helper returns a new slice. Composes with .join(' ')
1641
+ // to make the reversed order visible in the rendered output.
1642
+ const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
1643
+ return <div>{items.reverse().join(' ')}</div>
1644
+ }
1645
+ export { A }`)
1646
+ expect(result.template).toContain('bf_join (bf_reverse .Items) " "')
1647
+ })
1648
+
1649
+ test('items.toReversed().join(\' \') routes through the same helper', () => {
1650
+ // `.toReversed()` is the non-mutating sibling. Sharing a
1651
+ // lowering with `.reverse()` is fine in template context.
1652
+ const result = compileAndGenerate(`function A({ items }: { items: string[] }) {
1653
+ return <div>{items.toReversed().join(' ')}</div>
1654
+ }
1655
+ export { A }`)
1656
+ expect(result.template).toContain('bf_join (bf_reverse .Items) " "')
1657
+ })
1658
+ })
1659
+
1660
+ describe('.concat lowering (#1448 Tier A)', () => {
1661
+ test('left.concat(right).join(\' \') chains through bf_concat → bf_join', () => {
1662
+ // Composition pin: the canonical Tier A fixture
1663
+ // (`packages/adapter-tests/fixtures/methods/array-concat.ts`)
1664
+ // composes `.concat(...).join(' ')` so the concatenation
1665
+ // result must be a real iterable (`[]any` from `bf_concat`),
1666
+ // not a stringified `[object Object]` from a wrong lowering.
1667
+ const result = compileAndGenerate(`function A({ left, right }: { left: string[]; right: string[] }) {
1668
+ return <div>{left.concat(right).join(' ')}</div>
1669
+ }
1670
+ export { A }`)
1671
+ expect(result.template).toContain('bf_join (bf_concat .Left .Right) " "')
1672
+ })
1673
+ })
1674
+ })
1675
+
1676
+ // =============================================================================
1677
+ // #1448 Tier A — fixture-driven lowering pins
1678
+ // =============================================================================
1679
+ //
1680
+ // Companion to the Mojo adapter's fixture-driven block (see
1681
+ // `packages/adapter-mojolicious/src/__tests__/mojo-adapter.test.ts`).
1682
+ // The conformance test suite above renders every fixture end-to-end
1683
+ // through `go run` and compares HTML — strongest possible signal —
1684
+ // but skips with `GoNotAvailableError` on hosts without Go installed.
1685
+ // This block compiles each Tier A fixture's `source` through the
1686
+ // adapter and pins the emitted helper-call substring directly on
1687
+ // the Go template string. No `go run` needed; runs on every host.
1688
+ //
1689
+ // One row per Tier A method fixture from
1690
+ // packages/adapter-tests/fixtures/methods/. Each PR in the Tier A
1691
+ // stack appends its rows as the corresponding lowering lands.
1692
+
1693
+ import { fixture as arrayIncludesFixture } from '../../../adapter-tests/fixtures/methods/array-includes'
1694
+ import { fixture as stringIncludesFixture } from '../../../adapter-tests/fixtures/methods/string-includes'
1695
+ import { fixture as arrayIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-indexOf'
1696
+ import { fixture as arrayLastIndexOfFixture } from '../../../adapter-tests/fixtures/methods/array-lastIndexOf'
1697
+ import { fixture as arrayAtFixture } from '../../../adapter-tests/fixtures/methods/array-at'
1698
+ import { fixture as arrayConcatFixture } from '../../../adapter-tests/fixtures/methods/array-concat'
1699
+ import { fixture as arraySliceFixture } from '../../../adapter-tests/fixtures/methods/array-slice'
1700
+ import { fixture as arrayReverseFixture } from '../../../adapter-tests/fixtures/methods/array-reverse'
1701
+ import { fixture as arrayToReversedFixture } from '../../../adapter-tests/fixtures/methods/array-toReversed'
1702
+ import { fixture as stringToLowerCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toLowerCase'
1703
+ import { fixture as stringToUpperCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toUpperCase'
1704
+ import { fixture as stringTrimFixture } from '../../../adapter-tests/fixtures/methods/string-trim'
1705
+ // #1448 Tier B — .sort / .toSorted fixtures.
1706
+ import { fixture as arraySortFieldAscFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-asc'
1707
+ import { fixture as arraySortFieldDescFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-desc'
1708
+ import { fixture as arraySortPrimitiveFixture } from '../../../adapter-tests/fixtures/methods/array-sort-primitive'
1709
+ import { fixture as arraySortLocaleFixture } from '../../../adapter-tests/fixtures/methods/array-sort-locale'
1710
+ import { fixture as arrayToSortedFixture } from '../../../adapter-tests/fixtures/methods/array-toSorted'
1711
+
1712
+ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
1713
+ const cases = [
1714
+ // The `.includes` fixtures sit at condition position
1715
+ // (`{cond ? 'yes' : 'no'}`), so the emit lands inside `{{if ...}}`.
1716
+ { fixture: arrayIncludesFixture, expect: '{{if bf_includes .Items .Target}}' },
1717
+ { fixture: stringIncludesFixture, expect: '{{if bf_includes .Value .Needle}}' },
1718
+ { fixture: arrayIndexOfFixture, expect: 'bf_index_of .Items .Target' },
1719
+ { fixture: arrayLastIndexOfFixture, expect: 'bf_last_index_of .Items .Target' },
1720
+ // The literal `-1` lowers through `bf_neg 1` — Go template
1721
+ // doesn't accept literal negative numbers in prefix-call
1722
+ // positions. Pre-existing unary-emit pattern.
1723
+ { fixture: arrayAtFixture, expect: 'bf_at .Items (bf_neg 1)' },
1724
+ { fixture: arrayConcatFixture, expect: 'bf_concat .Left .Right' },
1725
+ { fixture: arraySliceFixture, expect: 'bf_slice .Items 1 3' },
1726
+ { fixture: arrayReverseFixture, expect: 'bf_reverse .Items' },
1727
+ // .toReversed shares the helper with .reverse — pinning both
1728
+ // routings catches a future divergence between them.
1729
+ { fixture: arrayToReversedFixture, expect: 'bf_reverse .Items' },
1730
+ { fixture: stringToLowerCaseFixture,expect: 'bf_lower .Value' },
1731
+ { fixture: stringToUpperCaseFixture,expect: 'bf_upper .Value' },
1732
+ { fixture: stringTrimFixture, expect: 'bf_trim .Value' },
1733
+ // #1448 Tier B — sort / toSorted. Loop-chained shapes wrap the
1734
+ // iterable in `bf_sort .Items <kind> <key> <type> <dir>`;
1735
+ // standalone shapes inline the helper at the call site.
1736
+ { fixture: arraySortFieldAscFixture, expect: 'bf_sort .Items "field" "Price" "numeric" "asc"' },
1737
+ { fixture: arraySortFieldDescFixture, expect: 'bf_sort .Items "field" "Price" "numeric" "desc"' },
1738
+ { fixture: arraySortPrimitiveFixture, expect: 'bf_sort .Nums "self" "" "numeric" "asc"' },
1739
+ { fixture: arraySortLocaleFixture, expect: 'bf_sort .Names "self" "" "string" "asc"' },
1740
+ { fixture: arrayToSortedFixture, expect: 'bf_sort .Nums "self" "" "numeric" "asc"' },
1741
+ ]
1742
+
1743
+ for (const { fixture, expect: expectedHelper } of cases) {
1744
+ test(`[${fixture.id}] lowers to \`${expectedHelper}\``, () => {
1745
+ const adapter = new GoTemplateAdapter()
1746
+ const result = compileJSX(fixture.source, `${fixture.id}.tsx`, { adapter })
1747
+ // No BF101 — the parser arm + adapter case took the call.
1748
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
1749
+ // ...and no BF102 — `.includes` lands at condition position so
1750
+ // a regression to the "Condition not supported" path would
1751
+ // surface here.
1752
+ expect(result.errors?.filter(e => e.code === 'BF102') ?? []).toEqual([])
1753
+ const template = result.files.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
1754
+ expect(template).toContain(expectedHelper)
1755
+ })
1756
+ }
1757
+ })