@startsimpli/ui-native 0.1.1 → 0.1.3
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/README.md +13 -0
- package/package.json +2 -1
- package/src/__tests__/forms.test.tsx +169 -0
- package/src/__tests__/interactive.test.tsx +117 -0
- package/src/__tests__/lists.test.tsx +190 -0
- package/src/__tests__/logic-list.test.ts +88 -0
- package/src/__tests__/logic-markdown.test.ts +39 -0
- package/src/__tests__/logic-misc.test.ts +87 -0
- package/src/__tests__/styles-overlay.test.ts +14 -0
- package/src/__tests__/tabbar.test.tsx +21 -0
- package/src/components/ChatComposer.tsx +65 -0
- package/src/components/Chip.tsx +5 -1
- package/src/components/DataList.tsx +52 -0
- package/src/components/EmptyState.tsx +6 -1
- package/src/components/FormField.tsx +38 -0
- package/src/components/Header.tsx +46 -0
- package/src/components/ListControls.tsx +85 -0
- package/src/components/Markdown.tsx +93 -0
- package/src/components/MessageBubble.tsx +49 -0
- package/src/components/SearchBar.tsx +52 -0
- package/src/components/SectionedList.tsx +55 -0
- package/src/components/SegmentedControl.tsx +4 -1
- package/src/components/Select.tsx +95 -0
- package/src/components/Sheet.tsx +41 -0
- package/src/components/StatusChip.tsx +27 -0
- package/src/components/SwipeRow.tsx +85 -0
- package/src/components/TabBar.tsx +1 -1
- package/src/components/TextArea.tsx +38 -0
- package/src/components/Toast.tsx +61 -0
- package/src/components/serverListTypes.ts +53 -0
- package/src/components/useServerList.tsx +58 -0
- package/src/index.ts +20 -0
- package/src/logic/chat.ts +3 -0
- package/src/logic/listState.ts +77 -0
- package/src/logic/markdown.ts +112 -0
- package/src/logic/select.ts +12 -0
- package/src/logic/status.ts +27 -0
- package/src/logic/swipe.ts +13 -0
- package/src/logic/toast.ts +28 -0
- package/src/styles-chat.ts +51 -0
- package/src/styles-overlay.ts +100 -0
- package/src/styles.ts +3 -0
package/README.md
CHANGED
|
@@ -69,6 +69,19 @@ export default function App() {
|
|
|
69
69
|
`theme={lightTheme | darkTheme | customTheme}` to pin it) and mounts a
|
|
70
70
|
`SafeAreaProvider` so `Screen` can apply insets.
|
|
71
71
|
|
|
72
|
+
## Tandem-screen primitives
|
|
73
|
+
|
|
74
|
+
`Markdown`, `Sheet`, `Select`, `FormField`, `TextArea`, `SearchBar`, `Header`,
|
|
75
|
+
`StatusChip`, `SwipeRow`, `ToastProvider`/`useToast`, `MessageBubble`,
|
|
76
|
+
`ChatComposer`, `DataList` and `SectionedList`. Core React Native only (no
|
|
77
|
+
gesture-handler, reanimated or markdown dependency).
|
|
78
|
+
|
|
79
|
+
`DataList` / `SectionedList` are built for server-driven data and never filter,
|
|
80
|
+
sort or slice rows themselves: debounced `search.onSearch`, `filters` +
|
|
81
|
+
`onFilterChange`, `sort`, an active-filter summary with `onClearFilters`,
|
|
82
|
+
`onRefresh`, `onLoadMore` guarded to one call per loaded length (`hasMore` /
|
|
83
|
+
`loadingMore` footer), and distinct loading / empty / no-results / error states.
|
|
84
|
+
|
|
72
85
|
## Design tokens
|
|
73
86
|
|
|
74
87
|
Tokens are the single source for color, spacing, type scale, radii, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui-native",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "React Native UI primitives for StartSimpli apps (View/Text/Pressable), fed by shared design tokens",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://startsimpli.com",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"react-native": "0.85.3",
|
|
52
52
|
"react-native-safe-area-context": "~5.7.0",
|
|
53
53
|
"react-native-svg": "15.15.4",
|
|
54
|
+
"react-test-renderer": "19.2.5",
|
|
54
55
|
"typescript": "^6.0.3",
|
|
55
56
|
"vitest": "^4.1.5"
|
|
56
57
|
},
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, renderDark, allText, hostById, maybeById, press, act, flat } from '../../test/render';
|
|
3
|
+
import { Markdown } from '../components/Markdown';
|
|
4
|
+
import { Sheet } from '../components/Sheet';
|
|
5
|
+
import { Select } from '../components/Select';
|
|
6
|
+
import { TextArea } from '../components/TextArea';
|
|
7
|
+
import { FormField } from '../components/FormField';
|
|
8
|
+
import { SearchBar } from '../components/SearchBar';
|
|
9
|
+
import { Header } from '../components/Header';
|
|
10
|
+
import { StatusChip } from '../components/StatusChip';
|
|
11
|
+
import * as RN from 'react-native';
|
|
12
|
+
import { Text } from '../components/Text';
|
|
13
|
+
|
|
14
|
+
describe('Markdown', () => {
|
|
15
|
+
const src = '# Title\n\nHello **bold** and [link](https://a.com)\n\n- one\n- two\n\n1. first';
|
|
16
|
+
it('renders headings as headers, list markers and bold weight', () => {
|
|
17
|
+
const r = render(<Markdown testID="md">{src}</Markdown>);
|
|
18
|
+
const t = allText(r);
|
|
19
|
+
expect(t).toContain('Title');
|
|
20
|
+
expect(t).toContain('•');
|
|
21
|
+
expect(t).toContain('1.');
|
|
22
|
+
const header = r.root.findAll((n) => n.props.accessibilityRole === 'header');
|
|
23
|
+
expect(header.length).toBe(1);
|
|
24
|
+
const bold = r.root.findAll((n) => n.type === 'Text' && flat(n.props.style).fontWeight === '700' && n.children.includes('bold'));
|
|
25
|
+
expect(bold.length).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
it('links open via onLinkPress, default Linking, and take the primary colour per scheme', () => {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
const r = render(<Markdown onLinkPress={(u) => calls.push(u)}>{src}</Markdown>);
|
|
30
|
+
const link = r.root.findAll((n) => n.props.accessibilityRole === 'link')[0];
|
|
31
|
+
act(() => link.props.onPress());
|
|
32
|
+
expect(calls).toEqual(['https://a.com']);
|
|
33
|
+
const r2 = render(<Markdown>{src}</Markdown>);
|
|
34
|
+
act(() => r2.root.findAll((n) => n.props.accessibilityRole === 'link')[0].props.onPress());
|
|
35
|
+
expect((RN as any).opened).toContain('https://a.com');
|
|
36
|
+
const light = flat(link.props.style).color;
|
|
37
|
+
const dark = flat(renderDark(<Markdown>{src}</Markdown>).root.findAll((n) => n.props.accessibilityRole === 'link')[0].props.style).color;
|
|
38
|
+
expect(light).not.toBe(dark);
|
|
39
|
+
});
|
|
40
|
+
it('renders nothing for blank source', () => {
|
|
41
|
+
expect(allText(render(<Markdown>{' '}</Markdown>))).toBe('');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('Sheet', () => {
|
|
46
|
+
it('renders nothing when closed, title + content when open', () => {
|
|
47
|
+
expect(allText(render(<Sheet visible={false} onClose={() => {}} title="T"><Text>body</Text></Sheet>))).toBe('');
|
|
48
|
+
const r = render(<Sheet visible onClose={() => {}} title="Pick" testID="s"><Text>body</Text></Sheet>);
|
|
49
|
+
expect(allText(r)).toContain('Pick');
|
|
50
|
+
expect(allText(r)).toContain('body');
|
|
51
|
+
});
|
|
52
|
+
it('backdrop dismisses and is labelled', () => {
|
|
53
|
+
let closed = 0;
|
|
54
|
+
const r = render(<Sheet visible onClose={() => closed++} testID="s"><Text>b</Text></Sheet>);
|
|
55
|
+
const bd = hostById(r, 's-backdrop');
|
|
56
|
+
expect(bd.props.accessibilityLabel).toBe('Dismiss');
|
|
57
|
+
press(bd);
|
|
58
|
+
expect(closed).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('Select', () => {
|
|
63
|
+
const options = [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, { value: 'c', label: 'Gamma' }];
|
|
64
|
+
it('shows placeholder then selected label; opens sheet; picks option', () => {
|
|
65
|
+
const picks: (string | null)[] = [];
|
|
66
|
+
const r = render(<Select testID="sel" label="Project" options={options} value={null} onChange={(v) => picks.push(v)} placeholder="Choose" />);
|
|
67
|
+
expect(allText(r)).toContain('Choose');
|
|
68
|
+
expect(maybeById(r, 'sel-sheet')).toHaveLength(0);
|
|
69
|
+
press(hostById(r, 'sel'));
|
|
70
|
+
expect(maybeById(r, 'sel-option-b')).toHaveLength(1);
|
|
71
|
+
press(hostById(r, 'sel-option-b'));
|
|
72
|
+
expect(picks).toEqual(['b']);
|
|
73
|
+
expect(maybeById(r, 'sel-option-b')).toHaveLength(0); // closed
|
|
74
|
+
});
|
|
75
|
+
it('trigger is an accessible button with the current value', () => {
|
|
76
|
+
const r = render(<Select testID="sel" label="Project" options={options} value="c" onChange={() => {}} />);
|
|
77
|
+
const t = hostById(r, 'sel');
|
|
78
|
+
expect(t.props.accessibilityRole).toBe('button');
|
|
79
|
+
expect(t.props.accessibilityLabel).toBe('Project: Gamma');
|
|
80
|
+
});
|
|
81
|
+
it('searchable filters the options', () => {
|
|
82
|
+
const r = render(<Select testID="sel" options={options} value={null} onChange={() => {}} searchable />);
|
|
83
|
+
press(hostById(r, 'sel'));
|
|
84
|
+
act(() => hostById(r, 'sel-search-input').props.onChangeText('gam'));
|
|
85
|
+
expect(maybeById(r, 'sel-option-a')).toHaveLength(0);
|
|
86
|
+
expect(maybeById(r, 'sel-option-c')).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
it('clearLabel offers an explicit "all" row that emits null', () => {
|
|
89
|
+
const picks: (string | null)[] = [];
|
|
90
|
+
const r = render(<Select testID="sel" options={options} value="a" onChange={(v) => picks.push(v)} clearLabel="All projects" />);
|
|
91
|
+
press(hostById(r, 'sel'));
|
|
92
|
+
press(hostById(r, 'sel-clear'));
|
|
93
|
+
expect(picks).toEqual([null]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('TextArea + FormField', () => {
|
|
98
|
+
it('multiline input with label, hint and error alert', () => {
|
|
99
|
+
const r = render(<TextArea testID="ta" label="Notes" hint="Optional" error="Too short" value="x" onChangeText={() => {}} />);
|
|
100
|
+
const input = hostById(r, 'ta');
|
|
101
|
+
expect(input.props.multiline).toBe(true);
|
|
102
|
+
expect(input.props.accessibilityLabel).toBe('Notes');
|
|
103
|
+
expect(allText(r)).toContain('Notes');
|
|
104
|
+
expect(allText(r)).toContain('Too short');
|
|
105
|
+
expect(allText(r)).not.toContain('Optional'); // error replaces hint
|
|
106
|
+
expect(r.root.findAll((n) => n.props.accessibilityRole === 'alert').length).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
it('shows a character counter with maxLength', () => {
|
|
109
|
+
const r = render(<TextArea testID="ta" value="abc" onChangeText={() => {}} maxLength={10} />);
|
|
110
|
+
expect(allText(r)).toContain('3/10');
|
|
111
|
+
});
|
|
112
|
+
it('FormField marks required and shows hint without error', () => {
|
|
113
|
+
const r = render(<FormField label="Name" required hint="Your name"><Text>child</Text></FormField>);
|
|
114
|
+
expect(allText(r)).toContain('Name');
|
|
115
|
+
expect(allText(r)).toContain('*');
|
|
116
|
+
expect(allText(r)).toContain('Your name');
|
|
117
|
+
expect(allText(r)).toContain('child');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('SearchBar', () => {
|
|
122
|
+
it('reports text, submit, and clears with an accessible button', () => {
|
|
123
|
+
const log: string[] = [];
|
|
124
|
+
const r = render(<SearchBar testID="sb" value="milk" onChangeText={(t) => log.push('t:' + t)} onSubmit={(t) => log.push('s:' + t)} onClear={() => log.push('clear')} />);
|
|
125
|
+
const input = hostById(r, 'sb');
|
|
126
|
+
expect(input.props.returnKeyType).toBe('search');
|
|
127
|
+
act(() => input.props.onChangeText('mil'));
|
|
128
|
+
act(() => input.props.onSubmitEditing({ nativeEvent: { text: 'mil' } }));
|
|
129
|
+
const clear = hostById(r, 'sb-clear');
|
|
130
|
+
expect(clear.props.accessibilityLabel).toBe('Clear search');
|
|
131
|
+
press(clear);
|
|
132
|
+
expect(log).toEqual(['t:mil', 's:milk', 't:', 'clear']);
|
|
133
|
+
});
|
|
134
|
+
it('no clear button when empty', () => {
|
|
135
|
+
expect(maybeById(render(<SearchBar testID="sb" value="" onChangeText={() => {}} />), 'sb-clear')).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('Header', () => {
|
|
140
|
+
it('title, subtitle, back and trailing', () => {
|
|
141
|
+
let back = 0;
|
|
142
|
+
const r = render(<Header testID="h" title="Note" subtitle="Today" onBack={() => back++} trailing={<Text>Edit</Text>} />);
|
|
143
|
+
expect(allText(r)).toContain('Note');
|
|
144
|
+
expect(allText(r)).toContain('Today');
|
|
145
|
+
expect(allText(r)).toContain('Edit');
|
|
146
|
+
const b = hostById(r, 'h-back');
|
|
147
|
+
expect(b.props.accessibilityLabel).toBe('Back');
|
|
148
|
+
press(b);
|
|
149
|
+
expect(back).toBe(1);
|
|
150
|
+
expect(r.root.findAll((n) => n.props.accessibilityRole === 'header').length).toBeGreaterThan(0);
|
|
151
|
+
});
|
|
152
|
+
it('no back button without onBack', () => {
|
|
153
|
+
expect(maybeById(render(<Header testID="h" title="Home" />), 'h-back')).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('StatusChip', () => {
|
|
158
|
+
it('maps status to label and variant colours', () => {
|
|
159
|
+
const ok = render(<StatusChip testID="c" status="done" />);
|
|
160
|
+
expect(allText(ok)).toContain('Done');
|
|
161
|
+
const bad = render(<StatusChip testID="c" status="failed" />);
|
|
162
|
+
expect(flat(hostById(ok, 'c').props.style).backgroundColor).not.toBe(flat(hostById(bad, 'c').props.style).backgroundColor);
|
|
163
|
+
});
|
|
164
|
+
it('shows progress percent and a labelled bar', () => {
|
|
165
|
+
const r = render(<StatusChip testID="c" status="processing" progress={0.42} />);
|
|
166
|
+
expect(allText(r)).toContain('42%');
|
|
167
|
+
expect(hostById(r, 'c').props.accessibilityLabel).toBe('Processing 42%');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, allText, hostById, maybeById, press, act, flat, texts } from '../../test/render';
|
|
3
|
+
import { SwipeRow } from '../components/SwipeRow';
|
|
4
|
+
import { ToastProvider, useToast } from '../components/Toast';
|
|
5
|
+
import { MessageBubble } from '../components/MessageBubble';
|
|
6
|
+
import { ChatComposer } from '../components/ChatComposer';
|
|
7
|
+
import { Text } from '../components/Text';
|
|
8
|
+
|
|
9
|
+
describe('SwipeRow', () => {
|
|
10
|
+
const mk = (log: string[]) => (
|
|
11
|
+
<SwipeRow testID="row" actions={[
|
|
12
|
+
{ key: 'done', label: 'Done', tone: 'success', onPress: () => log.push('done') },
|
|
13
|
+
{ key: 'delete', label: 'Delete', tone: 'destructive', onPress: () => log.push('delete') },
|
|
14
|
+
]}><Text>Buy milk</Text></SwipeRow>
|
|
15
|
+
);
|
|
16
|
+
it('renders content and tappable actions', () => {
|
|
17
|
+
const log: string[] = [];
|
|
18
|
+
const r = render(mk(log));
|
|
19
|
+
expect(allText(r)).toContain('Buy milk');
|
|
20
|
+
press(hostById(r, 'row-action-delete'));
|
|
21
|
+
press(hostById(r, 'row-action-done'));
|
|
22
|
+
expect(log).toEqual(['delete', 'done']);
|
|
23
|
+
});
|
|
24
|
+
it('exposes actions to VoiceOver without swiping', () => {
|
|
25
|
+
const log: string[] = [];
|
|
26
|
+
const r = render(mk(log));
|
|
27
|
+
const row = hostById(r, 'row');
|
|
28
|
+
expect(row.props.accessibilityActions).toEqual([{ name: 'done', label: 'Done' }, { name: 'delete', label: 'Delete' }]);
|
|
29
|
+
act(() => row.props.onAccessibilityAction({ nativeEvent: { actionName: 'delete' } }));
|
|
30
|
+
expect(log).toEqual(['delete']);
|
|
31
|
+
});
|
|
32
|
+
it('defaultOpen starts with the actions revealed', () => {
|
|
33
|
+
const r = render(<SwipeRow testID="row" defaultOpen actions={[{ key: 'a', label: 'A', onPress: () => {} }, { key: 'b', label: 'B', onPress: () => {} }]}><Text>x</Text></SwipeRow>);
|
|
34
|
+
const view = r.root.findAll((n) => n.props.__cfg)[0];
|
|
35
|
+
expect(view.props.style.transform[0].translateX.v).toBe(-168);
|
|
36
|
+
});
|
|
37
|
+
it('pan handler opens past half and closes on slow short drag', () => {
|
|
38
|
+
const r = render(mk([]));
|
|
39
|
+
const cfg = r.root.findAll((n) => n.props.__cfg)[0]?.props.__cfg ?? (r.root.findAll((n) => n.props.panHandlers)[0]?.props.panHandlers.__cfg);
|
|
40
|
+
expect(cfg).toBeTruthy();
|
|
41
|
+
expect(cfg.onMoveShouldSetPanResponder({}, { dx: -20, dy: 2 })).toBe(true);
|
|
42
|
+
expect(cfg.onMoveShouldSetPanResponder({}, { dx: 3, dy: 30 })).toBe(false); // vertical scroll wins
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('Toast', () => {
|
|
47
|
+
beforeEach(() => vi.useFakeTimers());
|
|
48
|
+
afterEach(() => vi.useRealTimers());
|
|
49
|
+
function Trigger({ opts }: { opts?: any }) {
|
|
50
|
+
const toast = useToast();
|
|
51
|
+
return <Text testID="go" onPress={() => toast.show('Saved!', opts)}>go</Text>;
|
|
52
|
+
}
|
|
53
|
+
it('shows a toast, then auto-dismisses after durationMs', () => {
|
|
54
|
+
const r = render(<ToastProvider><Trigger /></ToastProvider>);
|
|
55
|
+
act(() => hostById(r, 'go').props.onPress());
|
|
56
|
+
expect(allText(r)).toContain('Saved!');
|
|
57
|
+
act(() => { vi.advanceTimersByTime(3001); });
|
|
58
|
+
expect(allText(r)).not.toContain('Saved!');
|
|
59
|
+
});
|
|
60
|
+
it('durationMs 0 sticks until tapped; role alert for errors', () => {
|
|
61
|
+
const r = render(<ToastProvider><Trigger opts={{ tone: 'error', durationMs: 0 }} /></ToastProvider>);
|
|
62
|
+
act(() => hostById(r, 'go').props.onPress());
|
|
63
|
+
act(() => { vi.advanceTimersByTime(60000); });
|
|
64
|
+
expect(allText(r)).toContain('Saved!');
|
|
65
|
+
const t = r.root.findAll((n) => n.props.accessibilityRole === 'alert')[0];
|
|
66
|
+
expect(t).toBeTruthy();
|
|
67
|
+
act(() => t.props.onPress());
|
|
68
|
+
expect(allText(r)).not.toContain('Saved!');
|
|
69
|
+
});
|
|
70
|
+
it('useToast without a provider throws a clear error', () => {
|
|
71
|
+
expect(() => render(<Trigger />)).toThrow(/ToastProvider/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('MessageBubble', () => {
|
|
76
|
+
it('user bubbles right, assistant left, colours differ', () => {
|
|
77
|
+
const u = render(<MessageBubble testID="b" role="user" text="hi" />);
|
|
78
|
+
const a = render(<MessageBubble testID="b" role="assistant" text="hello" />);
|
|
79
|
+
expect(flat(hostById(u, 'b').props.style).alignSelf).toBe('flex-end');
|
|
80
|
+
expect(flat(hostById(a, 'b').props.style).alignSelf).toBe('flex-start');
|
|
81
|
+
expect(flat(hostById(u, 'b').props.style).backgroundColor).not.toBe(flat(hostById(a, 'b').props.style).backgroundColor);
|
|
82
|
+
});
|
|
83
|
+
it('assistant text renders markdown', () => {
|
|
84
|
+
const a = render(<MessageBubble testID="b" role="assistant" text={'- one\n- two'} />);
|
|
85
|
+
expect(allText(a)).toContain('•');
|
|
86
|
+
});
|
|
87
|
+
it('pending shows indicator, error offers retry', () => {
|
|
88
|
+
let retried = 0;
|
|
89
|
+
const p = render(<MessageBubble testID="b" role="assistant" text="" pending />);
|
|
90
|
+
expect(hostById(p, 'b').props.accessibilityLabel).toMatch(/typing|thinking/i);
|
|
91
|
+
const e = render(<MessageBubble testID="b" role="user" text="x" error onRetry={() => retried++} />);
|
|
92
|
+
press(hostById(e, 'b-retry'));
|
|
93
|
+
expect(retried).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('ChatComposer', () => {
|
|
98
|
+
it('keyboard-avoiding wrapper, send disabled until text', () => {
|
|
99
|
+
const sent: string[] = [];
|
|
100
|
+
const r = render(<ChatComposer testID="c" onSend={(t) => sent.push(t)} placeholder="Ask" />);
|
|
101
|
+
expect(r.root.findAllByType('KeyboardAvoidingView' as any).length).toBe(1);
|
|
102
|
+
const send = hostById(r, 'c-send');
|
|
103
|
+
expect(send.props.accessibilityLabel).toBe('Send message');
|
|
104
|
+
expect(send.props.disabled).toBe(true);
|
|
105
|
+
act(() => hostById(r, 'c-input').props.onChangeText(' hello '));
|
|
106
|
+
expect(hostById(r, 'c-send').props.disabled).toBe(false);
|
|
107
|
+
press(hostById(r, 'c-send'));
|
|
108
|
+
expect(sent).toEqual(['hello']);
|
|
109
|
+
expect(hostById(r, 'c-input').props.value).toBe(''); // uncontrolled clears
|
|
110
|
+
});
|
|
111
|
+
it('controlled mode does not clear; sending disables', () => {
|
|
112
|
+
const sent: string[] = [];
|
|
113
|
+
const r = render(<ChatComposer testID="c" value="q" onChangeText={() => {}} onSend={(t) => sent.push(t)} sending />);
|
|
114
|
+
expect(hostById(r, 'c-send').props.disabled).toBe(true);
|
|
115
|
+
expect(hostById(r, 'c-input').props.value).toBe('q');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, allText, hostById, maybeById, press, act } from '../../test/render';
|
|
3
|
+
import { DataList } from '../components/DataList';
|
|
4
|
+
import { SectionedList } from '../components/SectionedList';
|
|
5
|
+
import { Text } from '../components/Text';
|
|
6
|
+
import { ThemeProvider } from '../components/ThemeProvider';
|
|
7
|
+
import { create } from 'react-test-renderer';
|
|
8
|
+
|
|
9
|
+
type Row = { id: string; name: string };
|
|
10
|
+
const rows: Row[] = [{ id: '1', name: 'Alpha' }, { id: '2', name: 'Beta' }];
|
|
11
|
+
const base = {
|
|
12
|
+
testID: 'l',
|
|
13
|
+
data: rows,
|
|
14
|
+
keyExtractor: (r: Row) => r.id,
|
|
15
|
+
renderItem: ({ item }: { item: Row }) => <Text>{item.name}</Text>,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('DataList states', () => {
|
|
19
|
+
it('loading shows skeletons only when no rows', () => {
|
|
20
|
+
const r = render(<DataList {...base} data={[]} loading />);
|
|
21
|
+
expect(maybeById(r, 'l-loading')).toHaveLength(1);
|
|
22
|
+
const r2 = render(<DataList {...base} loading />);
|
|
23
|
+
expect(maybeById(r2, 'l-loading')).toHaveLength(0);
|
|
24
|
+
expect(allText(r2)).toContain('Alpha');
|
|
25
|
+
});
|
|
26
|
+
it('error with retry', () => {
|
|
27
|
+
let n = 0;
|
|
28
|
+
const r = render(<DataList {...base} data={[]} error="Network down" onRetry={() => n++} />);
|
|
29
|
+
expect(allText(r)).toContain('Network down');
|
|
30
|
+
press(hostById(r, 'l-retry'));
|
|
31
|
+
expect(n).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
it('empty vs noResults are distinct copy; noResults offers clear', () => {
|
|
34
|
+
const e = render(<DataList {...base} data={[]} emptyTitle="Nothing yet" noResultsTitle="No matches" />);
|
|
35
|
+
expect(allText(e)).toContain('Nothing yet');
|
|
36
|
+
let cleared = 0;
|
|
37
|
+
const nr = render(<DataList {...base} data={[]} emptyTitle="Nothing yet" noResultsTitle="No matches" search={{ value: 'zzz', onChange: () => {}, onSearch: () => {} }} onClearFilters={() => cleared++} />);
|
|
38
|
+
expect(allText(nr)).toContain('No matches');
|
|
39
|
+
expect(allText(nr)).not.toContain('Nothing yet');
|
|
40
|
+
press(hostById(nr, 'l-empty-clear'));
|
|
41
|
+
expect(cleared).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
it('renders exactly the rows it is given: no client-side filter or sort', () => {
|
|
44
|
+
const r = render(<DataList {...base} data={[{ id: '9', name: 'Zulu' }, ...rows]} search={{ value: 'alpha', onChange: () => {}, onSearch: () => {} }} />);
|
|
45
|
+
const t = allText(r);
|
|
46
|
+
expect(t.indexOf('Zulu')).toBeGreaterThan(-1);
|
|
47
|
+
expect(t.indexOf('Zulu')).toBeLessThan(t.indexOf('Alpha'));
|
|
48
|
+
expect(t).toContain('Beta');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('DataList search debounce', () => {
|
|
53
|
+
beforeEach(() => vi.useFakeTimers());
|
|
54
|
+
afterEach(() => vi.useRealTimers());
|
|
55
|
+
it('onChange is immediate, onSearch fires once after debounceMs', () => {
|
|
56
|
+
const changes: string[] = []; const searches: string[] = [];
|
|
57
|
+
const r = render(<DataList {...base} search={{ value: '', onChange: (t) => changes.push(t), onSearch: (t) => searches.push(t), debounceMs: 250 }} />);
|
|
58
|
+
const input = () => hostById(r, 'l-search');
|
|
59
|
+
act(() => input().props.onChangeText('m'));
|
|
60
|
+
act(() => { vi.advanceTimersByTime(100); });
|
|
61
|
+
act(() => input().props.onChangeText('mi'));
|
|
62
|
+
act(() => { vi.advanceTimersByTime(249); });
|
|
63
|
+
expect(searches).toEqual([]);
|
|
64
|
+
act(() => { vi.advanceTimersByTime(1); });
|
|
65
|
+
expect(changes).toEqual(['m', 'mi']);
|
|
66
|
+
expect(searches).toEqual(['mi']);
|
|
67
|
+
});
|
|
68
|
+
it('submit flushes immediately and does not double-fire', () => {
|
|
69
|
+
const searches: string[] = [];
|
|
70
|
+
const r = render(<DataList {...base} search={{ value: 'milk', onChange: () => {}, onSearch: (t) => searches.push(t), debounceMs: 250 }} />);
|
|
71
|
+
act(() => hostById(r, 'l-search').props.onChangeText('milk'));
|
|
72
|
+
act(() => hostById(r, 'l-search').props.onSubmitEditing({ nativeEvent: { text: 'milk' } }));
|
|
73
|
+
act(() => { vi.advanceTimersByTime(1000); });
|
|
74
|
+
expect(searches).toEqual(['milk']);
|
|
75
|
+
});
|
|
76
|
+
it('clear button searches empty immediately', () => {
|
|
77
|
+
const searches: string[] = []; const changes: string[] = [];
|
|
78
|
+
const r = render(<DataList {...base} search={{ value: 'milk', onChange: (t) => changes.push(t), onSearch: (t) => searches.push(t) }} />);
|
|
79
|
+
press(hostById(r, 'l-search-clear'));
|
|
80
|
+
expect(changes).toEqual(['']);
|
|
81
|
+
expect(searches).toEqual(['']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('DataList filters, sort, summary', () => {
|
|
86
|
+
const filters = [
|
|
87
|
+
{ key: 'status', label: 'Status', value: 'open', options: [{ value: 'open', label: 'Open' }, { value: 'done', label: 'Done' }] },
|
|
88
|
+
{ key: 'project', label: 'Project', value: null, display: 'select' as const, options: [{ value: 'p1', label: 'Alpha' }] },
|
|
89
|
+
];
|
|
90
|
+
it('chip tap selects; tapping the active chip clears that filter', () => {
|
|
91
|
+
const log: any[] = [];
|
|
92
|
+
const r = render(<DataList {...base} filters={filters} onFilterChange={(k, v) => log.push([k, v])} />);
|
|
93
|
+
press(hostById(r, 'l-filter-status-done'));
|
|
94
|
+
press(hostById(r, 'l-filter-status-open'));
|
|
95
|
+
expect(log).toEqual([['status', 'done'], ['status', null]]);
|
|
96
|
+
});
|
|
97
|
+
it('select-style groups open a sheet picker', () => {
|
|
98
|
+
const log: any[] = [];
|
|
99
|
+
const r = render(<DataList {...base} filters={filters} onFilterChange={(k, v) => log.push([k, v])} />);
|
|
100
|
+
press(hostById(r, 'l-filter-project'));
|
|
101
|
+
press(hostById(r, 'l-filter-project-option-p1'));
|
|
102
|
+
expect(log).toEqual([['project', 'p1']]);
|
|
103
|
+
});
|
|
104
|
+
it('active-filter summary with count, and clear-all', () => {
|
|
105
|
+
let cleared = 0;
|
|
106
|
+
const r = render(<DataList {...base} filters={filters} search={{ value: 'milk', onChange: () => {}, onSearch: () => {} }} onFilterChange={() => {}} onClearFilters={() => cleared++} />);
|
|
107
|
+
expect(allText(r)).toContain('Status: Open');
|
|
108
|
+
expect(allText(r)).toContain('"milk"');
|
|
109
|
+
const clear = hostById(r, 'l-clear-filters');
|
|
110
|
+
expect(clear.props.accessibilityLabel).toBe('Clear 2 filters');
|
|
111
|
+
press(clear);
|
|
112
|
+
expect(cleared).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
it('no summary when nothing is active', () => {
|
|
115
|
+
const r = render(<DataList {...base} filters={[{ ...filters[0], value: null }]} onFilterChange={() => {}} onClearFilters={() => {}} />);
|
|
116
|
+
expect(maybeById(r, 'l-clear-filters')).toHaveLength(0);
|
|
117
|
+
});
|
|
118
|
+
it('sort control emits the chosen option', () => {
|
|
119
|
+
const log: string[] = [];
|
|
120
|
+
const r = render(<DataList {...base} sort={{ value: 'new', options: [{ value: 'new', label: 'Newest' }, { value: 'old', label: 'Oldest' }], onChange: (v) => log.push(v) }} />);
|
|
121
|
+
expect(allText(r)).toContain('Oldest');
|
|
122
|
+
press(hostById(r, 'l-sort-old'));
|
|
123
|
+
expect(log).toEqual(['old']);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('DataList paging and refresh', () => {
|
|
128
|
+
const list = (r: any) => r.root.findByType('FlatList' as any);
|
|
129
|
+
it('end reached fetches once per page length (no duplicate fetches)', () => {
|
|
130
|
+
let n = 0;
|
|
131
|
+
const r = render(<DataList {...base} hasMore onLoadMore={() => n++} />);
|
|
132
|
+
act(() => list(r).props.onEndReached());
|
|
133
|
+
act(() => list(r).props.onEndReached());
|
|
134
|
+
act(() => list(r).props.onEndReached());
|
|
135
|
+
expect(n).toBe(1);
|
|
136
|
+
r.update(<></>);
|
|
137
|
+
});
|
|
138
|
+
it('same instance: grows then fetches again; hasMore false / loadingMore block', () => {
|
|
139
|
+
let n = 0;
|
|
140
|
+
const wrap = (p: any) => <ThemeProvider><DataList {...base} onLoadMore={() => n++} {...p} /></ThemeProvider>;
|
|
141
|
+
const a = act;
|
|
142
|
+
let r: any;
|
|
143
|
+
a(() => { r = create(wrap({ hasMore: true })); });
|
|
144
|
+
const fl = () => r.root.findByType('FlatList');
|
|
145
|
+
a(() => fl().props.onEndReached());
|
|
146
|
+
a(() => r.update(wrap({ hasMore: true, loadingMore: true })));
|
|
147
|
+
a(() => fl().props.onEndReached());
|
|
148
|
+
a(() => r.update(wrap({ hasMore: true, data: [...rows, { id: '3', name: 'G' }] })));
|
|
149
|
+
a(() => fl().props.onEndReached());
|
|
150
|
+
a(() => r.update(wrap({ hasMore: false, data: [...rows, { id: '3', name: 'G' }, { id: '4', name: 'H' }] })));
|
|
151
|
+
a(() => fl().props.onEndReached());
|
|
152
|
+
expect(n).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
it('footer: spinner while loadingMore, retry row on paging error', () => {
|
|
155
|
+
const r = render(<DataList {...base} hasMore loadingMore />);
|
|
156
|
+
expect(maybeById(r, 'l-footer-loading')).toHaveLength(1);
|
|
157
|
+
let retry = 0;
|
|
158
|
+
const e = render(<DataList {...base} hasMore error="Page failed" onRetry={() => retry++} />);
|
|
159
|
+
expect(allText(e)).toContain('Page failed');
|
|
160
|
+
expect(maybeById(e, 'l-footer-loading')).toHaveLength(0);
|
|
161
|
+
press(hostById(e, 'l-footer-retry'));
|
|
162
|
+
expect(retry).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
it('pull to refresh wires refreshing/onRefresh', () => {
|
|
165
|
+
let n = 0;
|
|
166
|
+
const r = render(<DataList {...base} refreshing onRefresh={() => n++} />);
|
|
167
|
+
const rc = list(r).props.refreshControl;
|
|
168
|
+
expect(rc.props.refreshing).toBe(true);
|
|
169
|
+
rc.props.onRefresh();
|
|
170
|
+
expect(n).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('SectionedList', () => {
|
|
175
|
+
const sections = [{ title: 'Today', data: [{ id: '1', name: 'A' }] }, { title: 'Earlier', data: [{ id: '2', name: 'B' }] }];
|
|
176
|
+
it('renders section headers and rows; headers are accessibility headers', () => {
|
|
177
|
+
const r = render(<SectionedList testID="s" sections={sections} keyExtractor={(i: Row) => i.id} renderItem={({ item }: any) => <Text>{item.name}</Text>} />);
|
|
178
|
+
const t = allText(r);
|
|
179
|
+
expect(t).toContain('Today'); expect(t).toContain('Earlier'); expect(t).toContain('A'); expect(t).toContain('B');
|
|
180
|
+
expect(r.root.findAll((n) => n.props.accessibilityRole === 'header').length).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
it('shares the empty/error states and paging guard', () => {
|
|
183
|
+
let n = 0;
|
|
184
|
+
const r = render(<SectionedList testID="s" sections={[]} emptyTitle="No groups" keyExtractor={(i: Row) => i.id} renderItem={() => null} hasMore onLoadMore={() => n++} />);
|
|
185
|
+
expect(allText(r)).toContain('No groups');
|
|
186
|
+
const l = r.root.findByType('SectionList' as any);
|
|
187
|
+
act(() => l.props.onEndReached()); act(() => l.props.onEndReached());
|
|
188
|
+
expect(n).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { resolveListState, createLoadMoreGuard, createDebouncer, summarizeFilters } from '../logic/listState';
|
|
3
|
+
|
|
4
|
+
describe('resolveListState', () => {
|
|
5
|
+
it('loading only when nothing is shown yet', () => {
|
|
6
|
+
expect(resolveListState({ itemCount: 0, loading: true })).toBe('loading');
|
|
7
|
+
expect(resolveListState({ itemCount: 5, loading: true })).toBe('content');
|
|
8
|
+
});
|
|
9
|
+
it('refreshing an empty list is not the skeleton', () => {
|
|
10
|
+
expect(resolveListState({ itemCount: 0, loading: true, refreshing: true })).toBe('empty');
|
|
11
|
+
});
|
|
12
|
+
it('error wins over empty but not over content', () => {
|
|
13
|
+
expect(resolveListState({ itemCount: 0, error: 'boom' })).toBe('error');
|
|
14
|
+
expect(resolveListState({ itemCount: 2, error: 'boom' })).toBe('content');
|
|
15
|
+
});
|
|
16
|
+
it('empty with an active query/filter is noResults', () => {
|
|
17
|
+
expect(resolveListState({ itemCount: 0, hasActiveQuery: true })).toBe('noResults');
|
|
18
|
+
expect(resolveListState({ itemCount: 0 })).toBe('empty');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('createLoadMoreGuard', () => {
|
|
23
|
+
const ok = { hasMore: true, loading: false, loadingMore: false, error: false };
|
|
24
|
+
it('allows one fetch per data length (no duplicate pages)', () => {
|
|
25
|
+
const g = createLoadMoreGuard();
|
|
26
|
+
expect(g.tryStart(ok, 20)).toBe(true);
|
|
27
|
+
expect(g.tryStart(ok, 20)).toBe(false);
|
|
28
|
+
expect(g.tryStart(ok, 20)).toBe(false);
|
|
29
|
+
expect(g.tryStart(ok, 40)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it('refuses when no more, loading, loadingMore or error', () => {
|
|
32
|
+
const g = createLoadMoreGuard();
|
|
33
|
+
expect(g.tryStart({ ...ok, hasMore: false }, 1)).toBe(false);
|
|
34
|
+
expect(g.tryStart({ ...ok, loading: true }, 2)).toBe(false);
|
|
35
|
+
expect(g.tryStart({ ...ok, loadingMore: true }, 3)).toBe(false);
|
|
36
|
+
expect(g.tryStart({ ...ok, error: true }, 4)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('refused attempts do not burn the token', () => {
|
|
39
|
+
const g = createLoadMoreGuard();
|
|
40
|
+
g.tryStart({ ...ok, loadingMore: true }, 20);
|
|
41
|
+
expect(g.tryStart(ok, 20)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
it('reset allows retrying the same length (refresh / retry / new query)', () => {
|
|
44
|
+
const g = createLoadMoreGuard();
|
|
45
|
+
g.tryStart(ok, 20);
|
|
46
|
+
g.reset();
|
|
47
|
+
expect(g.tryStart(ok, 20)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('createDebouncer', () => {
|
|
52
|
+
beforeEach(() => vi.useFakeTimers());
|
|
53
|
+
afterEach(() => vi.useRealTimers());
|
|
54
|
+
it('fires once with the last value after the quiet period', () => {
|
|
55
|
+
const fn = vi.fn();
|
|
56
|
+
const d = createDebouncer(fn, 300);
|
|
57
|
+
d.call('a'); vi.advanceTimersByTime(200);
|
|
58
|
+
d.call('ab'); vi.advanceTimersByTime(299);
|
|
59
|
+
expect(fn).not.toHaveBeenCalled();
|
|
60
|
+
vi.advanceTimersByTime(1);
|
|
61
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(fn).toHaveBeenCalledWith('ab');
|
|
63
|
+
});
|
|
64
|
+
it('flush fires immediately, cancel drops', () => {
|
|
65
|
+
const fn = vi.fn();
|
|
66
|
+
const d = createDebouncer(fn, 300);
|
|
67
|
+
d.call('x'); d.flush();
|
|
68
|
+
expect(fn).toHaveBeenCalledWith('x');
|
|
69
|
+
d.call('y'); d.cancel(); vi.advanceTimersByTime(1000);
|
|
70
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('summarizeFilters', () => {
|
|
75
|
+
const groups = [
|
|
76
|
+
{ key: 'project', label: 'Project', options: [{ value: 'p1', label: 'Alpha' }], value: 'p1' },
|
|
77
|
+
{ key: 'status', label: 'Status', options: [{ value: 'open', label: 'Open' }], value: null },
|
|
78
|
+
];
|
|
79
|
+
it('lists only active groups with option labels', () => {
|
|
80
|
+
expect(summarizeFilters(groups)).toEqual({ count: 1, labels: ['Project: Alpha'] });
|
|
81
|
+
});
|
|
82
|
+
it('counts a search query', () => {
|
|
83
|
+
expect(summarizeFilters(groups, 'milk')).toEqual({ count: 2, labels: ['Project: Alpha', '"milk"'] });
|
|
84
|
+
});
|
|
85
|
+
it('empty when nothing active', () => {
|
|
86
|
+
expect(summarizeFilters([groups[1]], ' ')).toEqual({ count: 0, labels: [] });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseMarkdown, parseInline } from '../logic/markdown';
|
|
3
|
+
|
|
4
|
+
describe('parseInline', () => {
|
|
5
|
+
it('plain text', () => expect(parseInline('hi')).toEqual([{ type: 'text', text: 'hi' }]));
|
|
6
|
+
it('bold, italic, code', () => {
|
|
7
|
+
expect(parseInline('a **b** *c* `d`')).toEqual([
|
|
8
|
+
{ type: 'text', text: 'a ' },
|
|
9
|
+
{ type: 'bold', children: [{ type: 'text', text: 'b' }] },
|
|
10
|
+
{ type: 'text', text: ' ' },
|
|
11
|
+
{ type: 'italic', children: [{ type: 'text', text: 'c' }] },
|
|
12
|
+
{ type: 'text', text: ' ' },
|
|
13
|
+
{ type: 'code', text: 'd' },
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
it('links keep http(s)/mailto/tel only', () => {
|
|
17
|
+
expect(parseInline('[x](https://a.com)')).toEqual([{ type: 'link', href: 'https://a.com', children: [{ type: 'text', text: 'x' }] }]);
|
|
18
|
+
expect(parseInline('[x](javascript:alert(1))')).toEqual([{ type: 'text', text: 'x' }]);
|
|
19
|
+
});
|
|
20
|
+
it('unclosed markers stay literal', () => expect(parseInline('a **b')).toEqual([{ type: 'text', text: 'a **b' }]));
|
|
21
|
+
it('bold with nested italic', () => {
|
|
22
|
+
expect(parseInline('**a *b***')).toEqual([{ type: 'bold', children: [{ type: 'text', text: 'a ' }, { type: 'italic', children: [{ type: 'text', text: 'b' }] }] }]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('parseMarkdown', () => {
|
|
27
|
+
it('headings, paragraphs, bullets, numbered, quote, rule, code', () => {
|
|
28
|
+
const src = ['# T', '', 'para one', 'still one', '', '- a', '* b **x**', '', '1. one', '2. two', '', '> q', '', '---', '', '```', 'code **raw**', '```'].join('\n');
|
|
29
|
+
const b = parseMarkdown(src);
|
|
30
|
+
expect(b.map((x) => x.type)).toEqual(['heading', 'paragraph', 'bullets', 'numbered', 'quote', 'rule', 'code']);
|
|
31
|
+
expect(b[0]).toMatchObject({ type: 'heading', level: 1 });
|
|
32
|
+
expect(b[1]).toEqual({ type: 'paragraph', inlines: [{ type: 'text', text: 'para one\nstill one' }] });
|
|
33
|
+
expect((b[2] as any).items).toHaveLength(2);
|
|
34
|
+
expect(b[6]).toEqual({ type: 'code', text: 'code **raw**' });
|
|
35
|
+
});
|
|
36
|
+
it('heading levels clamp to 3', () => expect(parseMarkdown('#### deep')[0]).toMatchObject({ type: 'heading', level: 3 }));
|
|
37
|
+
it('empty / whitespace gives no blocks', () => expect(parseMarkdown(' \n\n')).toEqual([]));
|
|
38
|
+
it('unterminated code fence consumes the rest', () => expect(parseMarkdown('```\na\nb')).toEqual([{ type: 'code', text: 'a\nb' }]));
|
|
39
|
+
});
|