@proulr/sdui-components 0.1.28 → 0.1.32

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.28",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -23,16 +23,21 @@
23
23
  "scripts": {
24
24
  "prepare": "husky",
25
25
  "typecheck": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "test:integration": "vitest run --config vitest.integration.config.ts",
29
+ "test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
26
30
  "prepublishOnly": "npm run typecheck",
27
31
  "lint": "eslint .",
28
32
  "lint:fix": "eslint . --fix",
29
- "version:sync": "node ../scripts/proulr-bump-and-sync-deps.mjs"
33
+ "version:sync": "node ../scripts/proulr-bump-and-sync-deps.mjs",
34
+ "test:coverage": "vitest run --coverage"
30
35
  },
31
36
  "publishConfig": {
32
37
  "access": "public"
33
38
  },
34
39
  "dependencies": {
35
- "@proulr/sdui-contract": "^0.1.4"
40
+ "@proulr/sdui-contract": "^0.1.5"
36
41
  },
37
42
  "peerDependencies": {
38
43
  "expo-blur": ">=15",
@@ -65,6 +70,7 @@
65
70
  "devDependencies": {
66
71
  "@types/node": "^22.20.0",
67
72
  "@types/react": "~19.2.2",
73
+ "@vitest/coverage-v8": "^3.2.7",
68
74
  "eslint": "^9.0.0",
69
75
  "eslint-config-expo": "~56.0.4",
70
76
  "expo-blur": "~56.0.3",
@@ -76,7 +82,8 @@
76
82
  "react-native": "0.85.3",
77
83
  "react-native-google-mobile-ads": "^16.3.4",
78
84
  "react-native-safe-area-context": "~5.7.0",
79
- "typescript": "~6.0.3"
85
+ "typescript": "~6.0.3",
86
+ "vitest": "^3.2.7"
80
87
  },
81
88
  "engines": {
82
89
  "node": ">=22"
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { decodeSduiScreenWire } from '@proulr/sdui-contract';
3
+ import { compileBdcBlock, compileBdcToWire } from './compile-bdc-to-wire';
4
+ import type { BdcElement } from './jsx-runtime';
5
+
6
+ function screen(props: Record<string, unknown>, children: BdcElement[]): BdcElement {
7
+ return { type: 'screen', props, children };
8
+ }
9
+
10
+ describe('BDC compile → contract decode integration', () => {
11
+ it('round-trips a full BDC tree including header slots and derived labels', () => {
12
+ const slot: BdcElement = {
13
+ type: 'chip_row',
14
+ props: { id: 'slot' },
15
+ children: [],
16
+ };
17
+
18
+ const wire = compileBdcToWire(
19
+ screen(
20
+ {
21
+ id: 'demo.home',
22
+ title: 'Demo',
23
+ data: { city: 'sp' },
24
+ header: {
25
+ title: 'Home',
26
+ headerSearch: true,
27
+ brandMode: true,
28
+ slotBlocks: [{ type: 'ignored' }],
29
+ },
30
+ headerSlot: [slot, { type: 'chip', props: {}, children: [] }],
31
+ actions: {
32
+ go: { type: 'navigate', path: '/x' },
33
+ },
34
+ meta: { bff: 'demo-bff', cache_ttl_ms: 12_000, stale_policy: 'cache_only' },
35
+ },
36
+ [
37
+ {
38
+ type: 'column',
39
+ props: { gap: 8, id: 'root' },
40
+ children: [
41
+ {
42
+ type: 'button',
43
+ props: { actionId: 'go', style: { paddingTop: 8, marginBottom: 4 } },
44
+ children: [
45
+ { type: '_text_node', props: { value: ' Press ' }, children: [] },
46
+ { type: 'text', props: { text: 'me' }, children: [] },
47
+ ],
48
+ },
49
+ {
50
+ type: 'text',
51
+ props: {},
52
+ children: [{ type: '_text_node', props: { value: 'Body' }, children: [] }],
53
+ },
54
+ {
55
+ type: '_fragment',
56
+ props: {},
57
+ children: [],
58
+ },
59
+ {
60
+ type: 'button',
61
+ props: { actionId: 'send_message', label: 'Send' },
62
+ children: [],
63
+ },
64
+ ],
65
+ },
66
+ ],
67
+ ),
68
+ );
69
+
70
+ expect(wire.schema_version).toBe(1);
71
+ expect(wire.screen_id).toBe('demo.home');
72
+ expect(wire.header).toMatchObject({
73
+ title: 'Home',
74
+ header_search: true,
75
+ brand_mode: true,
76
+ });
77
+ expect(wire.header?.slot_blocks).toHaveLength(2);
78
+ expect(wire.blocks[0]?.props).toMatchObject({ gap: 8 });
79
+ expect(wire.blocks[0]?.children?.[0]?.props).toMatchObject({
80
+ action_id: 'go',
81
+ label: 'Press me',
82
+ style: { padding_top: 8, margin_bottom: 4 },
83
+ });
84
+ expect(wire.blocks[0]?.children?.[1]?.props?.text).toBe('Body');
85
+ expect(wire.blocks[0]?.children?.[2]?.props).toMatchObject({
86
+ action_id: 'send_message',
87
+ label: 'Send',
88
+ });
89
+
90
+ const decoded = decodeSduiScreenWire(wire);
91
+ expect(decoded.screenId).toBe('demo.home');
92
+ expect(decoded.blocks).toHaveLength(1);
93
+ expect(decoded.blocks[0]?.type).toBe('column');
94
+ expect(decoded.data).toEqual({ city: 'sp' });
95
+ expect(decoded.meta.bff).toBe('demo-bff');
96
+ });
97
+
98
+ it('rejects non-screen roots and compiles lone text nodes', () => {
99
+ expect(() =>
100
+ compileBdcToWire({ type: 'stack', props: {}, children: [] }),
101
+ ).toThrow(/bdc_root_must_be_screen/);
102
+
103
+ const block = compileBdcBlock({
104
+ type: '_text_node',
105
+ props: { value: 'x' },
106
+ children: [],
107
+ });
108
+ expect(block).toEqual({ id: '0', type: 'text', props: { text: 'x' } });
109
+
110
+ const empty = compileBdcToWire(screen({ id: 'empty' }, []));
111
+ expect(empty.data).toEqual({});
112
+ expect(empty.actions).toEqual({});
113
+ expect(empty.meta).toMatchObject({ cache_ttl_ms: 30_000 });
114
+ });
115
+ });
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileBdcBlock, compileBdcToWire } from './compile-bdc-to-wire';
3
+ import type { BdcElement } from './jsx-runtime';
4
+ import { compileBdcToWire as viaIndex } from './index';
5
+
6
+ function screen(props: Record<string, unknown>, children: BdcElement[]): BdcElement {
7
+ return { type: 'screen', props, children };
8
+ }
9
+
10
+ describe('compileBdcToWire', () => {
11
+ it('compiles a minimal screen tree', () => {
12
+ const wire = compileBdcToWire(
13
+ screen({ id: 'demo.home', data: { n: 1 } }, [
14
+ {
15
+ type: 'text',
16
+ props: { text: 'Hello' },
17
+ children: [],
18
+ },
19
+ ]),
20
+ );
21
+
22
+ expect(wire.schema_version).toBe(1);
23
+ expect(wire.screen_id).toBe('demo.home');
24
+ expect(wire.data).toEqual({ n: 1 });
25
+ expect(wire.blocks[0]).toMatchObject({
26
+ type: 'text',
27
+ props: { text: 'Hello' },
28
+ });
29
+ });
30
+
31
+ it('maps camelCase props to snake_case', () => {
32
+ const wire = compileBdcToWire(
33
+ screen({ id: 'demo.home' }, [
34
+ {
35
+ type: 'button',
36
+ props: { actionId: 'go', label: 'Go' },
37
+ children: [],
38
+ },
39
+ ]),
40
+ );
41
+ expect(wire.blocks[0].props).toMatchObject({ action_id: 'go', label: 'Go' });
42
+ });
43
+
44
+ it('rejects non-screen roots', () => {
45
+ expect(() =>
46
+ compileBdcToWire({ type: 'stack', props: {}, children: [] }),
47
+ ).toThrow(/bdc_root_must_be_screen/);
48
+ });
49
+
50
+ it('derives button/text labels from children and maps style keys', () => {
51
+ const wire = compileBdcToWire(
52
+ screen({ id: 'demo.home' }, [
53
+ {
54
+ type: 'button',
55
+ props: { actionId: 'go', style: { paddingTop: 8, marginBottom: 4 } },
56
+ children: [
57
+ { type: '_text_node', props: { value: ' Press ' }, children: [] },
58
+ { type: 'text', props: { text: 'me' }, children: [] },
59
+ ],
60
+ },
61
+ {
62
+ type: 'text',
63
+ props: {},
64
+ children: [{ type: '_text_node', props: { value: 'Body' }, children: [] }],
65
+ },
66
+ {
67
+ type: '_fragment',
68
+ props: {},
69
+ children: [],
70
+ },
71
+ {
72
+ type: 'column',
73
+ props: { id: 'col-1', children: 'ignored' },
74
+ children: [
75
+ { type: '_text_node', props: { value: 'skip' }, children: [] },
76
+ { type: 'spacer', props: {}, children: [] },
77
+ ],
78
+ },
79
+ ]),
80
+ );
81
+ expect(wire.blocks).toHaveLength(3);
82
+ expect(wire.blocks[0]?.props).toMatchObject({
83
+ action_id: 'go',
84
+ label: 'Press me',
85
+ style: { padding_top: 8, margin_bottom: 4 },
86
+ });
87
+ expect(wire.blocks[1]?.props?.text).toBe('Body');
88
+ expect(wire.blocks[2]?.id).toBe('col-1');
89
+ expect(wire.blocks[2]?.children).toHaveLength(1);
90
+ expect(wire.blocks[2]?.children?.[0]?.type).toBe('spacer');
91
+ });
92
+
93
+ it('compiles header slot blocks and defaults meta/actions/data', () => {
94
+ const slot: BdcElement = {
95
+ type: 'chip_row',
96
+ props: { id: 'slot' },
97
+ children: [],
98
+ };
99
+ const wire = compileBdcToWire(
100
+ screen(
101
+ {
102
+ id: 'demo.home',
103
+ header: {
104
+ title: 'Home',
105
+ headerSearch: true,
106
+ slotBlocks: [{ type: 'ignored' }],
107
+ headerSlot: 'ignored',
108
+ },
109
+ headerSlot: slot,
110
+ },
111
+ [],
112
+ ),
113
+ );
114
+ expect(wire.header).toMatchObject({
115
+ title: 'Home',
116
+ header_search: true,
117
+ });
118
+ expect(wire.header?.slot_blocks?.[0]).toMatchObject({ id: 'slot', type: 'chip_row' });
119
+ expect(wire.data).toEqual({});
120
+ expect(wire.actions).toEqual({});
121
+ expect(wire.meta).toMatchObject({ cache_ttl_ms: 30_000, offline_policy: 'cache_only' });
122
+ });
123
+
124
+ it('accepts headerSlot arrays and maps header camelCase', () => {
125
+ const wire = compileBdcToWire(
126
+ screen(
127
+ {
128
+ id: 'demo.home',
129
+ header: { brandMode: true },
130
+ headerSlot: [
131
+ { type: 'chip', props: {}, children: [] },
132
+ { type: 'chip', props: {}, children: [] },
133
+ ],
134
+ },
135
+ [],
136
+ ),
137
+ );
138
+ expect(wire.header).toMatchObject({ brand_mode: true });
139
+ expect(wire.header?.slot_blocks).toHaveLength(2);
140
+ });
141
+
142
+ it('compileBdcBlock and index re-export work', () => {
143
+ const block = compileBdcBlock({
144
+ type: '_text_node',
145
+ props: { value: 'x' },
146
+ children: [],
147
+ });
148
+ expect(block).toEqual({ id: '0', type: 'text', props: { text: 'x' } });
149
+ expect(viaIndex(screen({ id: 'i' }, [])).screen_id).toBe('i');
150
+ });
151
+ });
152
+
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ Chip,
4
+ ChipRow,
5
+ DiscoverGrid,
6
+ DiscoverMap,
7
+ DiscoverProfileCard,
8
+ } from './discover';
9
+ import { TextField } from './forms';
10
+ import {
11
+ ChatInboxCapabilities,
12
+ EmptyState,
13
+ FlashInboxCapabilities,
14
+ FloatingCreateMenu,
15
+ InboxPersonRow,
16
+ InboxSearchField,
17
+ ProfileVisitsCapabilities,
18
+ SegmentedTabs,
19
+ } from './inbox';
20
+ import {
21
+ AlbumDetail,
22
+ AlbumGrid,
23
+ ChatGroupAddMembers,
24
+ ChatGroupCreate,
25
+ ChatGroupInfo,
26
+ ChatGroupInvite,
27
+ ChatThread,
28
+ DiscoverBrowseLocation,
29
+ NewPostComposer,
30
+ NewStoryComposer,
31
+ PostComments,
32
+ ProfileOnboarding,
33
+ ProfileReport,
34
+ ProfileSelf,
35
+ ProfileSetup,
36
+ ProfileView,
37
+ SettingsHome,
38
+ SettingsPage,
39
+ SocialPostFeed,
40
+ SocialProfile,
41
+ ThoughtFeed,
42
+ } from './native-blocks';
43
+ import { ProfileCompletionBanner, StoryRail } from './social';
44
+ import {
45
+ Button,
46
+ Card,
47
+ Column,
48
+ InfoBanner,
49
+ ListItem,
50
+ NativeAd,
51
+ Row,
52
+ Screen,
53
+ Scroll,
54
+ SectionHeader,
55
+ Spacer,
56
+ Text,
57
+ ToggleRow,
58
+ screenProps,
59
+ } from './ui';
60
+
61
+ describe('bdc ui components', () => {
62
+ it('builds layout and leaf blocks', () => {
63
+ expect(Screen({ id: 'demo', children: [] })).toMatchObject({ type: 'screen' });
64
+ expect(Column({ gap: 8, children: [] }).type).toBe('column');
65
+ expect(Row({}).type).toBe('row');
66
+ expect(Scroll({}).type).toBe('scroll');
67
+ expect(Spacer({ height: 12 }).type).toBe('spacer');
68
+ expect(Text({ text: 'hi' }).type).toBe('text');
69
+ expect(Button({ actionId: 'go', label: 'Go' }).type).toBe('button');
70
+ expect(InfoBanner({ message: 'msg' }).type).toBe('info_banner');
71
+ expect(NativeAd({ placement: 'home' }).type).toBe('native_ad');
72
+ expect(Card({}).type).toBe('card');
73
+ expect(SectionHeader({ title: 'T' }).type).toBe('section_header');
74
+ expect(ListItem({ title: 'Item' }).type).toBe('list_item');
75
+ expect(ToggleRow({ label: 'On' }).type).toBe('toggle_row');
76
+ expect(screenProps({ id: 'x' })).toEqual({ id: 'x' });
77
+ });
78
+ });
79
+
80
+ describe('bdc discover/forms/social/inbox/native', () => {
81
+ it('builds discover and form blocks', () => {
82
+ expect(Chip({ label: 'All' }).type).toBe('chip');
83
+ expect(ChipRow({}).type).toBe('chip_row');
84
+ expect(DiscoverGrid({ itemsRef: 'items' }).type).toBe('discover_grid');
85
+ expect(DiscoverProfileCard({ profileRef: 'p' }).type).toBe('discover_profile_card');
86
+ expect(DiscoverMap({}).type).toBe('discover_map');
87
+ expect(TextField({ placeholder: 'x' }).type).toBe('text_field');
88
+ expect(StoryRail({ surface: 'discover' }).type).toBe('story_rail');
89
+ expect(ProfileCompletionBanner({}).type).toBe('profile_completion_banner');
90
+ });
91
+
92
+ it('builds inbox blocks and normalizes floating menu actions', () => {
93
+ expect(SegmentedTabs({}).type).toBe('segmented_tabs');
94
+ expect(EmptyState({ title: 'Empty' }).type).toBe('empty_state');
95
+ expect(InboxPersonRow({ variant: 'chat', rowRef: 'r' }).type).toBe('inbox_person_row');
96
+ expect(InboxSearchField({ placeholder: 'q' }).type).toBe('inbox_search_field');
97
+ expect(ChatInboxCapabilities({}).type).toBe('chat_inbox_capabilities');
98
+ expect(FlashInboxCapabilities({}).type).toBe('flash_inbox_capabilities');
99
+ expect(ProfileVisitsCapabilities({}).type).toBe('profile_visits_capabilities');
100
+ const menu = FloatingCreateMenu({
101
+ id: 'fab',
102
+ closedLabel: '+',
103
+ actions: [{ id: 'a1', label: 'Post', actionId: 'create_post', tone: 'primary', icon: 'plus' }],
104
+ });
105
+ expect(menu.type).toBe('floating_create_menu');
106
+ expect(menu.props.actions).toEqual([
107
+ { id: 'a1', label: 'Post', tone: 'primary', icon: 'plus', action_id: 'create_post' },
108
+ ]);
109
+ expect(FloatingCreateMenu({ actions: [] }).props).not.toHaveProperty('id');
110
+ });
111
+
112
+ it('builds all native hybrid blocks', () => {
113
+ const natives = [
114
+ ChatThread,
115
+ SocialPostFeed,
116
+ ThoughtFeed,
117
+ ProfileView,
118
+ ProfileSelf,
119
+ ProfileSetup,
120
+ ProfileOnboarding,
121
+ ProfileReport,
122
+ AlbumGrid,
123
+ AlbumDetail,
124
+ NewPostComposer,
125
+ NewStoryComposer,
126
+ PostComments,
127
+ SocialProfile,
128
+ SettingsHome,
129
+ SettingsPage,
130
+ ChatGroupCreate,
131
+ ChatGroupInfo,
132
+ ChatGroupAddMembers,
133
+ ChatGroupInvite,
134
+ DiscoverBrowseLocation,
135
+ ];
136
+ for (const Comp of natives) {
137
+ const el = Comp({ id: 'n1' });
138
+ expect(el.children).toEqual([]);
139
+ expect(typeof el.type).toBe('string');
140
+ }
141
+ });
142
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Fragment, jsx, jsxs, styleWire, type BdcElement } from './jsx-runtime';
3
+
4
+ describe('jsx-runtime', () => {
5
+ it('creates an element from a string type', () => {
6
+ const el = jsx('text', { text: 'hi' });
7
+ expect(el).toEqual({ type: 'text', props: { text: 'hi' }, children: [] });
8
+ });
9
+
10
+ it('flattens null, boolean, nested arrays, and text nodes', () => {
11
+ const el = jsxs('column', null, null, false, true, 'hello', 42, [
12
+ jsx('text', { text: 'nested' }),
13
+ [null, jsx('spacer', {})],
14
+ ]);
15
+ expect(el.children).toEqual([
16
+ { type: '_text_node', props: { value: 'hello' }, children: [] },
17
+ { type: '_text_node', props: { value: '42' }, children: [] },
18
+ { type: 'text', props: { text: 'nested' }, children: [] },
19
+ { type: 'spacer', props: {}, children: [] },
20
+ ]);
21
+ });
22
+
23
+ it('merges children from props and rest args', () => {
24
+ const child: BdcElement = { type: 'text', props: { text: 'a' }, children: [] };
25
+ const el = jsx('row', { gap: 8, children: child }, jsx('text', { text: 'b' }));
26
+ expect(el.props).toEqual({ gap: 8 });
27
+ expect(el.children).toHaveLength(2);
28
+ expect(el.children[0].props).toEqual({ text: 'a' });
29
+ expect(el.children[1].props).toEqual({ text: 'b' });
30
+ });
31
+
32
+ it('invokes function components', () => {
33
+ const Comp = (props: Record<string, unknown>) => ({
34
+ type: 'button',
35
+ props: { label: props.label, children: props.children },
36
+ children: (props.children as BdcElement[]) ?? [],
37
+ });
38
+ const el = jsx(Comp, { label: 'Go' }, jsx('text', { text: 'inner' }));
39
+ expect(el.type).toBe('button');
40
+ expect(el.props.label).toBe('Go');
41
+ expect(el.children[0].type).toBe('text');
42
+ });
43
+
44
+ it('exports Fragment and styleWire helpers', () => {
45
+ expect(Fragment).toBe('_fragment');
46
+ expect(styleWire({ padding: 'md' })).toEqual({ style: { padding: 'md' } });
47
+ });
48
+
49
+ it('ignores non-element objects without type', () => {
50
+ const el = jsx('column', null, { foo: 1 });
51
+ expect(el.children).toEqual([]);
52
+ });
53
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { APP_ICON_NAMES, isAppIconName } from './app-icon-names';
3
+ import { isKnownBlockType, SDUI_BLOCK_TYPES } from './block-types';
4
+ import {
5
+ allCatalogEmojis,
6
+ CHAT_EMOJI_CATEGORIES,
7
+ DEFAULT_QUICK_REACTIONS,
8
+ getEmojiCategory,
9
+ QUICK_REACTION_SLOTS,
10
+ } from './chat-emoji-catalog';
11
+ import {
12
+ FLASH_FILTER_ALL_ACTION_ID,
13
+ FLASH_REACTION_IDS,
14
+ FLASH_REACTIONS,
15
+ flashFilterActionId,
16
+ getFlashReaction,
17
+ isFlashReactionId,
18
+ parseFlashInboxFilter,
19
+ } from './flash-reaction-catalog';
20
+ import {
21
+ COLOR_TOKENS,
22
+ SHAPE_TOKENS,
23
+ SPACING_TOKENS,
24
+ TYPE_TOKENS,
25
+ } from './style-tokens';
26
+ import {
27
+ allCatalogEmojis as viaIndexAll,
28
+ isAppIconName as viaIndexIsIcon,
29
+ isKnownBlockType as viaIndexIsBlock,
30
+ } from './index';
31
+
32
+ describe('block-types', () => {
33
+ it('lists known block types', () => {
34
+ expect(SDUI_BLOCK_TYPES).toContain('text');
35
+ expect(isKnownBlockType('button')).toBe(true);
36
+ expect(isKnownBlockType('unknown_block')).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe('app-icon-names', () => {
41
+ it('validates icon tokens', () => {
42
+ expect(APP_ICON_NAMES.length).toBeGreaterThan(10);
43
+ expect(isAppIconName('search')).toBe(true);
44
+ expect(isAppIconName('not-an-icon')).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe('style-tokens', () => {
49
+ it('exports token catalogs', () => {
50
+ expect(COLOR_TOKENS).toContain('primary');
51
+ expect(SPACING_TOKENS).toContain('md');
52
+ expect(SHAPE_TOKENS).toContain('cornerFull');
53
+ expect(TYPE_TOKENS).toContain('body');
54
+ });
55
+ });
56
+
57
+ describe('chat-emoji-catalog', () => {
58
+ it('resolves categories and flattens unique emojis', () => {
59
+ expect(DEFAULT_QUICK_REACTIONS).toHaveLength(QUICK_REACTION_SLOTS);
60
+ expect(getEmojiCategory('smileys').id).toBe('smileys');
61
+ expect(getEmojiCategory('spicy').id).toBe('spicy');
62
+ const all = allCatalogEmojis();
63
+ expect(all.length).toBeGreaterThan(50);
64
+ expect(new Set(all).size).toBe(all.length);
65
+ expect(CHAT_EMOJI_CATEGORIES.length).toBe(8);
66
+ });
67
+ });
68
+
69
+ describe('flash-reaction-catalog', () => {
70
+ it('looks up reactions and parses filters', () => {
71
+ expect(FLASH_REACTIONS).toHaveLength(FLASH_REACTION_IDS.length);
72
+ expect(getFlashReaction('fire').id).toBe('fire');
73
+ expect(getFlashReaction(null).id).toBe('flash');
74
+ expect(getFlashReaction('nope').id).toBe('flash');
75
+ expect(flashFilterActionId('heart')).toBe('flash_filter_heart');
76
+ expect(FLASH_FILTER_ALL_ACTION_ID).toBe('flash_filter_all');
77
+ expect(parseFlashInboxFilter('wave')).toBe('wave');
78
+ expect(parseFlashInboxFilter(undefined)).toBe('all');
79
+ expect(parseFlashInboxFilter('nope')).toBe('all');
80
+ expect(isFlashReactionId('chat')).toBe(true);
81
+ expect(isFlashReactionId('x')).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe('catalog index re-exports', () => {
86
+ it('re-exports helpers', () => {
87
+ expect(viaIndexIsBlock('text')).toBe(true);
88
+ expect(viaIndexIsIcon('search')).toBe(true);
89
+ expect(viaIndexAll().length).toBeGreaterThan(0);
90
+ });
91
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { brToISO, isValidDateBR, maskDateBR } from './brazilian-date-input';
3
+
4
+ describe('brazilian-date-input', () => {
5
+ it('masks digits as DD/MM/YYYY', () => {
6
+ expect(maskDateBR('01022000')).toBe('01/02/2000');
7
+ expect(maskDateBR('01')).toBe('01');
8
+ });
9
+
10
+ it('validates calendar dates', () => {
11
+ expect(isValidDateBR('01/02/2000')).toBe(true);
12
+ expect(isValidDateBR('31/02/2000')).toBe(false);
13
+ expect(isValidDateBR('1/2/2000')).toBe(false);
14
+ });
15
+
16
+ it('converts BR date to ISO', () => {
17
+ expect(brToISO('01/02/2000')).toBe('2000-02-01');
18
+ expect(brToISO('bad')).toBeNull();
19
+ });
20
+ });
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ formatDistanceLabel,
4
+ getPresenceStatus,
5
+ isDiscoverGridItem,
6
+ profileAgeFromBirthDate,
7
+ readDiscoverGridItems,
8
+ } from './discover-profile-formatters';
9
+
10
+ describe('profileAgeFromBirthDate', () => {
11
+ it('returns null for empty or invalid dates', () => {
12
+ expect(profileAgeFromBirthDate(null)).toBeNull();
13
+ expect(profileAgeFromBirthDate('not-a-date')).toBeNull();
14
+ });
15
+
16
+ it('computes age from ISO birth date', () => {
17
+ expect(profileAgeFromBirthDate('2000-01-01')).toBeGreaterThanOrEqual(25);
18
+ });
19
+ });
20
+
21
+ describe('formatDistanceLabel', () => {
22
+ it('formats metric distances', () => {
23
+ expect(formatDistanceLabel(500)).toBe('500 m');
24
+ expect(formatDistanceLabel(1500)).toBe('1.5 km');
25
+ expect(formatDistanceLabel(15000)).toBe('15 km');
26
+ });
27
+
28
+ it('formats imperial distances', () => {
29
+ expect(formatDistanceLabel(10, 'imperial')).toMatch(/ft$/);
30
+ expect(formatDistanceLabel(2000, 'imperial')).toMatch(/mi$/);
31
+ });
32
+ });
33
+
34
+ describe('getPresenceStatus', () => {
35
+ afterEach(() => {
36
+ vi.useRealTimers();
37
+ });
38
+
39
+ it('classifies lastSeen windows', () => {
40
+ vi.useFakeTimers();
41
+ vi.setSystemTime(new Date('2026-01-01T12:00:00Z'));
42
+ expect(getPresenceStatus(null)).toBe('offline');
43
+ expect(getPresenceStatus('not-a-date')).toBe('offline');
44
+ expect(getPresenceStatus('2026-01-01T11:58:00Z')).toBe('online');
45
+ expect(getPresenceStatus('2026-01-01T06:00:00Z')).toBe('recent');
46
+ expect(getPresenceStatus('2025-12-01T12:00:00Z')).toBe('offline');
47
+ });
48
+ });
49
+
50
+ describe('discover grid helpers', () => {
51
+ it('type-guards and reads nested grid items', () => {
52
+ expect(isDiscoverGridItem(null)).toBe(false);
53
+ expect(isDiscoverGridItem({ kind: 'profile' })).toBe(true);
54
+ expect(isDiscoverGridItem({ kind: 'ad' })).toBe(true);
55
+ expect(isDiscoverGridItem({ kind: 'other' })).toBe(false);
56
+
57
+ const data = {
58
+ discover: {
59
+ items: [
60
+ { kind: 'profile', id: 'p1', person: { id: 'p1' } },
61
+ { kind: 'ad', id: 'a1' },
62
+ { kind: 'nope' },
63
+ null,
64
+ ],
65
+ },
66
+ };
67
+ expect(readDiscoverGridItems(data, 'discover.items')).toHaveLength(2);
68
+ expect(readDiscoverGridItems(data, 'discover.missing')).toEqual([]);
69
+ expect(readDiscoverGridItems({}, 'a.b')).toEqual([]);
70
+
71
+ vi.useFakeTimers();
72
+ vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
73
+ expect(profileAgeFromBirthDate('2000-07-01')).toBe(25);
74
+ expect(profileAgeFromBirthDate('2000-06-15')).toBe(26);
75
+ expect(profileAgeFromBirthDate('2099-01-01')).toBeNull();
76
+ vi.useRealTimers();
77
+ });
78
+ });
79
+
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveDataRef, resolveDataRefBoolean, resolveDataRefString } from './resolve-data-ref';
3
+
4
+ describe('resolveDataRef', () => {
5
+ const data = { user: { name: 'Ada', flags: { admin: true } } };
6
+
7
+ it('walks dotted paths', () => {
8
+ expect(resolveDataRef(data, 'user.name')).toBe('Ada');
9
+ expect(resolveDataRef(data, 'missing.path')).toBeUndefined();
10
+ expect(resolveDataRef(data, undefined)).toBeUndefined();
11
+ });
12
+
13
+ it('coerces string and boolean refs', () => {
14
+ expect(resolveDataRefString(data, 'user.name')).toBe('Ada');
15
+ expect(resolveDataRefString(data, 'missing')).toBe('');
16
+ expect(resolveDataRefBoolean(data, 'user.flags.admin')).toBe(true);
17
+ expect(resolveDataRefBoolean(data, 'user.name')).toBe(false);
18
+ });
19
+ });