@react-spa-scaffold/mcp 0.3.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.
Files changed (173) hide show
  1. package/README.md +423 -0
  2. package/dist/features/index.d.ts +5 -0
  3. package/dist/features/index.d.ts.map +1 -0
  4. package/dist/features/index.js +3 -0
  5. package/dist/features/index.js.map +1 -0
  6. package/dist/features/registry.d.ts +10 -0
  7. package/dist/features/registry.d.ts.map +1 -0
  8. package/dist/features/registry.js +508 -0
  9. package/dist/features/registry.js.map +1 -0
  10. package/dist/features/types.d.ts +45 -0
  11. package/dist/features/types.d.ts.map +1 -0
  12. package/dist/features/types.js +5 -0
  13. package/dist/features/types.js.map +1 -0
  14. package/dist/features/versions.d.ts +16 -0
  15. package/dist/features/versions.d.ts.map +1 -0
  16. package/dist/features/versions.js +46 -0
  17. package/dist/features/versions.js.map +1 -0
  18. package/dist/features/versions.json +5 -0
  19. package/dist/index.d.ts +22 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +43 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/resources/docs.d.ts +29 -0
  24. package/dist/resources/docs.d.ts.map +1 -0
  25. package/dist/resources/docs.js +105 -0
  26. package/dist/resources/docs.js.map +1 -0
  27. package/dist/resources/index.d.ts +2 -0
  28. package/dist/resources/index.d.ts.map +1 -0
  29. package/dist/resources/index.js +2 -0
  30. package/dist/resources/index.js.map +1 -0
  31. package/dist/server.d.ts +12 -0
  32. package/dist/server.d.ts.map +1 -0
  33. package/dist/server.js +115 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/tools/get-example.d.ts +51 -0
  36. package/dist/tools/get-example.d.ts.map +1 -0
  37. package/dist/tools/get-example.js +90 -0
  38. package/dist/tools/get-example.js.map +1 -0
  39. package/dist/tools/get-features.d.ts +30 -0
  40. package/dist/tools/get-features.d.ts.map +1 -0
  41. package/dist/tools/get-features.js +46 -0
  42. package/dist/tools/get-features.js.map +1 -0
  43. package/dist/tools/get-scaffold.d.ts +77 -0
  44. package/dist/tools/get-scaffold.d.ts.map +1 -0
  45. package/dist/tools/get-scaffold.js +153 -0
  46. package/dist/tools/get-scaffold.js.map +1 -0
  47. package/dist/tools/index.d.ts +4 -0
  48. package/dist/tools/index.d.ts.map +1 -0
  49. package/dist/tools/index.js +4 -0
  50. package/dist/tools/index.js.map +1 -0
  51. package/dist/utils/docs.d.ts +14 -0
  52. package/dist/utils/docs.d.ts.map +1 -0
  53. package/dist/utils/docs.js +64 -0
  54. package/dist/utils/docs.js.map +1 -0
  55. package/dist/utils/examples.d.ts +27 -0
  56. package/dist/utils/examples.d.ts.map +1 -0
  57. package/dist/utils/examples.js +399 -0
  58. package/dist/utils/examples.js.map +1 -0
  59. package/dist/utils/index.d.ts +5 -0
  60. package/dist/utils/index.d.ts.map +1 -0
  61. package/dist/utils/index.js +5 -0
  62. package/dist/utils/index.js.map +1 -0
  63. package/dist/utils/paths.d.ts +28 -0
  64. package/dist/utils/paths.d.ts.map +1 -0
  65. package/dist/utils/paths.js +40 -0
  66. package/dist/utils/paths.js.map +1 -0
  67. package/dist/utils/scaffold.d.ts +50 -0
  68. package/dist/utils/scaffold.d.ts.map +1 -0
  69. package/dist/utils/scaffold.js +500 -0
  70. package/dist/utils/scaffold.js.map +1 -0
  71. package/dist/version.d.ts +5 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +19 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +63 -0
  76. package/templates/.bundled +0 -0
  77. package/templates/CLAUDE.md +145 -0
  78. package/templates/docs/API_REFERENCE.md +58 -0
  79. package/templates/docs/ARCHITECTURE.md +185 -0
  80. package/templates/docs/CODING_STANDARDS.md +53 -0
  81. package/templates/docs/COMPONENT_GUIDELINES.md +301 -0
  82. package/templates/docs/E2E_TESTING.md +116 -0
  83. package/templates/docs/INTERNATIONALIZATION.md +67 -0
  84. package/templates/docs/TESTING.md +259 -0
  85. package/templates/docs/WORKFLOW.md +170 -0
  86. package/templates/src/App.tsx +42 -0
  87. package/templates/src/components/layout/Header.tsx +19 -0
  88. package/templates/src/components/layout/index.ts +1 -0
  89. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +104 -0
  90. package/templates/src/components/shared/ErrorBoundary/index.ts +1 -0
  91. package/templates/src/components/shared/LanguageSwitcher/LanguageSwitcher.tsx +45 -0
  92. package/templates/src/components/shared/LanguageSwitcher/index.ts +1 -0
  93. package/templates/src/components/shared/SEO/SEO.tsx +55 -0
  94. package/templates/src/components/shared/SEO/index.ts +1 -0
  95. package/templates/src/components/shared/ThemeToggle/ThemeToggle.tsx +41 -0
  96. package/templates/src/components/shared/ThemeToggle/index.ts +1 -0
  97. package/templates/src/components/shared/index.ts +4 -0
  98. package/templates/src/components/ui/button.tsx +48 -0
  99. package/templates/src/components/ui/dropdown-menu.tsx +228 -0
  100. package/templates/src/components/ui/form-error.tsx +95 -0
  101. package/templates/src/components/ui/loading.tsx +58 -0
  102. package/templates/src/components/ui/skeleton.tsx +52 -0
  103. package/templates/src/components/ui/sonner.tsx +34 -0
  104. package/templates/src/components/ui/spinner.tsx +40 -0
  105. package/templates/src/components/ui/visually-hidden.tsx +51 -0
  106. package/templates/src/contexts/mobileContext.tsx +66 -0
  107. package/templates/src/contexts/queryContext.tsx +28 -0
  108. package/templates/src/hooks/index.ts +7 -0
  109. package/templates/src/hooks/useContactForm.ts +33 -0
  110. package/templates/src/hooks/useExampleQuery.ts +20 -0
  111. package/templates/src/hooks/useLanguage.ts +23 -0
  112. package/templates/src/hooks/useMediaQuery.ts +53 -0
  113. package/templates/src/hooks/useThemeEffect.ts +31 -0
  114. package/templates/src/hooks/useTouchSizes.ts +16 -0
  115. package/templates/src/i18n/config.ts +11 -0
  116. package/templates/src/i18n/detectLanguage.ts +57 -0
  117. package/templates/src/i18n/index.ts +20 -0
  118. package/templates/src/i18n/loadCatalog.ts +30 -0
  119. package/templates/src/index.css +98 -0
  120. package/templates/src/lib/api.ts +142 -0
  121. package/templates/src/lib/config.ts +15 -0
  122. package/templates/src/lib/constants.ts +8 -0
  123. package/templates/src/lib/env.ts +53 -0
  124. package/templates/src/lib/format.ts +119 -0
  125. package/templates/src/lib/index.ts +24 -0
  126. package/templates/src/lib/routes.ts +11 -0
  127. package/templates/src/lib/storage.ts +91 -0
  128. package/templates/src/lib/storageKeys.ts +10 -0
  129. package/templates/src/lib/utils.ts +6 -0
  130. package/templates/src/lib/validations.ts +39 -0
  131. package/templates/src/locales/de.po +65 -0
  132. package/templates/src/locales/en.po +65 -0
  133. package/templates/src/locales/es.po +65 -0
  134. package/templates/src/main.tsx +107 -0
  135. package/templates/src/mocks/fixtures/index.ts +1 -0
  136. package/templates/src/mocks/fixtures/todos.ts +40 -0
  137. package/templates/src/mocks/handlers/index.ts +7 -0
  138. package/templates/src/mocks/handlers/todos.ts +59 -0
  139. package/templates/src/mocks/index.ts +3 -0
  140. package/templates/src/mocks/node.ts +9 -0
  141. package/templates/src/pages/Home.tsx +27 -0
  142. package/templates/src/pages/NotFound.tsx +28 -0
  143. package/templates/src/pages/index.ts +2 -0
  144. package/templates/src/stores/index.ts +2 -0
  145. package/templates/src/stores/preferencesStore.ts +85 -0
  146. package/templates/src/test/index.ts +8 -0
  147. package/templates/src/test/mocks.ts +17 -0
  148. package/templates/src/test/providers.tsx +54 -0
  149. package/templates/src/test-setup.ts +54 -0
  150. package/templates/src/types/api.ts +31 -0
  151. package/templates/src/types/index.ts +2 -0
  152. package/templates/src/types/preferences.ts +5 -0
  153. package/templates/src/vite-env.d.ts +10 -0
  154. package/templates/tests/unit/components/ErrorBoundary.test.tsx +193 -0
  155. package/templates/tests/unit/components/Header.test.tsx +33 -0
  156. package/templates/tests/unit/components/LanguageSwitcher.test.tsx +40 -0
  157. package/templates/tests/unit/components/Loading.test.tsx +76 -0
  158. package/templates/tests/unit/components/SEO.test.tsx +80 -0
  159. package/templates/tests/unit/components/ThemeToggle.test.tsx +62 -0
  160. package/templates/tests/unit/contexts/mobileContext.test.tsx +54 -0
  161. package/templates/tests/unit/hooks/useContactForm.test.ts +60 -0
  162. package/templates/tests/unit/hooks/useExampleQuery.test.tsx +94 -0
  163. package/templates/tests/unit/hooks/useLanguage.test.tsx +75 -0
  164. package/templates/tests/unit/hooks/useMediaQuery.test.ts +57 -0
  165. package/templates/tests/unit/hooks/useThemeEffect.test.ts +42 -0
  166. package/templates/tests/unit/i18n/detectLanguage.test.ts +40 -0
  167. package/templates/tests/unit/i18n/loadCatalog.test.ts +70 -0
  168. package/templates/tests/unit/lib/api.test.ts +142 -0
  169. package/templates/tests/unit/lib/format.test.ts +100 -0
  170. package/templates/tests/unit/lib/storage.test.ts +90 -0
  171. package/templates/tests/unit/lib/utils.test.ts +19 -0
  172. package/templates/tests/unit/lib/validations.test.ts +56 -0
  173. package/templates/tests/unit/stores/preferencesStore.test.ts +75 -0
