@proulr/sdui-components 0.1.88 → 0.1.90

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/sdui-components",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@proulr/sdui-contract": "^0.1.17",
42
+ "@proulr/sdui-contract": "^0.1.19",
43
43
  "react-native-qrcode-svg": "^6.3.21"
44
44
  },
45
45
  "peerDependencies": {
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileBdcToWire } from './compile-bdc-to-wire';
3
+ import { ListRowTemplate } from './components/list';
4
+ import { Button, Column, Screen, Text } from './components/ui';
5
+ import { SocialPostFeed } from './components/native-blocks';
6
+ import type { BdcElement } from './jsx-runtime';
7
+
8
+ function screen(children: BdcElement[]): BdcElement {
9
+ return Screen({ id: 'social.feed', data: { posts: [{ id: 'p1' }] }, children });
10
+ }
11
+
12
+ describe('compileBdcToWire — row templates', () => {
13
+ it('hoists <ListRowTemplate> subtree into host props.row_template', () => {
14
+ const host = SocialPostFeed({
15
+ dataRef: 'social.posts',
16
+ children: [
17
+ ListRowTemplate({
18
+ itemKey: 'id',
19
+ section: 'posts',
20
+ children: [
21
+ Column({
22
+ children: [
23
+ Text({ text: '{{item.body}}' }),
24
+ Button({ actionId: 'open_post_{{item.id}}', label: 'Abrir' }),
25
+ ],
26
+ }),
27
+ ],
28
+ }),
29
+ ],
30
+ });
31
+
32
+ const wire = compileBdcToWire(screen([host]));
33
+ const feed = wire.blocks[0];
34
+
35
+ expect(feed.type).toBe('social_post_feed');
36
+ expect(feed.props).toMatchObject({
37
+ data_ref: 'social.posts',
38
+ item_key: 'id',
39
+ section: 'posts',
40
+ });
41
+
42
+ // Template element never reaches the wire as a child block.
43
+ expect(feed.children).toEqual([]);
44
+
45
+ const rows = feed.props?.row_template as BdcElement[];
46
+ expect(Array.isArray(rows)).toBe(true);
47
+ expect(rows).toHaveLength(1);
48
+ expect(rows[0]).toMatchObject({ type: 'column', id: '0.row_template.0.0' });
49
+ // Bindings stay verbatim in the wire — substitution happens at materialize time.
50
+ const inner = (rows[0] as unknown as BdcElement).children;
51
+ expect(inner[0].props.text).toBe('{{item.body}}');
52
+ expect(inner[1].props.action_id).toBe('open_post_{{item.id}}');
53
+ });
54
+
55
+ it('defaults item_key to "id" and omits section when not set', () => {
56
+ const wire = compileBdcToWire(
57
+ screen([
58
+ SocialPostFeed({
59
+ children: [ListRowTemplate({ children: [Text({ text: 'x' })] })],
60
+ }),
61
+ ]),
62
+ );
63
+ expect(wire.blocks[0]?.props).toMatchObject({ item_key: 'id' });
64
+ expect('section' in (wire.blocks[0]?.props ?? {})).toBe(false);
65
+ });
66
+
67
+ it('single-template host: wire is byte-identical to before (no row_templates key)', () => {
68
+ const host = SocialPostFeed({
69
+ dataRef: 'social.posts',
70
+ children: [
71
+ Text({ text: 'header' }),
72
+ ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'a' })] }),
73
+ ],
74
+ });
75
+ const wire = compileBdcToWire(screen([host]));
76
+ const feed = wire.blocks[0];
77
+ expect(feed.children?.map((c) => c.props?.text)).toEqual(['header']);
78
+ expect(feed.props?.row_template).toHaveLength(1);
79
+ expect('row_templates' in (feed.props ?? {})).toBe(false);
80
+ expect(feed.props).toMatchObject({ item_key: 'id' });
81
+ });
82
+
83
+ it('multi-template host: row_templates is ordered (own item_key/section each), row_template stays the FIRST', () => {
84
+ const host = SocialPostFeed({
85
+ dataRef: 'social.posts',
86
+ children: [
87
+ ListRowTemplate({ itemKey: 'id', section: 'posts', children: [Text({ text: 'a' })] }),
88
+ ListRowTemplate({ itemKey: 'comment_id', section: 'comments', children: [Text({ text: 'b' })] }),
89
+ ],
90
+ });
91
+ const wire = compileBdcToWire(screen([host]));
92
+ const feed = wire.blocks[0];
93
+
94
+ // Backward compat: single-shape props describe the first template.
95
+ expect(feed.props).toMatchObject({ item_key: 'id', section: 'posts' });
96
+ expect(feed.props?.row_template).toHaveLength(1);
97
+ expect(((feed.props?.row_template as BdcElement[])[0]).props.text).toBe('a');
98
+
99
+ const set = feed.props?.row_templates as Record<string, unknown>[];
100
+ expect(Array.isArray(set)).toBe(true);
101
+ expect(set).toHaveLength(2);
102
+ expect(set[0]).toMatchObject({ item_key: 'id', section: 'posts' });
103
+ expect(set[1]).toMatchObject({ item_key: 'comment_id', section: 'comments' });
104
+ expect(set[1].row_template).toHaveLength(1);
105
+ expect((set[1].row_template as BdcElement[])[0].props.text).toBe('b');
106
+ });
107
+
108
+ it('template ids stay unique via host path + template index', () => {
109
+ const host = SocialPostFeed({
110
+ children: [
111
+ ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'a' })] }),
112
+ ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'b' }), Text({ text: 'c' })] }),
113
+ ],
114
+ });
115
+ const wire = compileBdcToWire(screen([host]));
116
+ const set = wire.blocks[0]?.props?.row_templates as { row_template: { id: string }[] }[];
117
+ const ids = set.flatMap((entry) => entry.row_template.map((block) => block.id));
118
+ expect(ids).toEqual(['0.row_template.0.0', '0.row_template.1.0', '0.row_template.1.1']);
119
+ expect(new Set(ids).size).toBe(ids.length);
120
+ });
121
+
122
+ it('template entry omits section when not set; item_key defaults to "id"', () => {
123
+ const host = SocialPostFeed({
124
+ children: [
125
+ ListRowTemplate({ children: [Text({ text: 'a' })] }),
126
+ ListRowTemplate({ itemKey: 'id', section: 'b', children: [Text({ text: 'b' })] }),
127
+ ],
128
+ });
129
+ const wire = compileBdcToWire(screen([host]));
130
+ const set = wire.blocks[0]?.props?.row_templates as Record<string, unknown>[];
131
+ expect(set[0]).toMatchObject({ item_key: 'id' });
132
+ expect('section' in set[0]).toBe(false);
133
+ expect(set[1]).toMatchObject({ item_key: 'id', section: 'b' });
134
+ // First template has no section → host-level `section` stays absent (compat).
135
+ expect('section' in (wire.blocks[0]?.props ?? {})).toBe(false);
136
+ });
137
+
138
+ it('flattens fragments wrapping template rows', () => {
139
+ const host = SocialPostFeed({
140
+ children: [
141
+ {
142
+ type: 'list_row_template',
143
+ props: { item_key: 'id' },
144
+ children: [
145
+ {
146
+ type: '_fragment',
147
+ props: {},
148
+ children: [{ type: 'text', props: { text: '{{item.a.b}}' }, children: [] }],
149
+ },
150
+ ],
151
+ },
152
+ ],
153
+ });
154
+ const wire = compileBdcToWire(screen([host]));
155
+ const rows = wire.blocks[0]?.props?.row_template;
156
+ expect(rows).toHaveLength(1);
157
+ expect((rows as unknown[])[0]).toMatchObject({ type: 'text', props: { text: '{{item.a.b}}' } });
158
+ });
159
+
160
+ it('supports templates inside any block (generic children handling), e.g. Column host', () => {
161
+ const host = Column({
162
+ children: [ListRowTemplate({ itemKey: 'id', children: [Text({ text: 'r' })] })],
163
+ });
164
+ const wire = compileBdcToWire(screen([host]));
165
+ expect(wire.blocks[0]?.props).toMatchObject({ item_key: 'id' });
166
+ expect(wire.blocks[0]?.props?.row_template).toHaveLength(1);
167
+ expect(wire.blocks[0]?.children).toEqual([]);
168
+ });
169
+
170
+ it('blocks without row_template compile exactly as before (backward compat)', () => {
171
+ const wire = compileBdcToWire(
172
+ screen([Column({ children: [Text({ text: 'plain' })] })]),
173
+ );
174
+ const col = wire.blocks[0];
175
+ expect(col.props).not.toHaveProperty('row_template');
176
+ expect(col.props).not.toHaveProperty('item_key');
177
+ expect(col.children).toHaveLength(1);
178
+ });
179
+ });
@@ -74,6 +74,57 @@ function flattenBlockChildren(children: BdcElement[]): BdcElement[] {
74
74
  return out;
75
75
  }
