@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.
- package/README.md +423 -0
- package/dist/features/index.d.ts +5 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +3 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/registry.d.ts +10 -0
- package/dist/features/registry.d.ts.map +1 -0
- package/dist/features/registry.js +508 -0
- package/dist/features/registry.js.map +1 -0
- package/dist/features/types.d.ts +45 -0
- package/dist/features/types.d.ts.map +1 -0
- package/dist/features/types.js +5 -0
- package/dist/features/types.js.map +1 -0
- package/dist/features/versions.d.ts +16 -0
- package/dist/features/versions.d.ts.map +1 -0
- package/dist/features/versions.js +46 -0
- package/dist/features/versions.js.map +1 -0
- package/dist/features/versions.json +5 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/docs.d.ts +29 -0
- package/dist/resources/docs.d.ts.map +1 -0
- package/dist/resources/docs.js +105 -0
- package/dist/resources/docs.js.map +1 -0
- package/dist/resources/index.d.ts +2 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +2 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +115 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/get-example.d.ts +51 -0
- package/dist/tools/get-example.d.ts.map +1 -0
- package/dist/tools/get-example.js +90 -0
- package/dist/tools/get-example.js.map +1 -0
- package/dist/tools/get-features.d.ts +30 -0
- package/dist/tools/get-features.d.ts.map +1 -0
- package/dist/tools/get-features.js +46 -0
- package/dist/tools/get-features.js.map +1 -0
- package/dist/tools/get-scaffold.d.ts +77 -0
- package/dist/tools/get-scaffold.d.ts.map +1 -0
- package/dist/tools/get-scaffold.js +153 -0
- package/dist/tools/get-scaffold.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/utils/docs.d.ts +14 -0
- package/dist/utils/docs.d.ts.map +1 -0
- package/dist/utils/docs.js +64 -0
- package/dist/utils/docs.js.map +1 -0
- package/dist/utils/examples.d.ts +27 -0
- package/dist/utils/examples.d.ts.map +1 -0
- package/dist/utils/examples.js +399 -0
- package/dist/utils/examples.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/paths.d.ts +28 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +40 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/scaffold.d.ts +50 -0
- package/dist/utils/scaffold.d.ts.map +1 -0
- package/dist/utils/scaffold.js +500 -0
- package/dist/utils/scaffold.js.map +1 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +63 -0
- package/templates/.bundled +0 -0
- package/templates/CLAUDE.md +145 -0
- package/templates/docs/API_REFERENCE.md +58 -0
- package/templates/docs/ARCHITECTURE.md +185 -0
- package/templates/docs/CODING_STANDARDS.md +53 -0
- package/templates/docs/COMPONENT_GUIDELINES.md +301 -0
- package/templates/docs/E2E_TESTING.md +116 -0
- package/templates/docs/INTERNATIONALIZATION.md +67 -0
- package/templates/docs/TESTING.md +259 -0
- package/templates/docs/WORKFLOW.md +170 -0
- package/templates/src/App.tsx +42 -0
- package/templates/src/components/layout/Header.tsx +19 -0
- package/templates/src/components/layout/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +104 -0
- package/templates/src/components/shared/ErrorBoundary/index.ts +1 -0
- package/templates/src/components/shared/LanguageSwitcher/LanguageSwitcher.tsx +45 -0
- package/templates/src/components/shared/LanguageSwitcher/index.ts +1 -0
- package/templates/src/components/shared/SEO/SEO.tsx +55 -0
- package/templates/src/components/shared/SEO/index.ts +1 -0
- package/templates/src/components/shared/ThemeToggle/ThemeToggle.tsx +41 -0
- package/templates/src/components/shared/ThemeToggle/index.ts +1 -0
- package/templates/src/components/shared/index.ts +4 -0
- package/templates/src/components/ui/button.tsx +48 -0
- package/templates/src/components/ui/dropdown-menu.tsx +228 -0
- package/templates/src/components/ui/form-error.tsx +95 -0
- package/templates/src/components/ui/loading.tsx +58 -0
- package/templates/src/components/ui/skeleton.tsx +52 -0
- package/templates/src/components/ui/sonner.tsx +34 -0
- package/templates/src/components/ui/spinner.tsx +40 -0
- package/templates/src/components/ui/visually-hidden.tsx +51 -0
- package/templates/src/contexts/mobileContext.tsx +66 -0
- package/templates/src/contexts/queryContext.tsx +28 -0
- package/templates/src/hooks/index.ts +7 -0
- package/templates/src/hooks/useContactForm.ts +33 -0
- package/templates/src/hooks/useExampleQuery.ts +20 -0
- package/templates/src/hooks/useLanguage.ts +23 -0
- package/templates/src/hooks/useMediaQuery.ts +53 -0
- package/templates/src/hooks/useThemeEffect.ts +31 -0
- package/templates/src/hooks/useTouchSizes.ts +16 -0
- package/templates/src/i18n/config.ts +11 -0
- package/templates/src/i18n/detectLanguage.ts +57 -0
- package/templates/src/i18n/index.ts +20 -0
- package/templates/src/i18n/loadCatalog.ts +30 -0
- package/templates/src/index.css +98 -0
- package/templates/src/lib/api.ts +142 -0
- package/templates/src/lib/config.ts +15 -0
- package/templates/src/lib/constants.ts +8 -0
- package/templates/src/lib/env.ts +53 -0
- package/templates/src/lib/format.ts +119 -0
- package/templates/src/lib/index.ts +24 -0
- package/templates/src/lib/routes.ts +11 -0
- package/templates/src/lib/storage.ts +91 -0
- package/templates/src/lib/storageKeys.ts +10 -0
- package/templates/src/lib/utils.ts +6 -0
- package/templates/src/lib/validations.ts +39 -0
- package/templates/src/locales/de.po +65 -0
- package/templates/src/locales/en.po +65 -0
- package/templates/src/locales/es.po +65 -0
- package/templates/src/main.tsx +107 -0
- package/templates/src/mocks/fixtures/index.ts +1 -0
- package/templates/src/mocks/fixtures/todos.ts +40 -0
- package/templates/src/mocks/handlers/index.ts +7 -0
- package/templates/src/mocks/handlers/todos.ts +59 -0
- package/templates/src/mocks/index.ts +3 -0
- package/templates/src/mocks/node.ts +9 -0
- package/templates/src/pages/Home.tsx +27 -0
- package/templates/src/pages/NotFound.tsx +28 -0
- package/templates/src/pages/index.ts +2 -0
- package/templates/src/stores/index.ts +2 -0
- package/templates/src/stores/preferencesStore.ts +85 -0
- package/templates/src/test/index.ts +8 -0
- package/templates/src/test/mocks.ts +17 -0
- package/templates/src/test/providers.tsx +54 -0
- package/templates/src/test-setup.ts +54 -0
- package/templates/src/types/api.ts +31 -0
- package/templates/src/types/index.ts +2 -0
- package/templates/src/types/preferences.ts +5 -0
- package/templates/src/vite-env.d.ts +10 -0
- package/templates/tests/unit/components/ErrorBoundary.test.tsx +193 -0
- package/templates/tests/unit/components/Header.test.tsx +33 -0
- package/templates/tests/unit/components/LanguageSwitcher.test.tsx +40 -0
- package/templates/tests/unit/components/Loading.test.tsx +76 -0
- package/templates/tests/unit/components/SEO.test.tsx +80 -0
- package/templates/tests/unit/components/ThemeToggle.test.tsx +62 -0
- package/templates/tests/unit/contexts/mobileContext.test.tsx +54 -0
- package/templates/tests/unit/hooks/useContactForm.test.ts +60 -0
- package/templates/tests/unit/hooks/useExampleQuery.test.tsx +94 -0
- package/templates/tests/unit/hooks/useLanguage.test.tsx +75 -0
- package/templates/tests/unit/hooks/useMediaQuery.test.ts +57 -0
- package/templates/tests/unit/hooks/useThemeEffect.test.ts +42 -0
- package/templates/tests/unit/i18n/detectLanguage.test.ts +40 -0
- package/templates/tests/unit/i18n/loadCatalog.test.ts +70 -0
- package/templates/tests/unit/lib/api.test.ts +142 -0
- package/templates/tests/unit/lib/format.test.ts +100 -0
- package/templates/tests/unit/lib/storage.test.ts +90 -0
- package/templates/tests/unit/lib/utils.test.ts +19 -0
- package/templates/tests/unit/lib/validations.test.ts +56 -0
- 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
|
+
});
|