@proulr/sdui-components 0.1.87 → 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.
Files changed (32) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -2
  3. package/src/bdc/compile-bdc-row-template.test.ts +179 -0
  4. package/src/bdc/compile-bdc-to-wire.ts +52 -1
  5. package/src/bdc/components/discover.tsx +5 -1
  6. package/src/bdc/components/list.test.ts +36 -0
  7. package/src/bdc/components/list.tsx +62 -0
  8. package/src/bdc/components/media.test.ts +268 -0
  9. package/src/bdc/components/media.tsx +275 -0
  10. package/src/bdc/components/native-blocks.tsx +7 -1
  11. package/src/bdc/components/ui.tsx +25 -0
  12. package/src/bdc/index.ts +5 -0
  13. package/src/catalog/block-types.ts +5 -0
  14. package/src/renderer/SduiTemplateRow.tsx +63 -0
  15. package/src/renderer/blocks/SduiContentBlocks.tsx +198 -0
  16. package/src/renderer/blocks/SduiDiscoverBlocks.tsx +37 -2
  17. package/src/renderer/blocks/SduiMediaBlocks.tsx +348 -0
  18. package/src/renderer/blocks/SduiNativeDomainBlocks.tsx +2 -0
  19. package/src/renderer/index.ts +48 -0
  20. package/src/renderer/lib/sdui-media-block-props.ts +282 -0
  21. package/src/renderer/lib/sdui-media-blocks.test.ts +279 -0
  22. package/src/renderer/lib/sdui-media-uri.ts +39 -0
  23. package/src/renderer/lib/sdui-row-template.test.ts +190 -0
  24. package/src/renderer/lib/sdui-row-template.ts +153 -0
  25. package/src/renderer/sdui-component-registry.tsx +16 -0
  26. package/src/renderer/sdui-hybrid-renderer-context.tsx +2 -0
  27. package/src/renderer/sdui-media-resolver-context.tsx +84 -0
  28. package/src/renderer/sdui-video-player-context.tsx +55 -0
  29. package/src/renderer/ui/Chip.tsx +15 -2
  30. package/src/renderer/ui/Skeleton.tsx +13 -4
  31. package/src/renderer/ui/index.ts +1 -0
  32. package/src/renderer/ui/use-reduce-motion.ts +31 -0