76
76
 
77
+ /** Tipo BDC de `<ListRowTemplate>` — promoted para `props.row_template` do hospedeiro. */
78
+ const LIST_ROW_TEMPLATE_TYPE = 'list_row_template';
79
+
80
+ /** Um template promovido: blocos da linha + item_key/section próprios. */
81
+ type RowTemplateEntry = {
82
+ row_template: SduiBlockWire[];
83
+ item_key: string;
84
+ section?: string;
85
+ };
86
+
87
+ function hoistRowTemplates(
88
+ blockChildren: BdcElement[],
89
+ props: Record<string, unknown>,
90
+ path: string,
91
+ ): BdcElement[] {
92
+ const templates = blockChildren.filter((child) => child.type === LIST_ROW_TEMPLATE_TYPE);
93
+ if (templates.length === 0) return blockChildren;
94
+
95
+ const entries: RowTemplateEntry[] = templates.map((template, templateIndex) => {
96
+ const rowBlocks: SduiBlockWire[] = [];
97
+ flattenBlockChildren(template.children).forEach((child, childIndex) => {
98
+ rowBlocks.push(mapBlock(child, `${path}.row_template.${templateIndex}.${childIndex}`));
99
+ });
100
+ const itemKey = template.props.item_key ?? template.props.itemKey;
101
+ const entry: RowTemplateEntry = {
102
+ row_template: rowBlocks,
103
+ item_key: typeof itemKey === 'string' && itemKey ? itemKey : 'id',
104
+ };
105
+ if (typeof template.props.section === 'string' && template.props.section) {
106
+ entry.section = template.props.section;
107
+ }
108
+ return entry;
109
+ });
110
+
111
+ // Backward compat: as props singulares descrevem SEMPRE o primeiro template —
112
+ // leitores antigos (`readSduiRowTemplate`) continuam funcionando inalterados.
113
+ props.row_template = entries[0].row_template;
114
+ props.item_key = entries[0].item_key;
115
+ if (entries[0].section !== undefined) {
116
+ props.section = entries[0].section;
117
+ }
118
+ // Host multi-template: lista ordenada, um entry por template (cada um com
119
+ // seu `item_key`/`section`). Hosts de template único mantém o wire idêntico
120
+ // (sem `row_templates`). Leitura: `readSduiRowTemplates` no renderer.
121
+ if (entries.length > 1) {
122
+ props.row_templates = entries;
123
+ }
124
+
125
+ return blockChildren.filter((child) => child.type !== LIST_ROW_TEMPLATE_TYPE);
126
+ }
127
+
77
128
  function mapBlock(element: BdcElement, path: string): SduiBlockWire {
78
129
  if (element.type === '_text_node') {
79
130
  return { id: path, type: 'text', props: { text: String(element.props.value ?? '') } };
@@ -89,7 +140,7 @@ function mapBlock(element: BdcElement, path: string): SduiBlockWire {
89
140
  props.text = textFromChildren(element.children);
90
141
  }
91
142
 
92
- const blockChildren = flattenBlockChildren(element.children);
143
+ const blockChildren = hoistRowTemplates(flattenBlockChildren(element.children), props, path);
93
144
 
94
145
  return {
95
146
  id: typeof element.props.id === 'string' ? element.props.id : path,
@@ -14,6 +14,7 @@ function layoutBlock(type: string, props: LayoutProps): BdcElement {
14
14
  type ChipProps = LayoutProps & {
15
15
  label: string;
16
16
  glyph?: string;
17
+ tone?: 'primary' | 'secondary';
17
18
  actionId?: string;
18
19
  actionParams?: Record<string, string>;
19
20
  active?: boolean;
@@ -21,7 +22,10 @@ type ChipProps = LayoutProps & {
21
22
 
22
23
  export const Chip: BdcComponent = (props) => layoutBlock('chip', props as ChipProps);
23
24
 
24
- type ChipRowProps = LayoutProps;
25
+ type ChipRowProps = LayoutProps & {
26
+ /** Caminho em `screen.data` para chips dinâmicos: `{ label, glyph?, tone?, actionId?, actionParams? }[]`. */
27
+ itemsRef?: string;
28
+ };
25
29
 
26
30
  export const ChipRow: BdcComponent = (props) => layoutBlock('chip_row', props as ChipRowProps);
27
31
 
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ListRowTemplate } from './list';
3
+ import { Text } from './ui';
4
+ import type { BdcElement } from '../jsx-runtime';
5
+
6
+ function el(type: string, props: Record<string, unknown>, children: BdcElement[] = []): BdcElement {
7
+ return { type, props, children };
8
+ }
9
+
10
+ describe('ListRowTemplate (BDC)', () => {
11
+ it('normalizes itemKey to wire item_key with default id', () => {
12
+ const node = ListRowTemplate({ itemKey: 'post_id', children: [el('text', { text: 'x' })] });
13
+ expect(node.type).toBe('list_row_template');
14
+ expect(node.props.item_key).toBe('post_id');
15
+ expect(node.children).toHaveLength(1);
16
+
17
+ const defaulted = ListRowTemplate({});
18
+ expect(defaulted.props.item_key).toBe('id');
19
+ expect(defaulted.children).toEqual([]);
20
+ });
21
+
22
+ it('accepts snake_case item_key and hoists section only when set', () => {
23
+ const withSection = ListRowTemplate({ item_key: 'id', section: 'comments' });
24
+ expect(withSection.props.item_key).toBe('id');
25
+ expect(withSection.props.section).toBe('comments');
26
+
27
+ const withoutSection = ListRowTemplate({ item_key: 'id', section: '' });
28
+ expect('section' in withoutSection.props).toBe(false);
29
+ });
30
+
31
+ it('composes with BDC children', () => {
32
+ const row = Text({ text: '{{item.body}}' });
33
+ const node = ListRowTemplate({ itemKey: 'id', children: [row] });
34
+ expect(node.children[0]?.props.text).toBe('{{item.body}}');
35
+ });
36
+ });
@@ -0,0 +1,62 @@
1
+ import type { BdcComponent, BdcElement } from '../jsx-runtime';
2
+
3
+ /**
4
+ * `<ListRowTemplate>` — marca de autoria (BFF) que transforma o subtree de
5
+ * filhos num **row template** do bloco hospedeiro (híbrido nativo: `SocialPostFeed`,
6
+ * `ThoughtFeed`, `PostComments`, `SocialProfile`, `ProfileView`, `ProfileSelf`,
7
+ * `StoryViewersPanel`, `RightNowFeed`, `AlbumGrid`, …).
8
+ *
9
+ * `compileBdcToWire` remove o elemento dos `children` do hospedeiro e promove
10
+ * no `props` dele (ver `SduiRowTemplateWireProps` em `@proulr/sdui-contract`):
11
+ *
12
+ * ```
13
+ * row_template: SduiBlockWire[] // subtree compilado (wire normal, snake_case)
14
+ * item_key: string // campo identificador de cada item (default 'id'; dot path ok)
15
+ * section?: string // marcador opcional de seção (só quando definido)
16
+ * ```
17
+ *
18
+ * Bindings: qualquer string em props do subtree (texto, imagem, `actionId`,
19
+ * payloads de action aninhados) pode usar `{{item.campo}}` / `{{item.a.b}}`;
20
+ * o app resolve em `materializeRowTemplate` (renderer) por item vivo do
21
+ * `screen.data`. Path não resolvido → string vazia.
22
+ *
23
+ * **Multi-template:** cada `<ListRowTemplate>` carrega o **próprio**
24
+ * `item_key`/`section`. Com dois ou mais templates no mesmo hospedeiro, o
25
+ * `compileBdcToWire` emite `props.row_templates` (lista ordenada, um entry por
26
+ * template) e mantém `row_template`/`item_key`/`section` = primeiro template
27
+ * (backward compat; leitura via `readSduiRowTemplates` no renderer).
28
+ *
29
+ * Restrições documentadas:
30
+ * - deve ser **filho direto** do bloco hospedeiro;
31
+ * - `item_key`/`section` são **por template** (default `item_key` = `'id'`);
32
+ * - nunca chega ao wire como bloco renderizável (não registrar no renderer).
33
+ *
34
+ * @example
35
+ * <SocialPostFeed dataRef="social.posts">
36
+ * <ListRowTemplate itemKey="id">
37
+ * <Column>
38
+ * <Text text="{{item.author.name}}" />
39
+ * <Button label="Abrir" actionId="open_post_{{item.id}}" />
40
+ * </Column>
41
+ * </ListRowTemplate>
42
+ * </SocialPostFeed>
43
+ */
44
+ type ListRowTemplateProps = {
45
+ id?: string;
46
+ /** Campo identificador de cada item vivo (ex.: `'id'`, `'post.id'`). Default `'id'`. */
47
+ itemKey?: string;
48
+ /** Aceita também a forma snake_case do wire. */
49
+ item_key?: string;
50
+ /** Marcador opcional de seção para feeds com múltiplas seções. */
51
+ section?: string;
52
+ children?: BdcElement[];
53
+ };
54
+
55
+ export const ListRowTemplate: BdcComponent = (props) => {
56
+ const { id, itemKey, item_key: itemKeySnake, section, children } = props as ListRowTemplateProps;
57
+ const itemKeyWire = itemKey ?? itemKeySnake ?? 'id';
58
+ const elementProps: Record<string, unknown> = { item_key: itemKeyWire };
59
+ if (typeof id === 'string') elementProps.id = id;
60
+ if (typeof section === 'string' && section) elementProps.section = section;
61
+ return { type: 'list_row_template', props: elementProps, children: children ?? [] };
62
+ };
@@ -0,0 +1,268 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Avatar, ContentImage, IconButton, RowOverflowMenu, Video } from './media';
3
+ import { compileBdcBlock, compileBdcToWire } from '../compile-bdc-to-wire';
4
+ import { ListRowTemplate } from './list';
5
+ import { SocialPostFeed } from './native-blocks';
6
+ import { Column, Screen } from './ui';
7
+ import type { BdcElement } from '../jsx-runtime';
8
+
9
+ describe('Video (BDC)', () => {
10
+ it('emits video with binding path/poster and tokens; aliases normalized', () => {
11
+ const wire = compileBdcBlock(
12
+ Video({
13
+ pathRef: '{{item.video_path}}',
14
+ posterPath: '{{item.poster_path}}',
15
+ width: 480,
16
+ aspect: 'video',
17
+ corner: 'cornerMedium',
18
+ fit: 'contain',
19
+ testId: 'clip-video',
20
+ }),
21
+ );
22
+ expect(wire.type).toBe('video');
23
+ expect(wire.props).toEqual({
24
+ path: '{{item.video_path}}',
25
+ poster_path: '{{item.poster_path}}',
26
+ width: 480,
27
+ aspect: 'video',
28
+ corner: 'cornerMedium',
29
+ fit: 'contain',
30
+ test_id: 'clip-video',
31
+ });
32
+ });
33
+
34
+ it('drops undefined props and keeps poster absent when not authored', () => {
35
+ const node = Video({ path: 'clips/a.mp4' });
36
+ expect(node.type).toBe('video');
37
+ expect('poster_path' in node.props).toBe(false);
38
+ expect('width' in node.props).toBe(false);
39
+ });
40
+
41
+ it('composes into a row template with bindings verbatim', () => {
42
+ const host = SocialPostFeed({
43
+ children: [
44
+ ListRowTemplate({
45
+ itemKey: 'id',
46
+ children: [Video({ pathRef: '{{item.clip_path}}', aspect: 'story' })],
47
+ }),
48
+ ],
49
+ });
50
+ const wire = compileBdcToWire(Screen({ id: 'social.feed', children: [host] }));
51
+ const rows = wire.blocks[0]?.props?.row_template as BdcElement[];
52
+ expect(rows[0].type).toBe('video');
53
+ expect(rows[0].props.path).toBe('{{item.clip_path}}');
54
+ });
55
+ });
56
+
57
+ describe('RowOverflowMenu (BDC)', () => {
58
+ it('normalizes nested actions to snake_case action_id (arrays skip camelToSnake)', () => {
59
+ const wire = compileBdcBlock(
60
+ RowOverflowMenu({
61
+ actions: [
62
+ { actionId: 'delete_post_{{item.id}}', label: 'Excluir', icon: 'trash', destructive: true },
63
+ { actionId: 'bookmark_{{item.id}}', label: 'Salvar' },
64
+ ],
65
+ icon: 'overflow',
66
+ testId: 'post-menu',
67
+ }),
68
+ );
69
+ expect(wire.type).toBe('row_overflow_menu');
70
+ expect(wire.props).toEqual({
71
+ actions: [
72
+ { action_id: 'delete_post_{{item.id}}', label: 'Excluir', icon: 'trash', destructive: true },
73
+ { action_id: 'bookmark_{{item.id}}', label: 'Salvar' },
74
+ ],
75
+ icon: 'overflow',
76
+ test_id: 'post-menu',
77
+ });
78
+ });
79
+
80
+ it('accepts snake_case authoring and omits undefined keys per action', () => {
81
+ const node = RowOverflowMenu({ actions: [{ action_id: 'a', label: 'A' }] });
82
+ expect(node.props.actions).toEqual([{ action_id: 'a', label: 'A' }]);
83
+ expect('icon' in node.props).toBe(false);
84
+ });
85
+
86
+ it('keeps item bindings untouched inside label/action_id (template-safe)', () => {
87
+ const host = SocialPostFeed({
88
+ children: [
89
+ ListRowTemplate({
90
+ itemKey: 'id',
91
+ children: [
92
+ RowOverflowMenu({ actions: [{ actionId: 'report_{{item.id}}', label: '{{item.author.name}}' }], icon: 'overflow' }),
93
+ ],
94
+ }),
95
+ ],
96
+ });
97
+ const wire = compileBdcToWire(Screen({ id: 'social.feed', children: [host] }));
98
+ const rows = wire.blocks[0]?.props?.row_template as BdcElement[];
99
+ const actions = rows[0].props.actions as Record<string, unknown>[];
100
+ expect(actions[0].action_id).toBe('report_{{item.id}}');
101
+ expect(actions[0].label).toBe('{{item.author.name}}');
102
+ });
103
+ });
104
+
105
+ describe('registry wiring for new block types (static)', () => {
106
+ it('bdc media factories map to catalog block types video / row_overflow_menu', () => {
107
+ expect(Video({ path: 'a.mp4' }).type).toBe('video');
108
+ expect(RowOverflowMenu({ actions: [] }).type).toBe('row_overflow_menu');
109
+ });
110
+ });
111
+
112
+ describe('ContentImage (BDC)', () => {
113
+ it('emits content_image with path and tokens preserved verbatim', () => {
114
+ const node = ContentImage({
115
+ path: '{{item.media_path}}',
116
+ width: 96,
117
+ aspect: 'square',
118
+ corner: 'cornerMedium',
119
+ fit: 'cover',
120
+ blurFill: true,
121
+ testId: 'feed-image',
122
+ });
123
+ expect(node.type).toBe('content_image');
124
+ expect(node.props?.path).toBe('{{item.media_path}}');
125
+ expect(node.props.aspect).toBe('square');
126
+ expect(node.props.corner).toBe('cornerMedium');
127
+ expect(node.children).toEqual([]);
128
+ });
129
+
130
+ it('normalizes pathRef/path_ref aliases onto wire path', () => {
131
+ expect(ContentImage({ pathRef: 'posts/a.jpg' }).props?.path).toBe('posts/a.jpg');
132
+ expect(ContentImage({ path_ref: 'posts/b.jpg' }).props?.path).toBe('posts/b.jpg');
133
+ expect('pathRef' in ContentImage({ path: 'x' }).props).toBe(false);
134
+ });
135
+
136
+ it('compiles camel props to snake_case wire, keeping bindings untouched', () => {
137
+ const wire = compileBdcBlock(
138
+ ContentImage({
139
+ id: 'img',
140
+ path: '{{item.media_path}}',
141
+ blurFill: true,
142
+ testId: 'row-image',
143
+ fit: 'contain',
144
+ }),
145
+ );
146
+ expect(wire.type).toBe('content_image');
147
+ expect(wire.props).toEqual({
148
+ id: 'img',
149
+ path: '{{item.media_path}}',
150
+ blur_fill: true,
151
+ test_id: 'row-image',
152
+ fit: 'contain',
153
+ });
154
+ });
155
+
156
+ it('drops undefined props (no noise on the wire)', () => {
157
+ const node = ContentImage({ path: 'a.jpg', width: undefined });
158
+ expect('width' in node.props).toBe(false);
159
+ expect('blur_fill' in node.props).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('Avatar (BDC)', () => {
164
+ it('emits avatar with path alias, name, initials and size token', () => {
165
+ const node = Avatar({
166
+ pathRef: '{{item.author.avatar_path}}',
167
+ name: '{{item.author.name}}',
168
+ size: 'sm',
169
+ corner: 'cornerFull',
170
+ });
171
+ expect(node.type).toBe('avatar');
172
+ expect(node.props?.path).toBe('{{item.author.avatar_path}}');
173
+ expect(node.props.name).toBe('{{item.author.name}}');
174
+ expect(node.props.size).toBe('sm');
175
+
176
+ const wire = compileBdcBlock(node);
177
+ expect(wire.props?.path).toBe('{{item.author.avatar_path}}');
178
+ expect(wire.props?.corner).toBe('cornerFull');
179
+ });
180
+
181
+ it('supports initials-only authoring', () => {
182
+ const wire = compileBdcBlock(Avatar({ initials: 'AB', size: 'xs' }));
183
+ expect(wire.props).toEqual({ initials: 'AB', size: 'xs' });
184
+ });
185
+ });
186
+
187
+ describe('IconButton (BDC)', () => {
188
+ it('emits icon_button with binding in selected kept verbatim (stringly-typed rule)', () => {
189
+ const wire = compileBdcBlock(
190
+ IconButton({
191
+ icon: 'heart',
192
+ selectedIcon: 'heart-filled',
193
+ actionId: 'like_post_{{item.id}}',
194
+ selectedRef: '{{item.liked_by_me}}',
195
+ selectedColor: 'primary',
196
+ testId: 'like-btn',
197
+ }),
198
+ );
199
+ expect(wire.type).toBe('icon_button');
200
+ expect(wire.props).toEqual({
201
+ icon: 'heart',
202
+ selected_icon: 'heart-filled',
203
+ action_id: 'like_post_{{item.id}}',
204
+ selected: '{{item.liked_by_me}}',
205
+ selected_color: 'primary',
206
+ test_id: 'like-btn',
207
+ });
208
+ });
209
+
210
+ it('passes literal booleans through for selected', () => {
211
+ expect(compileBdcBlock(IconButton({ icon: 'heart', selected: true })).props?.selected).toBe(true);
212
+ expect(compileBdcBlock(IconButton({ icon: 'heart', selected: false })).props?.selected).toBe(false);
213
+ });
214
+
215
+ it('accepts snake_case authoring too', () => {
216
+ const wire = compileBdcBlock(
217
+ IconButton({ icon: 'heart', action_id: 'like', selected: 'false', test_id: 'x' }),
218
+ );
219
+ expect(wire.props?.action_id).toBe('like');
220
+ expect(wire.props?.selected).toBe('false');
221
+ expect(wire.props?.test_id).toBe('x');
222
+ });
223
+
224
+ it('composes into a social feed row template (bindings verbatim in the wire)', () => {
225
+ const host = SocialPostFeed({
226
+ dataRef: 'social.posts',
227
+ children: [
228
+ ListRowTemplate({
229
+ itemKey: 'id',
230
+ children: [
231
+ Column({
232
+ children: [
233
+ Avatar({
234
+ pathRef: '{{item.author.avatar_path}}',
235
+ name: '{{item.author.name}}',
236
+ size: 'sm',
237
+ }),
238
+ ContentImage({ pathRef: '{{item.media_path}}', width: 96, aspect: 'square' }),
239
+ IconButton({
240
+ icon: 'heart',
241
+ selectedIcon: 'heart-filled',
242
+ actionId: 'like_post_{{item.id}}',
243
+ selectedRef: '{{item.liked_by_me}}',
244
+ }),
245
+ ],
246
+ }),
247
+ ],
248
+ }),
249
+ ],
250
+ });
251
+
252
+ const wire = compileBdcToWire(
253
+ Screen({ id: 'social.feed', data: { posts: [] }, children: [host] }),
254
+ );
255
+ const feed = wire.blocks[0];
256
+ expect(feed.children).toEqual([]);
257
+
258
+ const rows = feed.props?.row_template as unknown as BdcElement[];
259
+ expect(rows).toHaveLength(1);
260
+ const [avatar, image, like] = rows[0].children;
261
+ expect(avatar.type).toBe('avatar');
262
+ expect(avatar.props.path).toBe('{{item.author.avatar_path}}');
263
+ expect(image.props.path).toBe('{{item.media_path}}');
264
+ expect(like.props.selected).toBe('{{item.liked_by_me}}');
265
+ expect(like.props.action_id).toBe('like_post_{{item.id}}');
266
+ expect(like.props.selected_icon).toBe('heart-filled');
267
+ });
268
+ });