@proulr/sdui-components 0.1.30 → 0.1.33

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.30",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -25,10 +25,13 @@
25
25
  "typecheck": "tsc --noEmit",
26
26
  "test": "vitest run",
27
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",
28
30
  "prepublishOnly": "npm run typecheck",
29
31
  "lint": "eslint .",
30
32
  "lint:fix": "eslint . --fix",
31
- "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"
32
35
  },
33
36
  "publishConfig": {
34
37
  "access": "public"
@@ -67,6 +70,7 @@
67
70
  "devDependencies": {
68
71
  "@types/node": "^22.20.0",
69
72
  "@types/react": "~19.2.2",
73
+ "@vitest/coverage-v8": "^3.2.7",
70
74
  "eslint": "^9.0.0",
71
75
  "eslint-config-expo": "~56.0.4",
72
76
  "expo-blur": "~56.0.3",
@@ -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
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { compileBdcToWire } from './compile-bdc-to-wire';
2
+ import { compileBdcBlock, compileBdcToWire } from './compile-bdc-to-wire';
3
3
  import type { BdcElement } from './jsx-runtime';
4
+ import { compileBdcToWire as viaIndex } from './index';
4
5
 
5
6
  function screen(props: Record<string, unknown>, children: BdcElement[]): BdcElement {
6
7
  return { type: 'screen', props, children };
@@ -45,4 +46,107 @@ describe('compileBdcToWire', () => {
45
46
  compileBdcToWire({ type: 'stack', props: {}, children: [] }),
46
47
  ).toThrow(/bdc_root_must_be_screen/);
47
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
+ });
48
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
+ });
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import {
3
3
  formatDistanceLabel,
4
4
  getPresenceStatus,
5
+ isDiscoverGridItem,
5
6
  profileAgeFromBirthDate,
7
+ readDiscoverGridItems,
6
8
  } from './discover-profile-formatters';
7
9
 
8
10
  describe('profileAgeFromBirthDate', () => {
@@ -38,8 +40,40 @@ describe('getPresenceStatus', () => {
38
40
  vi.useFakeTimers();
39
41
  vi.setSystemTime(new Date('2026-01-01T12:00:00Z'));
40
42
  expect(getPresenceStatus(null)).toBe('offline');
43
+ expect(getPresenceStatus('not-a-date')).toBe('offline');
41
44
  expect(getPresenceStatus('2026-01-01T11:58:00Z')).toBe('online');
42
45
  expect(getPresenceStatus('2026-01-01T06:00:00Z')).toBe('recent');
43
46
  expect(getPresenceStatus('2025-12-01T12:00:00Z')).toBe('offline');
44
47
  });
45
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
+
@@ -9,6 +9,7 @@ export function AppButton({
9
9
  disabled,
10
10
  loading,
11
11
  compact,
12
+ testID,
12
13
  }: {
13
14
  label: string;
14
15
  onPress: () => void;
@@ -16,6 +17,7 @@ export function AppButton({
16
17
  disabled?: boolean;
17
18
  loading?: boolean;
18
19
  compact?: boolean;
20
+ testID?: string;
19
21
  }) {
20
22
  const isPrimary = variant === 'primary';
21
23
  const isSecondary = variant === 'secondary';
@@ -30,6 +32,8 @@ export function AppButton({
30
32
 
31
33
  return (
32
34
  <Pressable
35
+ testID={testID}
36
+ accessibilityRole="button"
33
37
  onPress={onPress}
34
38
  disabled={disabled || loading}
35
39
  style={({ pressed }) => [