@@ -0,0 +1,94 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import { http, HttpResponse } from 'msw';
4
+ import type { ReactNode } from 'react';
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { useExampleQuery } from '@/hooks/useExampleQuery';
8
+ import { createTestQueryClient, server } from '@/test';
9
+
10
+ // Create a wrapper with QueryClient for hook testing
11
+ function createWrapper() {
12
+ const queryClient = createTestQueryClient();
13
+
14
+ return function Wrapper({ children }: { children: ReactNode }) {
15
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
16
+ };
17
+ }
18
+
19
+ describe('useExampleQuery', () => {
20
+ it('fetches todos successfully', async () => {
21
+ const { result } = renderHook(() => useExampleQuery(), {
22
+ wrapper: createWrapper(),
23
+ });
24
+
25
+ // Initially loading
26
+ expect(result.current.isLoading).toBe(true);
27
+
28
+ // Wait for success
29
+ await waitFor(() => {
30
+ expect(result.current.isSuccess).toBe(true);
31
+ });
32
+
33
+ // Verify data
34
+ expect(result.current.data).toHaveLength(5);
35
+ expect(result.current.data?.[0]).toMatchObject({
36
+ id: expect.any(Number),
37
+ title: expect.any(String),
38
+ completed: expect.any(Boolean),
39
+ });
40
+ });
41
+
42
+ it('handles server error gracefully', async () => {
43
+ // Override handler for this specific test
44
+ server.use(
45
+ http.get('https://jsonplaceholder.typicode.com/todos', () => {
46
+ return new HttpResponse(null, { status: 500 });
47
+ }),
48
+ );
49
+
50
+ const { result } = renderHook(() => useExampleQuery(), {
51
+ wrapper: createWrapper(),
52
+ });
53
+
54
+ await waitFor(() => {
55
+ expect(result.current.isError).toBe(true);
56
+ });
57
+
58
+ expect(result.current.error).toBeDefined();
59
+ });
60
+
61
+ it('handles network error', async () => {
62
+ server.use(
63
+ http.get('https://jsonplaceholder.typicode.com/todos', () => {
64
+ return HttpResponse.error();
65
+ }),
66
+ );
67
+
68
+ const { result } = renderHook(() => useExampleQuery(), {
69
+ wrapper: createWrapper(),
70
+ });
71
+
72
+ await waitFor(() => {
73
+ expect(result.current.isError).toBe(true);
74
+ });
75
+ });
76
+
77
+ it('handles empty response', async () => {
78
+ server.use(
79
+ http.get('https://jsonplaceholder.typicode.com/todos', () => {
80
+ return HttpResponse.json([]);
81
+ }),
82
+ );
83
+
84
+ const { result } = renderHook(() => useExampleQuery(), {
85
+ wrapper: createWrapper(),
86
+ });
87
+
88
+ await waitFor(() => {
89
+ expect(result.current.isSuccess).toBe(true);
90
+ });
91
+
92
+ expect(result.current.data).toEqual([]);
93
+ });
94
+ });
@@ -0,0 +1,75 @@
1
+ import { i18n } from '@lingui/core';
2
+ import { I18nProvider } from '@lingui/react';
3
+ import { renderHook, waitFor } from '@testing-library/react';
4
+ import type { ReactNode } from 'react';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ // Mock the storage module
8
+ vi.mock('@/lib/storage', () => ({
9
+ setStorageItem: vi.fn(() => true),
10
+ }));
11
+
12
+ // Mock dynamicActivate
13
+ vi.mock('@/i18n/loadCatalog', () => ({
14
+ dynamicActivate: vi.fn(() => Promise.resolve()),
15
+ }));
16
+
17
+ import { useLanguage } from '@/hooks/useLanguage';
18
+ import { dynamicActivate } from '@/i18n/loadCatalog';
19
+ import { setStorageItem } from '@/lib/storage';
20
+
21
+ // Setup i18n for tests
22
+ i18n.loadAndActivate({ locale: 'en', messages: {} });
23
+
24
+ describe('useLanguage', () => {
25
+ const wrapper = ({ children }: { children: ReactNode }) => <I18nProvider i18n={i18n}>{children}</I18nProvider>;
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ it('returns current locale', () => {
32
+ const { result } = renderHook(() => useLanguage(), { wrapper });
33
+
34
+ expect(result.current.currentLocale).toBeDefined();
35
+ expect(typeof result.current.currentLocale).toBe('string');
36
+ });
37
+
38
+ it('returns supported locales', () => {
39
+ const { result } = renderHook(() => useLanguage(), { wrapper });
40
+
41
+ expect(result.current.supportedLocales).toBeDefined();
42
+ expect(Array.isArray(result.current.supportedLocales)).toBe(true);
43
+ expect(result.current.supportedLocales).toContain('en');
44
+ expect(result.current.supportedLocales).toContain('es');
45
+ expect(result.current.supportedLocales).toContain('de');
46
+ });
47
+
48
+ it('returns changeLanguage function', () => {
49
+ const { result } = renderHook(() => useLanguage(), { wrapper });
50
+
51
+ expect(typeof result.current.changeLanguage).toBe('function');
52
+ });
53
+
54
+ it('changeLanguage calls dynamicActivate and setStorageItem', async () => {
55
+ const { result } = renderHook(() => useLanguage(), { wrapper });
56
+
57
+ await result.current.changeLanguage('es');
58
+
59
+ await waitFor(() => {
60
+ expect(dynamicActivate).toHaveBeenCalledWith('es');
61
+ expect(setStorageItem).toHaveBeenCalledWith('myapp-locale', 'es');
62
+ });
63
+ });
64
+
65
+ it('changeLanguage handles different locales', async () => {
66
+ const { result } = renderHook(() => useLanguage(), { wrapper });
67
+
68
+ await result.current.changeLanguage('de');
69
+
70
+ await waitFor(() => {
71
+ expect(dynamicActivate).toHaveBeenCalledWith('de');
72
+ expect(setStorageItem).toHaveBeenCalledWith('myapp-locale', 'de');
73
+ });
74
+ });
75
+ });
@@ -0,0 +1,57 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { BREAKPOINTS, useIsDesktop, useIsMobile, useMediaQuery } from '@/hooks/useMediaQuery';
5
+ import { mockMatchMedia } from '@/test';
6
+
7
+ describe('useMediaQuery', () => {
8
+ beforeEach(() => {
9
+ window.matchMedia = mockMatchMedia(false);
10
+ });
11
+
12
+ it.each([
13
+ { matches: false, expected: false },
14
+ { matches: true, expected: true },
15
+ ])('returns $expected when query matches=$matches', ({ matches, expected }) => {
16
+ window.matchMedia = mockMatchMedia(matches);
17
+ const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
18
+ expect(result.current).toBe(expected);
19
+ });
20
+
21
+ it('updates when media query changes', () => {
22
+ let listener: ((e: MediaQueryListEvent) => void) | null = null;
23
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
24
+ matches: false,
25
+ media: query,
26
+ addEventListener: vi.fn((_: string, cb: (e: MediaQueryListEvent) => void) => {
27
+ listener = cb;
28
+ }),
29
+ removeEventListener: vi.fn(),
30
+ }));
31
+
32
+ const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
33
+ expect(result.current).toBe(false);
34
+
35
+ act(() => listener?.({ matches: true } as MediaQueryListEvent));
36
+ expect(result.current).toBe(true);
37
+ });
38
+ });
39
+
40
+ describe('BREAKPOINTS', () => {
41
+ it('has correct values', () => {
42
+ expect(BREAKPOINTS).toEqual({ sm: 640, md: 768, lg: 1024, xl: 1280 });
43
+ });
44
+ });
45
+
46
+ describe('useIsMobile / useIsDesktop', () => {
47
+ it.each([
48
+ { hook: useIsMobile, matches: false, expected: true, name: 'useIsMobile (mobile)' },
49
+ { hook: useIsMobile, matches: true, expected: false, name: 'useIsMobile (desktop)' },
50
+ { hook: useIsDesktop, matches: true, expected: true, name: 'useIsDesktop (desktop)' },
51
+ { hook: useIsDesktop, matches: false, expected: false, name: 'useIsDesktop (mobile)' },
52
+ ])('$name returns $expected', ({ hook, matches, expected }) => {
53
+ window.matchMedia = mockMatchMedia(matches);
54
+ const { result } = renderHook(() => hook());
55
+ expect(result.current).toBe(expected);
56
+ });
57
+ });
@@ -0,0 +1,42 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useThemeEffect } from '@/hooks/useThemeEffect';
5
+ import { usePreferencesStore } from '@/stores/preferencesStore';
6
+ import { mockMatchMedia } from '@/test';
7
+
8
+ describe('useThemeEffect', () => {
9
+ beforeEach(() => {
10
+ usePreferencesStore.setState({ theme: 'light' });
11
+ document.documentElement.classList.remove('dark');
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it.each([
19
+ { theme: 'light', systemDark: false, expectDark: false },
20
+ { theme: 'dark', systemDark: false, expectDark: true },
21
+ { theme: 'system', systemDark: true, expectDark: true },
22
+ { theme: 'system', systemDark: false, expectDark: false },
23
+ ] as const)('applies dark=$expectDark for theme=$theme (system=$systemDark)', ({ theme, systemDark, expectDark }) => {
24
+ usePreferencesStore.setState({ theme });
25
+ window.matchMedia = mockMatchMedia(systemDark);
26
+
27
+ renderHook(() => useThemeEffect());
28
+
29
+ expect(document.documentElement.classList.contains('dark')).toBe(expectDark);
30
+ });
31
+
32
+ it('updates theme when store changes', () => {
33
+ window.matchMedia = mockMatchMedia(false);
34
+ renderHook(() => useThemeEffect());
35
+
36
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
37
+
38
+ act(() => usePreferencesStore.setState({ theme: 'dark' }));
39
+
40
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
41
+ });
42
+ });
@@ -0,0 +1,40 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { detectLanguage } from '@/i18n/detectLanguage';
4
+ import { STORAGE_KEYS } from '@/lib/storageKeys';
5
+
6
+ describe('detectLanguage', () => {
7
+ beforeEach(() => {
8
+ localStorage.clear();
9
+ vi.stubGlobal('navigator', { languages: ['en-US', 'en'], language: 'en-US' });
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ localStorage.clear();
15
+ });
16
+
17
+ describe('from localStorage', () => {
18
+ it.each([
19
+ { stored: '"es"', expected: 'es', desc: 'JSON format' },
20
+ { stored: 'de', expected: 'de', desc: 'plain string' },
21
+ { stored: '{invalid', expected: 'en', desc: 'invalid JSON (falls back)' },
22
+ ])('returns $expected for $desc', ({ stored, expected }) => {
23
+ localStorage.setItem(STORAGE_KEYS.locale, stored);
24
+ expect(detectLanguage()).toBe(expected);
25
+ });
26
+ });
27
+
28
+ describe('from browser', () => {
29
+ it.each([
30
+ { languages: ['es-ES', 'es'], language: 'es-ES', expected: 'es' },
31
+ { languages: ['de-AT'], language: 'de-AT', expected: 'de' },
32
+ { languages: [], language: 'es', expected: 'es' },
33
+ { languages: ['fr-FR'], language: 'fr-FR', expected: 'en' },
34
+ { languages: ['zh-CN'], language: 'zh-CN', expected: 'en' },
35
+ ])('returns $expected for languages=$languages', ({ languages, language, expected }) => {
36
+ vi.stubGlobal('navigator', { languages, language });
37
+ expect(detectLanguage()).toBe(expected);
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,70 @@
1
+ import { i18n } from '@lingui/core';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { dynamicActivate } from '@/i18n/loadCatalog';
5
+
6
+ // Mock the i18n module
7
+ vi.mock('@lingui/core', () => ({
8
+ i18n: {
9
+ locale: '',
10
+ messages: {} as Record<string, object>,
11
+ activate: vi.fn(),
12
+ loadAndActivate: vi.fn(),
13
+ },
14
+ }));
15
+
16
+ describe('dynamicActivate', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ vi.spyOn(console, 'error').mockImplementation(() => {});
20
+ // Reset i18n state
21
+ (i18n as { locale: string }).locale = '';
22
+ (i18n as { messages: Record<string, object> }).messages = {};
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ it('does nothing if locale is already active with messages', async () => {
30
+ (i18n as { locale: string }).locale = 'en';
31
+ (i18n as { messages: Record<string, object> }).messages = { en: { hello: 'Hello' } };
32
+
33
+ await dynamicActivate('en');
34
+
35
+ expect(i18n.activate).not.toHaveBeenCalled();
36
+ expect(i18n.loadAndActivate).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it('activates locale if messages already loaded but different locale active', async () => {
40
+ (i18n as { locale: string }).locale = 'pl';
41
+ (i18n as { messages: Record<string, object> }).messages = { en: { hello: 'Hello' } };
42
+
43
+ await dynamicActivate('en');
44
+
45
+ expect(i18n.activate).toHaveBeenCalledWith('en');
46
+ expect(i18n.loadAndActivate).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it('activates default locale if already loaded during fallback', async () => {
50
+ (i18n as { locale: string }).locale = '';
51
+ (i18n as { messages: Record<string, object> }).messages = { en: { hello: 'Hello' } };
52
+
53
+ // Try to load non-default locale (will fail in test env)
54
+ await dynamicActivate('de');
55
+
56
+ // Should activate the default locale since it's already loaded
57
+ expect(i18n.activate).toHaveBeenCalledWith('en');
58
+ });
59
+
60
+ it('logs error when locale fails to load', async () => {
61
+ (i18n as { locale: string }).locale = '';
62
+ (i18n as { messages: Record<string, object> }).messages = {};
63
+
64
+ // Try to load a locale (will fail in test env due to dynamic import)
65
+ await dynamicActivate('de');
66
+
67
+ // Should have logged an error about failing to load
68
+ expect(console.error).toHaveBeenCalled();
69
+ });
70
+ });
@@ -0,0 +1,142 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { api, ApiClientError } from '@/lib/api';
4
+
5
+ describe('api client', () => {
6
+ const mockFetch = vi.fn();
7
+ const originalFetch = global.fetch;
8
+
9
+ beforeEach(() => {
10
+ global.fetch = mockFetch;
11
+ vi.clearAllMocks();
12
+ });
13
+
14
+ afterEach(() => {
15
+ global.fetch = originalFetch;
16
+ });
17
+
18
+ describe('HTTP methods', () => {
19
+ it.each([
20
+ { method: 'get', httpMethod: 'GET' },
21
+ { method: 'post', httpMethod: 'POST' },
22
+ { method: 'put', httpMethod: 'PUT' },
23
+ { method: 'patch', httpMethod: 'PATCH' },
24
+ { method: 'delete', httpMethod: 'DELETE' },
25
+ ] as const)('$method makes $httpMethod request', async ({ method, httpMethod }) => {
26
+ mockFetch.mockResolvedValueOnce({
27
+ ok: true,
28
+ status: 200,
29
+ json: () => Promise.resolve({ id: 1 }),
30
+ });
31
+
32
+ await api[method]('/test');
33
+
34
+ expect(mockFetch).toHaveBeenCalledWith(
35
+ expect.stringContaining('/test'),
36
+ expect.objectContaining({ method: httpMethod }),
37
+ );
38
+ });
39
+
40
+ it('sends request body for POST/PUT/PATCH', async () => {
41
+ const body = { name: 'Test' };
42
+ mockFetch.mockResolvedValueOnce({
43
+ ok: true,
44
+ status: 200,
45
+ json: () => Promise.resolve({ id: 1 }),
46
+ });
47
+
48
+ await api.post('/test', body);
49
+
50
+ expect(mockFetch).toHaveBeenCalledWith(
51
+ expect.anything(),
52
+ expect.objectContaining({ body: JSON.stringify(body) }),
53
+ );
54
+ });
55
+
56
+ it('handles full URL without prepending base URL', async () => {
57
+ mockFetch.mockResolvedValueOnce({
58
+ ok: true,
59
+ status: 200,
60
+ json: () => Promise.resolve({}),
61
+ });
62
+
63
+ await api.get('https://external.api/data');
64
+
65
+ expect(mockFetch).toHaveBeenCalledWith('https://external.api/data', expect.anything());
66
+ });
67
+ });
68
+
69
+ describe('error handling', () => {
70
+ it.each([
71
+ { status: 404, message: 'Resource not found', hasJson: true },
72
+ { status: 500, message: 'Internal Server Error', hasJson: false },
73
+ ])('handles $status error response', async ({ status, message, hasJson }) => {
74
+ mockFetch.mockResolvedValueOnce({
75
+ ok: false,
76
+ status,
77
+ statusText: message,
78
+ json: hasJson ? () => Promise.resolve({ message }) : () => Promise.reject(new Error('No JSON')),
79
+ });
80
+
81
+ const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
82
+
83
+ expect(error).toBeInstanceOf(ApiClientError);
84
+ expect(error.message).toBe(message);
85
+ expect(error.status).toBe(status);
86
+ });
87
+
88
+ it('handles timeout with TIMEOUT code', async () => {
89
+ vi.useFakeTimers();
90
+ mockFetch.mockImplementationOnce(
91
+ () =>
92
+ new Promise((_, reject) => {
93
+ const error = new Error('Aborted');
94
+ error.name = 'AbortError';
95
+ setTimeout(() => reject(error), 100);
96
+ }),
97
+ );
98
+
99
+ const promise = api.get('/slow', { timeout: 50 });
100
+ vi.advanceTimersByTime(100);
101
+
102
+ const error = (await promise.catch((e) => e)) as ApiClientError;
103
+ expect(error.code).toBe('TIMEOUT');
104
+ expect(error.status).toBe(408);
105
+
106
+ vi.useRealTimers();
107
+ });
108
+
109
+ it.each([
110
+ { rejection: new Error('Network failure'), code: 'NETWORK_ERROR' },
111
+ { rejection: 'string error', code: 'UNKNOWN' },
112
+ ])('handles $code errors', async ({ rejection, code }) => {
113
+ mockFetch.mockRejectedValueOnce(rejection);
114
+
115
+ const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
116
+
117
+ expect(error).toBeInstanceOf(ApiClientError);
118
+ expect(error.code).toBe(code);
119
+ });
120
+
121
+ it('returns undefined for 204 No Content', async () => {
122
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 204 });
123
+
124
+ const result = await api.delete('/test/1');
125
+
126
+ expect(result).toBeUndefined();
127
+ });
128
+ });
129
+
130
+ describe('ApiClientError', () => {
131
+ it('has correct properties', () => {
132
+ const error = new ApiClientError('Test error', 400, 'TEST_CODE');
133
+
134
+ expect(error).toMatchObject({
135
+ message: 'Test error',
136
+ status: 400,
137
+ code: 'TEST_CODE',
138
+ name: 'ApiClientError',
139
+ });
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ formatBytes,
5
+ formatCurrency,
6
+ formatDate,
7
+ formatDateTime,
8
+ formatNumber,
9
+ formatPercent,
10
+ formatRelativeTime,
11
+ } from '@/lib/format';
12
+
13
+ describe('formatDate', () => {
14
+ it('formats date objects and strings', () => {
15
+ const date = new Date('2024-01-15T12:00:00Z');
16
+ expect(formatDate(date, {}, 'en-US')).toContain('2024');
17
+ expect(formatDate('2024-06-20', {}, 'en-US')).toContain('2024');
18
+ });
19
+
20
+ it('returns "Invalid date" for invalid input', () => {
21
+ expect(formatDate('invalid')).toBe('Invalid date');
22
+ });
23
+
24
+ it('respects custom options', () => {
25
+ const date = new Date('2024-01-15');
26
+ expect(formatDate(date, { weekday: 'long' }, 'en-US')).toContain('Monday');
27
+ });
28
+ });
29
+
30
+ describe('formatDateTime', () => {
31
+ it('includes time in output', () => {
32
+ const date = new Date('2024-01-15T14:30:00Z');
33
+ expect(formatDateTime(date, {}, 'en-US')).toMatch(/\d{1,2}:\d{2}/);
34
+ });
35
+ });
36
+
37
+ describe('formatRelativeTime', () => {
38
+ it.each([
39
+ { offset: -30 * 1000, unit: 'seconds', pattern: /second|now/i },
40
+ { offset: -5 * 60 * 1000, unit: 'minutes', pattern: /minute/i },
41
+ { offset: -60 * 60 * 1000, unit: 'hours', pattern: /hour|ago/i },
42
+ { offset: 24 * 60 * 60 * 1000, unit: 'days (future)', pattern: /day|tomorrow/i },
43
+ { offset: -45 * 24 * 60 * 60 * 1000, unit: 'months', pattern: /month/i },
44
+ { offset: -400 * 24 * 60 * 60 * 1000, unit: 'years', pattern: /year/i },
45
+ ])('formats $unit correctly', ({ offset, pattern }) => {
46
+ const date = new Date(Date.now() + offset);
47
+ expect(formatRelativeTime(date, 'en-US')).toMatch(pattern);
48
+ });
49
+
50
+ it('returns "Invalid date" for invalid input', () => {
51
+ expect(formatRelativeTime('invalid')).toBe('Invalid date');
52
+ });
53
+
54
+ it('accepts timestamp numbers', () => {
55
+ const timestamp = Date.now() - 60 * 60 * 1000;
56
+ expect(formatRelativeTime(timestamp, 'en-US')).toMatch(/hour|ago/i);
57
+ });
58
+ });
59
+
60
+ describe('formatNumber', () => {
61
+ it.each([
62
+ { value: 1234567.89, options: {}, expected: '1,234,567.89' },
63
+ { value: 1234.5, options: { minimumFractionDigits: 2 }, expected: '1,234.50' },
64
+ ])('formats $value with options', ({ value, options, expected }) => {
65
+ expect(formatNumber(value, options, 'en-US')).toBe(expected);
66
+ });
67
+ });
68
+
69
+ describe('formatCurrency', () => {
70
+ it.each([
71
+ { value: 99.99, currency: 'USD', locale: 'en-US', expected: '$99.99' },
72
+ { value: 99.99, currency: 'EUR', locale: 'de-DE', contains: '€' },
73
+ ])('formats $currency correctly', ({ value, currency, locale, expected, contains }) => {
74
+ const result = formatCurrency(value, currency, locale);
75
+ if (expected) expect(result).toBe(expected);
76
+ if (contains) expect(result).toContain(contains);
77
+ });
78
+ });
79
+
80
+ describe('formatPercent', () => {
81
+ it.each([
82
+ { value: 0.25, decimals: 0, expected: '25%' },
83
+ { value: 0.2567, decimals: 2, expected: '25.67%' },
84
+ ])('formats $value with $decimals decimals', ({ value, decimals, expected }) => {
85
+ expect(formatPercent(value, decimals, 'en-US')).toBe(expected);
86
+ });
87
+ });
88
+
89
+ describe('formatBytes', () => {
90
+ it.each([
91
+ { bytes: 0, expected: '0 Bytes' },
92
+ { bytes: 1024, expected: '1 KB' },
93
+ { bytes: 1024 * 1024, expected: '1 MB' },
94
+ { bytes: 1024 * 1024 * 1024, expected: '1 GB' },
95
+ { bytes: 1536, decimals: 1, expected: '1.5 KB' },
96
+ { bytes: 1536, decimals: 0, expected: '2 KB' },
97
+ ])('formats $bytes bytes', ({ bytes, decimals, expected }) => {
98
+ expect(formatBytes(bytes, decimals)).toBe(expected);
99
+ });
100
+ });