@@ -0,0 +1,282 @@
1
+ import type { SduiMediaAspectToken, SduiAvatarSizeToken, SduiMediaFitToken, ShapeToken } from '@proulr/sdui-contract';
2
+ import { isSduiSelectedTrue } from '@proulr/sdui-contract';
3
+ import { isAppIconName, type AppIconName } from '../../catalog/app-icon-names';
4
+
5
+ /**
6
+ * Parsing puro (node-testável) das props de blocos de mídia/estado
7
+ * (`content_image`, `avatar`, `icon_button`). Os componentes do renderer
8
+ * (`blocks/SduiMediaBlocks.tsx`) delegam aqui — nenhum import de
9
+ * react-native neste módulo.
10
+ *
11
+ * Wire é snake_case; acceptamos também a forma camelCase (wires antigos /
12
+ * props promocionais do BDC) lendo ambos os chaves por prop.
13
+ */
14
+
15
+ /** Razões por token de aspecto (BFF escolhe token; px fica no app/lib). */
16
+ export const SDUI_MEDIA_ASPECT_RATIOS: Record<SduiMediaAspectToken, number> = {
17
+ square: 1,
18
+ portrait: 4 / 5,
19
+ story: 9 / 16,
20
+ video: 16 / 9,
21
+ wide: 1.91,
22
+ };
23
+
24
+ /** Px por token de avatar. */
25
+ export const SDUI_AVATAR_SIZES: Record<SduiAvatarSizeToken, number> = {
26
+ xs: 24,
27
+ sm: 32,
28
+ md: 40,
29
+ lg: 56,
30
+ xl: 88,
31
+ };
32
+
33
+ const AVATAR_SIZE_TOKENS = Object.keys(SDUI_AVATAR_SIZES) as SduiAvatarSizeToken[];
34
+ const ASPECT_TOKENS = Object.keys(SDUI_MEDIA_ASPECT_RATIOS) as SduiMediaAspectToken[];
35
+ const FIT_TOKENS: SduiMediaFitToken[] = ['cover', 'contain'];
36
+ const SHAPE_TOKENS: ShapeToken[] = [
37
+ 'cornerExtraSmall',
38
+ 'cornerSmall',
39
+ 'cornerMedium',
40
+ 'cornerLarge',
41
+ 'cornerExtraLarge',
42
+ 'cornerFull',
43
+ ];
44
+
45
+ function readString(props: Record<string, unknown>, ...keys: string[]): string | undefined {
46
+ for (const key of keys) {
47
+ const value = props[key];
48
+ if (typeof value === 'string') return value;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ function readNumber(props: Record<string, unknown>, key: string): number | undefined {
54
+ const value = props[key];
55
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
56
+ }
57
+
58
+ function readBoolean(props: Record<string, unknown>, key: string): boolean | undefined {
59
+ const value = props[key];
60
+ return typeof value === 'boolean' ? value : undefined;
61
+ }
62
+
63
+ function pickToken<T extends string>(value: string | undefined, tokens: T[]): T | undefined {
64
+ return value && (tokens as string[]).includes(value) ? (value as T) : undefined;
65
+ }
66
+
67
+ export type SduiContentImageParsed = {
68
+ /** Path cru (pode ser binding já resolvido pelo materialize); `''` quando ausente. */
69
+ path: string;
70
+ width?: number;
71
+ height?: number;
72
+ aspectRatio?: number;
73
+ corner?: ShapeToken;
74
+ fit: SduiMediaFitToken;
75
+ blurFill: boolean;
76
+ testId?: string;
77
+ };
78
+
79
+ /**
80
+ * Frame estável para `content_image` (anti-flicker: dimensões vindas do wire,
81
+ * nunca do carregamento da imagem). Regras:
82
+ * - width+height → explícitos;
83
+ * - width-only / height-only → outro lado derivado do aspect (default square);
84
+ * - aspect-only → `aspectRatio` + `stretch` (ocupa a largura do pai);
85
+ * - nada → square + `stretch`.
86
+ */
87
+ export type SduiMediaFrame = {
88
+ width?: number;
89
+ height?: number;
90
+ aspectRatio?: number;
91
+ stretch: boolean;
92
+ };
93
+
94
+ export function computeSduiMediaFrame(
95
+ width: number | undefined,
96
+ height: number | undefined,
97
+ aspectRatio: number | undefined,
98
+ ): SduiMediaFrame {
99
+ const ratio = aspectRatio ?? SDUI_MEDIA_ASPECT_RATIOS.square;
100
+ if (width && height) return { width, height, stretch: false };
101
+ if (width) return { width, height: width / ratio, stretch: false };
102
+ if (height) return { width: height * ratio, height, stretch: false };
103
+ return { aspectRatio: aspectRatio ?? 1, stretch: true };
104
+ }
105
+
106
+ export function parseSduiContentImageProps(
107
+ props: Record<string, unknown> | undefined,
108
+ ): SduiContentImageParsed {
109
+ const raw = props ?? {};
110
+ const fit = pickToken(readString(raw, 'fit'), FIT_TOKENS);
111
+ return {
112
+ path: (readString(raw, 'path', 'pathRef', 'path_ref') ?? '').trim(),
113
+ width: readNumber(raw, 'width'),
114
+ height: readNumber(raw, 'height'),
115
+ aspectRatio: (() => {
116
+ const token = pickToken(readString(raw, 'aspect'), ASPECT_TOKENS);
117
+ return token ? SDUI_MEDIA_ASPECT_RATIOS[token] : undefined;
118
+ })(),
119
+ corner: pickToken(readString(raw, 'corner'), SHAPE_TOKENS),
120
+ fit: fit ?? 'cover',
121
+ blurFill: readBoolean(raw, 'blur_fill') === true || readBoolean(raw, 'blurFill') === true,
122
+ testId: readString(raw, 'test_id', 'testId'),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Parsing puro das props do bloco `video` (frame genérico; playback é slot do
128
+ * app). Mesmas regras de frame de `content_image` (anti-flicker: dimensões
129
+ * vindas do wire). `poster_path` é opcional e binding-capable (já resolvido em
130
+ * materialize time).
131
+ */
132
+ export type SduiVideoParsed = {
133
+ path: string;
134
+ posterPath?: string;
135
+ width?: number;
136
+ height?: number;
137
+ aspectRatio?: number;
138
+ corner?: ShapeToken;
139
+ fit: SduiMediaFitToken;
140
+ testId?: string;
141
+ };
142
+
143
+ export function parseSduiVideoProps(props: Record<string, unknown> | undefined): SduiVideoParsed {
144
+ const raw = props ?? {};
145
+ const fit = pickToken(readString(raw, 'fit'), FIT_TOKENS);
146
+ return {
147
+ path: (readString(raw, 'path', 'pathRef', 'path_ref') ?? '').trim(),
148
+ posterPath: readString(raw, 'poster_path', 'posterPath')?.trim(),
149
+ width: readNumber(raw, 'width'),
150
+ height: readNumber(raw, 'height'),
151
+ aspectRatio: (() => {
152
+ const token = pickToken(readString(raw, 'aspect'), ASPECT_TOKENS);
153
+ return token ? SDUI_MEDIA_ASPECT_RATIOS[token] : undefined;
154
+ })(),
155
+ corner: pickToken(readString(raw, 'corner'), SHAPE_TOKENS),
156
+ fit: fit ?? 'cover',
157
+ testId: readString(raw, 'test_id', 'testId'),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Parsing puro do `row_overflow_menu`: normaliza as entradas aninhadas
163
+ * (`action_id`/`actionId` ambos aceitos), valida ícones contra o catalog
164
+ * (`isAppIconName`) e aplica o trigger default `overflow`. Toda copy vem do
165
+ * wire — a lib não inventa strings.
166
+ */
167
+ export type SduiRowOverflowMenuActionParsed = {
168
+ actionId: string;
169
+ label: string;
170
+ icon: AppIconName | null;
171
+ destructive: boolean;
172
+ };
173
+
174
+ export type SduiRowOverflowMenuParsed = {
175
+ triggerIcon: AppIconName;
176
+ actions: SduiRowOverflowMenuActionParsed[];
177
+ testId?: string;
178
+ };
179
+
180
+ /** Trigger default do overflow (token kebab já existente no catalog). */
181
+ export const SDUI_ROW_OVERFLOW_DEFAULT_ICON: AppIconName = 'overflow';
182
+
183
+ export function parseSduiRowOverflowMenuProps(
184
+ props: Record<string, unknown> | undefined,
185
+ ): SduiRowOverflowMenuParsed {
186
+ const raw = props ?? {};
187
+ const icon = readString(raw, 'icon');
188
+ const rawActions = Array.isArray(raw.actions) ? (raw.actions as unknown[]) : [];
189
+ const actions: SduiRowOverflowMenuActionParsed[] = [];
190
+ for (const value of rawActions) {
191
+ if (!value || typeof value !== 'object') continue;
192
+ const entry = value as Record<string, unknown>;
193
+ const actionId = readString(entry, 'action_id', 'actionId');
194
+ const label = readString(entry, 'label');
195
+ if (!actionId || !label) continue;
196
+ const entryIcon = readString(entry, 'icon');
197
+ actions.push({
198
+ actionId,
199
+ label,
200
+ icon: entryIcon && isAppIconName(entryIcon) ? entryIcon : null,
201
+ destructive: entry.destructive === true,
202
+ });
203
+ }
204
+ return {
205
+ triggerIcon: icon && isAppIconName(icon) ? icon : SDUI_ROW_OVERFLOW_DEFAULT_ICON,
206
+ actions,
207
+ testId: readString(raw, 'test_id', 'testId'),
208
+ };
209
+ }
210
+
211
+ export type SduiAvatarParsed = {
212
+ path: string;
213
+ name?: string;
214
+ initials?: string;
215
+ sizePx: number;
216
+ corner: ShapeToken;
217
+ testId?: string;
218
+ };
219
+
220
+ export function parseSduiAvatarProps(props: Record<string, unknown> | undefined): SduiAvatarParsed {
221
+ const raw = props ?? {};
222
+ const size = pickToken(readString(raw, 'size'), AVATAR_SIZE_TOKENS);
223
+ const corner = pickToken(readString(raw, 'corner'), SHAPE_TOKENS);
224
+ return {
225
+ path: (readString(raw, 'path', 'pathRef', 'path_ref') ?? '').trim(),
226
+ name: readString(raw, 'name'),
227
+ initials: readString(raw, 'initials'),
228
+ sizePx: size ? SDUI_AVATAR_SIZES[size] : SDUI_AVATAR_SIZES.md,
229
+ corner: corner ?? 'cornerFull',
230
+ testId: readString(raw, 'test_id', 'testId'),
231
+ };
232
+ }
233
+
234
+ export type SduiIconButtonParsed = {
235
+ icon: AppIconName | null;
236
+ selectedIcon: AppIconName | null;
237
+ actionId?: string;
238
+ /** Coerção única `selected`: `'true'`/`true` → selecionado (ver `isSduiSelectedTrue`). */
239
+ selected: boolean;
240
+ size?: number;
241
+ color?: string;
242
+ selectedColor?: string;
243
+ label?: string;
244
+ disabled: boolean;
245
+ testId?: string;
246
+ };
247
+
248
+ export function parseSduiIconButtonProps(
249
+ props: Record<string, unknown> | undefined,
250
+ ): SduiIconButtonParsed {
251
+ const raw = props ?? {};
252
+ const icon = readString(raw, 'icon');
253
+ const selectedIcon = readString(raw, 'selected_icon', 'selectedIcon');
254
+ return {
255
+ icon: icon && isAppIconName(icon) ? icon : null,
256
+ selectedIcon: selectedIcon && isAppIconName(selectedIcon) ? selectedIcon : null,
257
+ actionId: readString(raw, 'action_id', 'actionId'),
258
+ selected: isSduiSelectedTrue(raw.selected),
259
+ size: readNumber(raw, 'size'),
260
+ color: readString(raw, 'color'),
261
+ selectedColor: readString(raw, 'selected_color', 'selectedColor'),
262
+ label: readString(raw, 'label'),
263
+ disabled: readBoolean(raw, 'disabled') === true,
264
+ testId: readString(raw, 'test_id', 'testId'),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Iniciais a partir do nome de exibição: múltiplas palavras → primeira +
270
+ * última (estilo WhatsApp/Telegram); palavra única → até 2 letras. Sempre
271
+ * maiúsculas, máx. 2 caracteres.
272
+ */
273
+ export function deriveSduiInitials(name: string | undefined): string {
274
+ if (!name) return '';
275
+ const words = name
276
+ .trim()
277
+ .split(/\s+/)
278
+ .filter((word) => /[\p{L}\p{N}]/u.test(word));
279
+ if (words.length === 0) return '';
280
+ if (words.length === 1) return [...(words[0] as string)].slice(0, 2).join('').toLocaleUpperCase();
281
+ return ((words[0] as string)[0] + (words[words.length - 1] as string)[0]).toLocaleUpperCase();
282
+ }
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+ import {
6
+ SDUI_AVATAR_SIZES,
7
+ SDUI_MEDIA_ASPECT_RATIOS,
8
+ SDUI_ROW_OVERFLOW_DEFAULT_ICON,
9
+ computeSduiMediaFrame,
10
+ deriveSduiInitials,
11
+ parseSduiAvatarProps,
12
+ parseSduiContentImageProps,
13
+ parseSduiIconButtonProps,
14
+ parseSduiRowOverflowMenuProps,
15
+ parseSduiVideoProps,
16
+ } from './sdui-media-block-props';
17
+ import { invokeSduiMediaResolver } from './sdui-media-uri';
18
+ import { SDUI_BLOCK_TYPES } from '../../catalog/block-types';
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
21
+
22
+ describe('invokeSduiMediaResolver (generic media capability contract)', () => {
23
+ it('renders nothing without a resolver or with an empty path', () => {
24
+ expect(invokeSduiMediaResolver(null, 'feeds/a.jpg')).toBeNull();
25
+ expect(invokeSduiMediaResolver((p) => `url:${p}`, '')).toBeNull();
26
+ expect(invokeSduiMediaResolver((p) => `url:${p}`, ' ')).toBeNull();
27
+ });
28
+
29
+ it('passes sync resolver results through; empty/non-string becomes null', () => {
30
+ expect(invokeSduiMediaResolver((p) => `url:${p}`, 'a/b.jpg')).toBe('url:a/b.jpg');
31
+ expect(invokeSduiMediaResolver(() => '', 'a')).toBeNull();
32
+ expect(invokeSduiMediaResolver(() => undefined as unknown as string, 'a')).toBeNull();
33
+ });
34
+
35
+ it('normalizes async resolver results (empty or rejection → null)', async () => {
36
+ const ok = invokeSduiMediaResolver(async (p) => `dl:${p}`, 'a.jpg');
37
+ expect(ok).toBeInstanceOf(Promise);
38
+ await expect(ok as Promise<string | null>).resolves.toBe('dl:a.jpg');
39
+ await expect(invokeSduiMediaResolver(async () => '', 'a') as Promise<string | null>).resolves.toBeNull();
40
+ await expect(
41
+ invokeSduiMediaResolver(async () => {
42
+ throw new Error('403');
43
+ }, 'a') as Promise<string | null>,
44
+ ).resolves.toBeNull();
45
+ });
46
+
47
+ it('swallows sync resolver exceptions (never a product fallback URL)', () => {
48
+ expect(
49
+ invokeSduiMediaResolver(() => {
50
+ throw new Error('boom');
51
+ }, 'a'),
52
+ ).toBeNull();
53
+ });
54
+
55
+ it('receives the trimmed path (binding already materialized by the app)', () => {
56
+ let received = '';
57
+ invokeSduiMediaResolver((p) => {
58
+ received = p;
59
+ return 'x';
60
+ }, ' posts/{{item.missing}} '.replace('{{item.missing}}', ''));
61
+ expect(received).toBe('posts/');
62
+ });
63
+ });
64
+
65
+ describe('parseSduiContentImageProps', () => {
66
+ it('keeps binding-resolved path, computes ratio from aspect token, defaults fit', () => {
67
+ const parsed = parseSduiContentImageProps({
68
+ path: 'feeds/2026/a.jpg',
69
+ width: 100,
70
+ aspect: 'square',
71
+ corner: 'cornerMedium',
72
+ blur_fill: true,
73
+ test_id: 'img',
74
+ });
75
+ expect(parsed.path).toBe('feeds/2026/a.jpg');
76
+ expect(parsed.aspectRatio).toBe(SDUI_MEDIA_ASPECT_RATIOS.square);
77
+ expect(parsed.corner).toBe('cornerMedium');
78
+ expect(parsed.fit).toBe('cover');
79
+ expect(parsed.blurFill).toBe(true);
80
+ expect(parsed.testId).toBe('img');
81
+ });
82
+
83
+ it('accepts camel aliases and rejects unknown tokens (fail-soft, never throw)', () => {
84
+ const parsed = parseSduiContentImageProps({
85
+ pathRef: '{{item.media_path}}',
86
+ blurFill: true,
87
+ aspect: 'nope',
88
+ fit: 'stretch',
89
+ corner: 12,
90
+ });
91
+ expect(parsed.path).toBe('{{item.media_path}}');
92
+ expect(parsed.blurFill).toBe(true);
93
+ expect(parsed.aspectRatio).toBeUndefined();
94
+ expect(parsed.fit).toBe('cover');
95
+ expect(parsed.corner).toBeUndefined();
96
+ });
97
+
98
+ it('empty/missing path parses to empty string (block renders null without resolver)', () => {
99
+ expect(parseSduiContentImageProps({}).path).toBe('');
100
+ expect(parseSduiContentImageProps({ path: ' ' }).path).toBe('');
101
+ expect(parseSduiContentImageProps(undefined).path).toBe('');
102
+ });
103
+ });
104
+
105
+ describe('computeSduiMediaFrame (stable wire-driven frame)', () => {
106
+ it('keeps explicit width+height; derives the missing side from aspect (square default)', () => {
107
+ expect(computeSduiMediaFrame(100, 80, undefined)).toEqual({ width: 100, height: 80, stretch: false });
108
+ expect(computeSduiMediaFrame(100, undefined, SDUI_MEDIA_ASPECT_RATIOS.video)).toEqual({
109
+ width: 100,
110
+ height: 100 / SDUI_MEDIA_ASPECT_RATIOS.video,
111
+ stretch: false,
112
+ });
113
+ expect(computeSduiMediaFrame(undefined, 90, undefined)).toEqual({
114
+ width: 90,
115
+ height: 90,
116
+ stretch: false,
117
+ });
118
+ });
119
+
120
+ it('falls back to aspectRatio + stretch when no explicit dimension', () => {
121
+ expect(computeSduiMediaFrame(undefined, undefined, SDUI_MEDIA_ASPECT_RATIOS.portrait)).toEqual({
122
+ aspectRatio: 4 / 5,
123
+ stretch: true,
124
+ });
125
+ expect(computeSduiMediaFrame(undefined, undefined, undefined)).toEqual({
126
+ aspectRatio: 1,
127
+ stretch: true,
128
+ });
129
+ });
130
+ });
131
+
132
+ describe('parseSduiAvatarProps', () => {
133
+ it('maps size tokens to px and defaults circle + md', () => {
134
+ expect(parseSduiAvatarProps({ size: 'sm' }).sizePx).toBe(SDUI_AVATAR_SIZES.sm);
135
+ const defaulted = parseSduiAvatarProps({ name: 'Ana' });
136
+ expect(defaulted.sizePx).toBe(SDUI_AVATAR_SIZES.md);
137
+ expect(defaulted.corner).toBe('cornerFull');
138
+ expect(defaulted.path).toBe('');
139
+ });
140
+ });
141
+
142
+ describe('parseSduiIconButtonProps (selected-binding coercion)', () => {
143
+ it("treats 'true'/true as selected and 'false'/false/garbage as default", () => {
144
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected: 'true' }).selected).toBe(true);
145
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected: ' True ' }).selected).toBe(true);
146
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected: true }).selected).toBe(true);
147
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected: 'false' }).selected).toBe(false);
148
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected: false }).selected).toBe(false);
149
+ expect(parseSduiIconButtonProps({ icon: 'heart' }).selected).toBe(false);
150
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected: '1' }).selected).toBe(false);
151
+ });
152
+
153
+ it('validates icon tokens against AppIconName (unknown → null icon)', () => {
154
+ expect(parseSduiIconButtonProps({ icon: 'heart' }).icon).toBe('heart');
155
+ expect(parseSduiIconButtonProps({ icon: '💜' }).icon).toBeNull();
156
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected_icon: 'heart-filled' }).selectedIcon).toBe(
157
+ 'heart-filled',
158
+ );
159
+ expect(parseSduiIconButtonProps({ icon: 'heart', selected_icon: 'nada' }).selectedIcon).toBeNull();
160
+ });
161
+
162
+ it('reads snake and camel action ids', () => {
163
+ expect(parseSduiIconButtonProps({ icon: 'heart', action_id: 'like_1' }).actionId).toBe('like_1');
164
+ expect(parseSduiIconButtonProps({ icon: 'heart', actionId: 'like_2' }).actionId).toBe('like_2');
165
+ });
166
+ });
167
+
168
+ describe('deriveSduiInitials', () => {
169
+ it('derives first+last initials (up to two letters)', () => {
170
+ expect(deriveSduiInitials('Ana Beatriz')).toBe('AB');
171
+ expect(deriveSduiInitials('ana carla souza')).toBe('AS');
172
+ expect(deriveSduiInitials('xavier')).toBe('XA');
173
+ expect(deriveSduiInitials(' ')).toBe('');
174
+ expect(deriveSduiInitials(undefined)).toBe('');
175
+ expect(deriveSduiInitials('1234')).toBe('12');
176
+ });
177
+ });
178
+
179
+ describe('parseSduiVideoProps', () => {
180
+ it('parses path + poster bindings already resolved, aspect token and fit default', () => {
181
+ const parsed = parseSduiVideoProps({
182
+ path: 'clips/2026/a.mp4',
183
+ poster_path: 'clips/2026/a.jpg',
184
+ aspect: 'video',
185
+ test_id: 'clip-video',
186
+ });
187
+ expect(parsed).toEqual({
188
+ path: 'clips/2026/a.mp4',
189
+ posterPath: 'clips/2026/a.jpg',
190
+ aspectRatio: SDUI_MEDIA_ASPECT_RATIOS.video,
191
+ fit: 'cover',
192
+ testId: 'clip-video',
193
+ });
194
+ });
195
+
196
+ it('accepts camel aliases; empty poster becomes undefined-safe trim', () => {
197
+ const parsed = parseSduiVideoProps({ pathRef: 'v.mp4', posterPath: ' p.jpg ', width: 320 });
198
+ expect(parsed.path).toBe('v.mp4');
199
+ expect(parsed.posterPath).toBe('p.jpg');
200
+ expect(parsed.width).toBe(320);
201
+ });
202
+
203
+ it('empty/missing path parses to empty string (block renders null without resolver)', () => {
204
+ expect(parseSduiVideoProps({}).path).toBe('');
205
+ expect(parseSduiVideoProps(undefined).posterPath).toBeUndefined();
206
+ expect(parseSduiVideoProps({ path: '', poster_path: '' }).posterPath).toBe('');
207
+ });
208
+ });
209
+
210
+ describe('parseSduiRowOverflowMenuProps', () => {
211
+ it('normalizes nested actions (snake + camel action ids) and keeps caller copy verbatim', () => {
212
+ const parsed = parseSduiRowOverflowMenuProps({
213
+ icon: 'overflow',
214
+ actions: [
215
+ { action_id: 'delete_post_{{item.id}}', label: 'Excluir', icon: 'trash', destructive: true },
216
+ { actionId: 'bookmark_{{item.id}}', label: 'Salvar' },
217
+ ],
218
+ test_id: 'row-menu',
219
+ });
220
+ expect(parsed.triggerIcon).toBe('overflow');
221
+ expect(parsed.testId).toBe('row-menu');
222
+ expect(parsed.actions).toEqual([
223
+ {
224
+ actionId: 'delete_post_{{item.id}}',
225
+ label: 'Excluir',
226
+ icon: 'trash',
227
+ destructive: true,
228
+ },
229
+ { actionId: 'bookmark_{{item.id}}', label: 'Salvar', icon: null, destructive: false },
230
+ ]);
231
+ });
232
+
233
+ it('defaults trigger to `overflow` and drops malformed entries (no invented copy)', () => {
234
+ const parsed = parseSduiRowOverflowMenuProps({
235
+ icon: 'not-an-icon',
236
+ actions: [
237
+ { action_id: 'ok', label: 'OK' },
238
+ { action_id: '', label: 'Sem id' },
239
+ { label: 'Sem action' },
240
+ 'noise',
241
+ null,
242
+ ],
243
+ });
244
+ expect(parsed.triggerIcon).toBe(SDUI_ROW_OVERFLOW_DEFAULT_ICON);
245
+ expect(parsed.actions).toHaveLength(1);
246
+ expect(parsed.actions[0]?.actionId).toBe('ok');
247
+ });
248
+
249
+ it('tolerates missing/non-array actions', () => {
250
+ expect(parseSduiRowOverflowMenuProps({}).actions).toEqual([]);
251
+ expect(parseSduiRowOverflowMenuProps({ actions: 'nope' }).actions).toEqual([]);
252
+ expect(parseSduiRowOverflowMenuProps(undefined).triggerIcon).toBe(SDUI_ROW_OVERFLOW_DEFAULT_ICON);
253
+ });
254
+ });
255
+
256
+ describe('registry + catalog wiring (static, node-safe)', () => {
257
+ it('catalog lists the three new block types', () => {
258
+ expect(SDUI_BLOCK_TYPES).toContain('content_image');
259
+ expect(SDUI_BLOCK_TYPES).toContain('avatar');
260
+ expect(SDUI_BLOCK_TYPES).toContain('icon_button');
261
+ });
262
+
263
+ it('catalog lists video and row_overflow_menu block types', () => {
264
+ expect(SDUI_BLOCK_TYPES).toContain('video');
265
+ expect(SDUI_BLOCK_TYPES).toContain('row_overflow_menu');
266
+ });
267
+
268
+ it('sdui-component-registry maps each block type to its media block component', () => {
269
+ const source = fs.readFileSync(
270
+ path.join(ROOT, 'src', 'renderer', 'sdui-component-registry.tsx'),
271
+ 'utf8',
272
+ );
273
+ expect(source).toContain('content_image: SduiContentImageBlock');
274
+ expect(source).toContain('avatar: SduiAvatarBlock');
275
+ expect(source).toContain('icon_button: SduiIconButtonBlock');
276
+ expect(source).toContain('video: SduiVideoBlock');
277
+ expect(source).toContain('row_overflow_menu: SduiRowOverflowMenuBlock');
278
+ });
279
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Capability genérica de mídia do renderer: o BFF manda **path** (storage
3
+ * path, possivelmente vindo de binding `{{item.media_path}}`); o app fornece a
4
+ * resolução path → URL (download autenticado) via `SduiMediaResolver`.
5
+ * A lib permanece agnóstica de produto: sem resolver, path vazio, resolver
6
+ * assíncrono pendente, retorno `''` ou exceção → nada de mídia (nunca um
7
+ * path/URL de produto hardcoded).
8
+ */
9
+
10
+ /** Contrato do resolver supplied pelo app (sync ou async). */
11
+ export type SduiMediaResolver = (path: string) => Promise<string> | string;
12
+
13
+ /**
14
+ * Invoca o resolver de forma segura, normalizando o resultado:
15
+ * - sem resolver ou path vazio → `null` (renderizar nada);
16
+ * - resolver síncrono → `string` (URL) ou `null` quando retornar `''`/não-string;
17
+ * - resolver assíncrono → `Promise<string | null>` (rejeição/`''` → `null`).
18
+ * Exceções do resolver são engolidas → `null` (a tela continua sem o media).
19
+ */
20
+ export function invokeSduiMediaResolver(
21
+ resolver: SduiMediaResolver | null | undefined,
22
+ path: string,
23
+ ): string | null | Promise<string | null> {
24
+ const trimmed = typeof path === 'string' ? path.trim() : '';
25
+ if (!resolver || !trimmed) return null;
26
+ try {
27
+ const result = resolver(trimmed);
28
+ if (typeof result === 'string') return result.trim() ? result : null;
29
+ if (result && typeof (result as Promise<string>).then === 'function') {
30
+ return (result as Promise<string>).then(
31
+ (uri) => (typeof uri === 'string' && uri.trim() ? uri : null),
32
+ () => null,
33
+ );
34
+ }
35
+ return null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }