@croacroa/react-native-template 1.0.0 → 2.0.0
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/.github/workflows/ci.yml +187 -184
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/CHANGELOG.md +106 -106
- package/CONTRIBUTING.md +377 -377
- package/README.md +399 -399
- package/__tests__/components/snapshots.test.tsx +131 -0
- package/__tests__/integration/auth-api.test.tsx +227 -0
- package/__tests__/performance/VirtualizedList.perf.test.tsx +362 -0
- package/app/(public)/onboarding.tsx +5 -5
- package/app.config.ts +45 -2
- package/assets/images/.gitkeep +7 -7
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/SuspenseBoundary.tsx +357 -0
- package/components/providers/index.ts +13 -0
- package/components/ui/Avatar.tsx +316 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Select.tsx +240 -240
- package/components/ui/VirtualizedList.tsx +285 -0
- package/components/ui/index.ts +23 -18
- package/constants/config.ts +97 -54
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/hooks/index.ts +27 -25
- package/hooks/useApi.ts +102 -5
- package/hooks/useAuth.tsx +82 -0
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useMFA.ts +499 -0
- package/hooks/useNotifications.ts +39 -0
- package/hooks/useOffline.ts +32 -2
- package/hooks/usePerformance.ts +434 -434
- package/hooks/useTheme.tsx +76 -0
- package/hooks/useUpdates.ts +358 -358
- package/i18n/index.ts +194 -77
- package/i18n/locales/ar.json +101 -0
- package/i18n/locales/de.json +101 -0
- package/i18n/locales/en.json +101 -101
- package/i18n/locales/es.json +101 -0
- package/i18n/locales/fr.json +101 -101
- package/jest.config.js +4 -4
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -0
- package/maestro/flows/mfa-setup.yaml +86 -0
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -0
- package/maestro/flows/offline-sync.yaml +128 -0
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +175 -170
- package/services/analytics.ts +428 -428
- package/services/api.ts +340 -340
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +626 -0
- package/services/index.ts +54 -22
- package/services/security.ts +229 -0
- package/tailwind.config.js +47 -47
- package/utils/accessibility.ts +446 -446
- package/utils/index.ts +52 -43
- package/utils/withAccessibility.tsx +272 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Snapshot tests for UI components
|
|
3
|
+
* Ensures visual consistency of components across changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { render } from "@testing-library/react-native";
|
|
8
|
+
import { Text, View } from "react-native";
|
|
9
|
+
|
|
10
|
+
import { Button } from "@/components/ui/Button";
|
|
11
|
+
import { Card } from "@/components/ui/Card";
|
|
12
|
+
|
|
13
|
+
// Mock useTheme hook for components that use it
|
|
14
|
+
jest.mock("@/hooks/useTheme", () => ({
|
|
15
|
+
useTheme: () => ({
|
|
16
|
+
isDark: false,
|
|
17
|
+
mode: "light",
|
|
18
|
+
isLoaded: true,
|
|
19
|
+
setMode: jest.fn(),
|
|
20
|
+
toggleTheme: jest.fn(),
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe("UI Components Snapshots", () => {
|
|
25
|
+
describe("Button", () => {
|
|
26
|
+
it("renders primary variant correctly", () => {
|
|
27
|
+
const { toJSON } = render(<Button variant="primary">Primary</Button>);
|
|
28
|
+
expect(toJSON()).toMatchSnapshot();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders secondary variant correctly", () => {
|
|
32
|
+
const { toJSON } = render(<Button variant="secondary">Secondary</Button>);
|
|
33
|
+
expect(toJSON()).toMatchSnapshot();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders outline variant correctly", () => {
|
|
37
|
+
const { toJSON } = render(<Button variant="outline">Outline</Button>);
|
|
38
|
+
expect(toJSON()).toMatchSnapshot();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders ghost variant correctly", () => {
|
|
42
|
+
const { toJSON } = render(<Button variant="ghost">Ghost</Button>);
|
|
43
|
+
expect(toJSON()).toMatchSnapshot();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders danger variant correctly", () => {
|
|
47
|
+
const { toJSON } = render(<Button variant="danger">Danger</Button>);
|
|
48
|
+
expect(toJSON()).toMatchSnapshot();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renders small size correctly", () => {
|
|
52
|
+
const { toJSON } = render(<Button size="sm">Small</Button>);
|
|
53
|
+
expect(toJSON()).toMatchSnapshot();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders large size correctly", () => {
|
|
57
|
+
const { toJSON } = render(<Button size="lg">Large</Button>);
|
|
58
|
+
expect(toJSON()).toMatchSnapshot();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders loading state correctly", () => {
|
|
62
|
+
const { toJSON } = render(<Button isLoading>Loading</Button>);
|
|
63
|
+
expect(toJSON()).toMatchSnapshot();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders disabled state correctly", () => {
|
|
67
|
+
const { toJSON } = render(<Button disabled>Disabled</Button>);
|
|
68
|
+
expect(toJSON()).toMatchSnapshot();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renders with custom children correctly", () => {
|
|
72
|
+
const { toJSON } = render(
|
|
73
|
+
<Button>
|
|
74
|
+
<View>
|
|
75
|
+
<Text>Custom Content</Text>
|
|
76
|
+
</View>
|
|
77
|
+
</Button>
|
|
78
|
+
);
|
|
79
|
+
expect(toJSON()).toMatchSnapshot();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("Card", () => {
|
|
84
|
+
it("renders default variant correctly", () => {
|
|
85
|
+
const { toJSON } = render(
|
|
86
|
+
<Card>
|
|
87
|
+
<Text>Default Card</Text>
|
|
88
|
+
</Card>
|
|
89
|
+
);
|
|
90
|
+
expect(toJSON()).toMatchSnapshot();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders elevated variant correctly", () => {
|
|
94
|
+
const { toJSON } = render(
|
|
95
|
+
<Card variant="elevated">
|
|
96
|
+
<Text>Elevated Card</Text>
|
|
97
|
+
</Card>
|
|
98
|
+
);
|
|
99
|
+
expect(toJSON()).toMatchSnapshot();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders outlined variant correctly", () => {
|
|
103
|
+
const { toJSON } = render(
|
|
104
|
+
<Card variant="outlined">
|
|
105
|
+
<Text>Outlined Card</Text>
|
|
106
|
+
</Card>
|
|
107
|
+
);
|
|
108
|
+
expect(toJSON()).toMatchSnapshot();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("renders with custom className correctly", () => {
|
|
112
|
+
const { toJSON } = render(
|
|
113
|
+
<Card className="p-4 m-2">
|
|
114
|
+
<Text>Custom Styled Card</Text>
|
|
115
|
+
</Card>
|
|
116
|
+
);
|
|
117
|
+
expect(toJSON()).toMatchSnapshot();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("renders complex content correctly", () => {
|
|
121
|
+
const { toJSON } = render(
|
|
122
|
+
<Card variant="elevated" className="p-4">
|
|
123
|
+
<Text>Title</Text>
|
|
124
|
+
<Text>Description text here</Text>
|
|
125
|
+
<Button>Action</Button>
|
|
126
|
+
</Card>
|
|
127
|
+
);
|
|
128
|
+
expect(toJSON()).toMatchSnapshot();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Integration tests for authentication and API flow
|
|
3
|
+
* Tests the complete flow from sign-in through authenticated API calls.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { render, waitFor, act } from "@testing-library/react-native";
|
|
8
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
9
|
+
import { AuthProvider, useAuth, getAuthToken } from "@/hooks/useAuth";
|
|
10
|
+
import { api } from "@/services/api";
|
|
11
|
+
|
|
12
|
+
// Mock SecureStore
|
|
13
|
+
const mockSecureStore: Record<string, string> = {};
|
|
14
|
+
jest.mock("expo-secure-store", () => ({
|
|
15
|
+
getItemAsync: jest.fn((key: string) =>
|
|
16
|
+
Promise.resolve(mockSecureStore[key] || null)
|
|
17
|
+
),
|
|
18
|
+
setItemAsync: jest.fn((key: string, value: string) => {
|
|
19
|
+
mockSecureStore[key] = value;
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
}),
|
|
22
|
+
deleteItemAsync: jest.fn((key: string) => {
|
|
23
|
+
delete mockSecureStore[key];
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock expo-router
|
|
29
|
+
jest.mock("expo-router", () => ({
|
|
30
|
+
router: {
|
|
31
|
+
replace: jest.fn(),
|
|
32
|
+
push: jest.fn(),
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Mock toast
|
|
37
|
+
jest.mock("@/utils/toast", () => ({
|
|
38
|
+
toast: {
|
|
39
|
+
success: jest.fn(),
|
|
40
|
+
error: jest.fn(),
|
|
41
|
+
info: jest.fn(),
|
|
42
|
+
},
|
|
43
|
+
handleApiError: jest.fn(),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Test component that uses auth
|
|
47
|
+
function TestAuthComponent({
|
|
48
|
+
onAuthState,
|
|
49
|
+
}: {
|
|
50
|
+
onAuthState: (state: {
|
|
51
|
+
user: unknown;
|
|
52
|
+
isAuthenticated: boolean;
|
|
53
|
+
isLoading: boolean;
|
|
54
|
+
}) => void;
|
|
55
|
+
}) {
|
|
56
|
+
const auth = useAuth();
|
|
57
|
+
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
onAuthState({
|
|
60
|
+
user: auth.user,
|
|
61
|
+
isAuthenticated: auth.isAuthenticated,
|
|
62
|
+
isLoading: auth.isLoading,
|
|
63
|
+
});
|
|
64
|
+
}, [auth.user, auth.isAuthenticated, auth.isLoading, onAuthState]);
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Wrapper with providers
|
|
70
|
+
function createWrapper() {
|
|
71
|
+
const queryClient = new QueryClient({
|
|
72
|
+
defaultOptions: {
|
|
73
|
+
queries: {
|
|
74
|
+
retry: false,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
80
|
+
return (
|
|
81
|
+
<QueryClientProvider client={queryClient}>
|
|
82
|
+
<AuthProvider>{children}</AuthProvider>
|
|
83
|
+
</QueryClientProvider>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("Auth + API Integration", () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
// Clear mock storage
|
|
91
|
+
Object.keys(mockSecureStore).forEach((key) => delete mockSecureStore[key]);
|
|
92
|
+
jest.clearAllMocks();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("Authentication Flow", () => {
|
|
96
|
+
it("starts with loading state then transitions to unauthenticated", async () => {
|
|
97
|
+
const states: Array<{ isLoading: boolean; isAuthenticated: boolean }> =
|
|
98
|
+
[];
|
|
99
|
+
|
|
100
|
+
render(
|
|
101
|
+
<TestAuthComponent
|
|
102
|
+
onAuthState={(state) => states.push(state)}
|
|
103
|
+
/>,
|
|
104
|
+
{ wrapper: createWrapper() }
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(states.length).toBeGreaterThanOrEqual(2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// First state should be loading
|
|
112
|
+
expect(states[0].isLoading).toBe(true);
|
|
113
|
+
|
|
114
|
+
// Final state should be not loading and not authenticated
|
|
115
|
+
const finalState = states[states.length - 1];
|
|
116
|
+
expect(finalState.isLoading).toBe(false);
|
|
117
|
+
expect(finalState.isAuthenticated).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("restores session from stored tokens", async () => {
|
|
121
|
+
// Pre-populate storage with valid tokens
|
|
122
|
+
const tokens = {
|
|
123
|
+
accessToken: "stored_access_token",
|
|
124
|
+
refreshToken: "stored_refresh_token",
|
|
125
|
+
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
|
|
126
|
+
};
|
|
127
|
+
const user = {
|
|
128
|
+
id: "1",
|
|
129
|
+
email: "stored@example.com",
|
|
130
|
+
name: "Stored User",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
mockSecureStore["auth_tokens"] = JSON.stringify(tokens);
|
|
134
|
+
mockSecureStore["auth_user"] = JSON.stringify(user);
|
|
135
|
+
|
|
136
|
+
let finalState: { user: unknown; isAuthenticated: boolean } | null = null;
|
|
137
|
+
|
|
138
|
+
render(
|
|
139
|
+
<TestAuthComponent
|
|
140
|
+
onAuthState={(state) => {
|
|
141
|
+
if (!state.isLoading) {
|
|
142
|
+
finalState = state;
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
/>,
|
|
146
|
+
{ wrapper: createWrapper() }
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
expect(finalState).not.toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(finalState?.isAuthenticated).toBe(true);
|
|
154
|
+
expect(finalState?.user).toEqual(user);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles expired tokens by refreshing", async () => {
|
|
158
|
+
// Pre-populate storage with expired tokens
|
|
159
|
+
const tokens = {
|
|
160
|
+
accessToken: "expired_access_token",
|
|
161
|
+
refreshToken: "valid_refresh_token",
|
|
162
|
+
expiresAt: Date.now() - 1000, // Expired 1 second ago
|
|
163
|
+
};
|
|
164
|
+
const user = {
|
|
165
|
+
id: "1",
|
|
166
|
+
email: "test@example.com",
|
|
167
|
+
name: "Test User",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
mockSecureStore["auth_tokens"] = JSON.stringify(tokens);
|
|
171
|
+
mockSecureStore["auth_user"] = JSON.stringify(user);
|
|
172
|
+
|
|
173
|
+
let finalState: { isAuthenticated: boolean } | null = null;
|
|
174
|
+
|
|
175
|
+
render(
|
|
176
|
+
<TestAuthComponent
|
|
177
|
+
onAuthState={(state) => {
|
|
178
|
+
if (!state.isLoading) {
|
|
179
|
+
finalState = state;
|
|
180
|
+
}
|
|
181
|
+
}}
|
|
182
|
+
/>,
|
|
183
|
+
{ wrapper: createWrapper() }
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await waitFor(
|
|
187
|
+
() => {
|
|
188
|
+
expect(finalState).not.toBeNull();
|
|
189
|
+
},
|
|
190
|
+
{ timeout: 3000 }
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Should be authenticated after token refresh (mock implementation always succeeds)
|
|
194
|
+
expect(finalState?.isAuthenticated).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("Token Management", () => {
|
|
199
|
+
it("getAuthToken returns null when no tokens stored", async () => {
|
|
200
|
+
const token = await getAuthToken();
|
|
201
|
+
expect(token).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("getAuthToken returns access token when stored", async () => {
|
|
205
|
+
const tokens = {
|
|
206
|
+
accessToken: "test_access_token",
|
|
207
|
+
refreshToken: "test_refresh_token",
|
|
208
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
mockSecureStore["auth_tokens"] = JSON.stringify(tokens);
|
|
212
|
+
|
|
213
|
+
const token = await getAuthToken();
|
|
214
|
+
expect(token).toBe("test_access_token");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("API Client Integration", () => {
|
|
219
|
+
it("api client exists and has required methods", () => {
|
|
220
|
+
expect(api).toBeDefined();
|
|
221
|
+
expect(typeof api.get).toBe("function");
|
|
222
|
+
expect(typeof api.post).toBe("function");
|
|
223
|
+
expect(typeof api.patch).toBe("function");
|
|
224
|
+
expect(typeof api.delete).toBe("function");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Performance tests for VirtualizedList component
|
|
3
|
+
* Tests render times and memory usage with large datasets.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { render } from "@testing-library/react-native";
|
|
8
|
+
import { View, Text } from "react-native";
|
|
9
|
+
|
|
10
|
+
// Mock FlashList since it requires native modules
|
|
11
|
+
jest.mock("@shopify/flash-list", () => {
|
|
12
|
+
const { FlatList } = require("react-native");
|
|
13
|
+
return {
|
|
14
|
+
FlashList: FlatList,
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Mock useTheme
|
|
19
|
+
jest.mock("@/hooks/useTheme", () => ({
|
|
20
|
+
useTheme: () => ({
|
|
21
|
+
isDark: false,
|
|
22
|
+
mode: "light",
|
|
23
|
+
isLoaded: true,
|
|
24
|
+
setMode: jest.fn(),
|
|
25
|
+
toggleTheme: jest.fn(),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
import { VirtualizedList } from "@/components/ui/VirtualizedList";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate test data
|
|
33
|
+
*/
|
|
34
|
+
function generateTestData(count: number): Array<{ id: string; title: string; value: number }> {
|
|
35
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
36
|
+
id: `item-${index}`,
|
|
37
|
+
title: `Item ${index}`,
|
|
38
|
+
value: Math.random() * 1000,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Simple render item component
|
|
44
|
+
*/
|
|
45
|
+
function TestItem({ item }: { item: { id: string; title: string; value: number } }) {
|
|
46
|
+
return (
|
|
47
|
+
<View testID={`item-${item.id}`} style={{ height: 50, padding: 10 }}>
|
|
48
|
+
<Text>{item.title}</Text>
|
|
49
|
+
<Text>{item.value.toFixed(2)}</Text>
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Performance thresholds (in milliseconds)
|
|
56
|
+
*/
|
|
57
|
+
const PERFORMANCE_THRESHOLDS = {
|
|
58
|
+
SMALL_LIST_RENDER: 100, // 100 items
|
|
59
|
+
MEDIUM_LIST_RENDER: 200, // 1000 items
|
|
60
|
+
LARGE_LIST_RENDER: 500, // 10000 items
|
|
61
|
+
INITIAL_RENDER: 50, // Initial render without data
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe("VirtualizedList Performance", () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
jest.clearAllMocks();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("Render Time", () => {
|
|
70
|
+
it("renders empty list within threshold", () => {
|
|
71
|
+
const startTime = performance.now();
|
|
72
|
+
|
|
73
|
+
render(
|
|
74
|
+
<VirtualizedList
|
|
75
|
+
data={[]}
|
|
76
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
77
|
+
keyExtractor={(item) => item.id}
|
|
78
|
+
estimatedItemSize={50}
|
|
79
|
+
emptyMessage="No items"
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const renderTime = performance.now() - startTime;
|
|
84
|
+
|
|
85
|
+
expect(renderTime).toBeLessThan(PERFORMANCE_THRESHOLDS.INITIAL_RENDER);
|
|
86
|
+
console.log(`Empty list render time: ${renderTime.toFixed(2)}ms`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("renders small list (100 items) within threshold", () => {
|
|
90
|
+
const data = generateTestData(100);
|
|
91
|
+
const startTime = performance.now();
|
|
92
|
+
|
|
93
|
+
render(
|
|
94
|
+
<VirtualizedList
|
|
95
|
+
data={data}
|
|
96
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
97
|
+
keyExtractor={(item) => item.id}
|
|
98
|
+
estimatedItemSize={50}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const renderTime = performance.now() - startTime;
|
|
103
|
+
|
|
104
|
+
expect(renderTime).toBeLessThan(PERFORMANCE_THRESHOLDS.SMALL_LIST_RENDER);
|
|
105
|
+
console.log(`Small list (100 items) render time: ${renderTime.toFixed(2)}ms`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders medium list (1000 items) within threshold", () => {
|
|
109
|
+
const data = generateTestData(1000);
|
|
110
|
+
const startTime = performance.now();
|
|
111
|
+
|
|
112
|
+
render(
|
|
113
|
+
<VirtualizedList
|
|
114
|
+
data={data}
|
|
115
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
116
|
+
keyExtractor={(item) => item.id}
|
|
117
|
+
estimatedItemSize={50}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const renderTime = performance.now() - startTime;
|
|
122
|
+
|
|
123
|
+
expect(renderTime).toBeLessThan(PERFORMANCE_THRESHOLDS.MEDIUM_LIST_RENDER);
|
|
124
|
+
console.log(`Medium list (1000 items) render time: ${renderTime.toFixed(2)}ms`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("renders large list (10000 items) within threshold", () => {
|
|
128
|
+
const data = generateTestData(10000);
|
|
129
|
+
const startTime = performance.now();
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<VirtualizedList
|
|
133
|
+
data={data}
|
|
134
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
135
|
+
keyExtractor={(item) => item.id}
|
|
136
|
+
estimatedItemSize={50}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const renderTime = performance.now() - startTime;
|
|
141
|
+
|
|
142
|
+
expect(renderTime).toBeLessThan(PERFORMANCE_THRESHOLDS.LARGE_LIST_RENDER);
|
|
143
|
+
console.log(`Large list (10000 items) render time: ${renderTime.toFixed(2)}ms`);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("Re-render Performance", () => {
|
|
148
|
+
it("handles data updates efficiently", () => {
|
|
149
|
+
const initialData = generateTestData(500);
|
|
150
|
+
|
|
151
|
+
const { rerender } = render(
|
|
152
|
+
<VirtualizedList
|
|
153
|
+
data={initialData}
|
|
154
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
155
|
+
keyExtractor={(item) => item.id}
|
|
156
|
+
estimatedItemSize={50}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Measure re-render with new data
|
|
161
|
+
const newData = generateTestData(500);
|
|
162
|
+
const startTime = performance.now();
|
|
163
|
+
|
|
164
|
+
rerender(
|
|
165
|
+
<VirtualizedList
|
|
166
|
+
data={newData}
|
|
167
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
168
|
+
keyExtractor={(item) => item.id}
|
|
169
|
+
estimatedItemSize={50}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const rerenderTime = performance.now() - startTime;
|
|
174
|
+
|
|
175
|
+
// Re-render should be fast since only visible items are rendered
|
|
176
|
+
expect(rerenderTime).toBeLessThan(100);
|
|
177
|
+
console.log(`Re-render time (500 items): ${rerenderTime.toFixed(2)}ms`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("handles appending items efficiently", () => {
|
|
181
|
+
const initialData = generateTestData(100);
|
|
182
|
+
|
|
183
|
+
const { rerender } = render(
|
|
184
|
+
<VirtualizedList
|
|
185
|
+
data={initialData}
|
|
186
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
187
|
+
keyExtractor={(item) => item.id}
|
|
188
|
+
estimatedItemSize={50}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Append 100 more items
|
|
193
|
+
const appendedData = [...initialData, ...generateTestData(100).map((item, i) => ({
|
|
194
|
+
...item,
|
|
195
|
+
id: `appended-${i}`,
|
|
196
|
+
}))];
|
|
197
|
+
|
|
198
|
+
const startTime = performance.now();
|
|
199
|
+
|
|
200
|
+
rerender(
|
|
201
|
+
<VirtualizedList
|
|
202
|
+
data={appendedData}
|
|
203
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
204
|
+
keyExtractor={(item) => item.id}
|
|
205
|
+
estimatedItemSize={50}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const appendTime = performance.now() - startTime;
|
|
210
|
+
|
|
211
|
+
expect(appendTime).toBeLessThan(50);
|
|
212
|
+
console.log(`Append time (100 items): ${appendTime.toFixed(2)}ms`);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("Memory Efficiency", () => {
|
|
217
|
+
it("maintains stable render count with static data", () => {
|
|
218
|
+
let renderCount = 0;
|
|
219
|
+
|
|
220
|
+
const CountingItem = ({ item }: { item: { id: string; title: string; value: number } }) => {
|
|
221
|
+
renderCount++;
|
|
222
|
+
return <TestItem item={item} />;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const data = generateTestData(100);
|
|
226
|
+
|
|
227
|
+
const { rerender } = render(
|
|
228
|
+
<VirtualizedList
|
|
229
|
+
data={data}
|
|
230
|
+
renderItem={({ item }) => <CountingItem item={item} />}
|
|
231
|
+
keyExtractor={(item) => item.id}
|
|
232
|
+
estimatedItemSize={50}
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const initialRenderCount = renderCount;
|
|
237
|
+
|
|
238
|
+
// Re-render with same data
|
|
239
|
+
rerender(
|
|
240
|
+
<VirtualizedList
|
|
241
|
+
data={data}
|
|
242
|
+
renderItem={({ item }) => <CountingItem item={item} />}
|
|
243
|
+
keyExtractor={(item) => item.id}
|
|
244
|
+
estimatedItemSize={50}
|
|
245
|
+
/>
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// With proper memoization, render count should not double
|
|
249
|
+
// Allow some flexibility for visible items being re-rendered
|
|
250
|
+
expect(renderCount).toBeLessThan(initialRenderCount * 2);
|
|
251
|
+
console.log(`Initial renders: ${initialRenderCount}, After re-render: ${renderCount}`);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("Props Validation", () => {
|
|
256
|
+
it("handles all size datasets without crashing", () => {
|
|
257
|
+
const sizes = [0, 1, 10, 100, 1000, 5000];
|
|
258
|
+
|
|
259
|
+
for (const size of sizes) {
|
|
260
|
+
const data = generateTestData(size);
|
|
261
|
+
|
|
262
|
+
expect(() => {
|
|
263
|
+
render(
|
|
264
|
+
<VirtualizedList
|
|
265
|
+
data={data}
|
|
266
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
267
|
+
keyExtractor={(item) => item.id}
|
|
268
|
+
estimatedItemSize={50}
|
|
269
|
+
/>
|
|
270
|
+
);
|
|
271
|
+
}).not.toThrow();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("handles undefined/null items gracefully", () => {
|
|
276
|
+
const data = [
|
|
277
|
+
{ id: "1", title: "Item 1", value: 100 },
|
|
278
|
+
{ id: "2", title: "Item 2", value: 200 },
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
expect(() => {
|
|
282
|
+
render(
|
|
283
|
+
<VirtualizedList
|
|
284
|
+
data={data}
|
|
285
|
+
renderItem={({ item }) => (item ? <TestItem item={item} /> : null)}
|
|
286
|
+
keyExtractor={(item) => item?.id || "unknown"}
|
|
287
|
+
estimatedItemSize={50}
|
|
288
|
+
/>
|
|
289
|
+
);
|
|
290
|
+
}).not.toThrow();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("Performance Metrics Summary", () => {
|
|
296
|
+
it("logs performance summary", () => {
|
|
297
|
+
const metrics = {
|
|
298
|
+
emptyList: 0,
|
|
299
|
+
smallList: 0,
|
|
300
|
+
mediumList: 0,
|
|
301
|
+
largeList: 0,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Empty list
|
|
305
|
+
let start = performance.now();
|
|
306
|
+
render(
|
|
307
|
+
<VirtualizedList
|
|
308
|
+
data={[]}
|
|
309
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
310
|
+
keyExtractor={(item) => item.id}
|
|
311
|
+
estimatedItemSize={50}
|
|
312
|
+
/>
|
|
313
|
+
);
|
|
314
|
+
metrics.emptyList = performance.now() - start;
|
|
315
|
+
|
|
316
|
+
// Small list
|
|
317
|
+
start = performance.now();
|
|
318
|
+
render(
|
|
319
|
+
<VirtualizedList
|
|
320
|
+
data={generateTestData(100)}
|
|
321
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
322
|
+
keyExtractor={(item) => item.id}
|
|
323
|
+
estimatedItemSize={50}
|
|
324
|
+
/>
|
|
325
|
+
);
|
|
326
|
+
metrics.smallList = performance.now() - start;
|
|
327
|
+
|
|
328
|
+
// Medium list
|
|
329
|
+
start = performance.now();
|
|
330
|
+
render(
|
|
331
|
+
<VirtualizedList
|
|
332
|
+
data={generateTestData(1000)}
|
|
333
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
334
|
+
keyExtractor={(item) => item.id}
|
|
335
|
+
estimatedItemSize={50}
|
|
336
|
+
/>
|
|
337
|
+
);
|
|
338
|
+
metrics.mediumList = performance.now() - start;
|
|
339
|
+
|
|
340
|
+
// Large list
|
|
341
|
+
start = performance.now();
|
|
342
|
+
render(
|
|
343
|
+
<VirtualizedList
|
|
344
|
+
data={generateTestData(10000)}
|
|
345
|
+
renderItem={({ item }) => <TestItem item={item} />}
|
|
346
|
+
keyExtractor={(item) => item.id}
|
|
347
|
+
estimatedItemSize={50}
|
|
348
|
+
/>
|
|
349
|
+
);
|
|
350
|
+
metrics.largeList = performance.now() - start;
|
|
351
|
+
|
|
352
|
+
console.log("\n=== VirtualizedList Performance Summary ===");
|
|
353
|
+
console.log(`Empty list: ${metrics.emptyList.toFixed(2)}ms`);
|
|
354
|
+
console.log(`Small (100): ${metrics.smallList.toFixed(2)}ms`);
|
|
355
|
+
console.log(`Medium (1000): ${metrics.mediumList.toFixed(2)}ms`);
|
|
356
|
+
console.log(`Large (10000): ${metrics.largeList.toFixed(2)}ms`);
|
|
357
|
+
console.log("==========================================\n");
|
|
358
|
+
|
|
359
|
+
// This test always passes, it's just for logging
|
|
360
|
+
expect(true).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
});
|