upgrade to tamagui v2 and guest pwa overhaul

This commit is contained in:
Codex Agent
2026-02-02 13:01:20 +01:00
parent 2e78f3ab8d
commit 7c6e14ffe2
168 changed files with 47462 additions and 8914 deletions

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/e/demo' }),
useNavigate: () => vi.fn(),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
vi.mock('@tamagui/stacks', () => ({
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
Home: () => <span>home</span>,
Image: () => <span>image</span>,
Share2: () => <span>share</span>,
}));
import BottomDock from '../components/BottomDock';
describe('BottomDock', () => {
it('renders navigation labels', () => {
render(<BottomDock />);
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Gallery')).toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
let identityState = { hydrated: true, name: '' };
vi.mock('react-router-dom', () => ({
useParams: () => ({ token: 'demo-token' }),
Navigate: ({ to }: { to: string }) => <div>navigate:{to}</div>,
Outlet: () => <div>outlet</div>,
}));
vi.mock('../context/EventDataContext', () => ({
EventDataProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useEventData: () => ({ event: null }),
}));
vi.mock('@/guest/context/EventBrandingContext', () => ({
EventBrandingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
LocaleProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DEFAULT_LOCALE: 'de',
isLocaleCode: () => true,
}));
vi.mock('@/guest/context/NotificationCenterContext', () => ({
NotificationCenterProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('../context/GuestIdentityContext', () => ({
GuestIdentityProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useOptionalGuestIdentity: () => identityState,
}));
import EventLayout from '../layouts/EventLayout';
describe('EventLayout profile gate', () => {
it('redirects to setup when profile is missing', () => {
identityState = { hydrated: true, name: '' };
render(<EventLayout requireProfile />);
expect(screen.getByText('navigate:/setup/demo-token')).toBeInTheDocument();
});
it('renders outlet when profile exists', () => {
identityState = { hydrated: true, name: 'Ava' };
render(<EventLayout requireProfile />);
expect(screen.getByText('outlet')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({ value }: { value?: string }) => <input value={value} readOnly />,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/StandaloneShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/components/PullToRefresh', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/services/helpApi', () => ({
getHelpArticles: () => Promise.resolve({
servedFromCache: false,
articles: [{ slug: 'intro', title: 'Intro', summary: 'Summary', updated_at: null }],
}),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
vi.mock('react-router-dom', () => ({
useParams: () => ({}),
Link: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import HelpCenterScreen from '../screens/HelpCenterScreen';
describe('HelpCenterScreen', () => {
it('renders help center title', async () => {
render(<HelpCenterScreen />);
expect(await screen.findByText('help.center.title')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
Camera: () => <span>camera</span>,
Sparkles: () => <span>sparkles</span>,
Image: () => <span>image</span>,
Star: () => <span>star</span>,
Trophy: () => <span>trophy</span>,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import HomeScreen from '../screens/HomeScreen';
describe('HomeScreen', () => {
it('shows prompt quest content when tasks are enabled', () => {
render(
<EventDataProvider tasksEnabledFallback>
<HomeScreen />
</EventDataProvider>
);
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
expect(screen.getByText('Start prompt')).toBeInTheDocument();
});
it('shows capture-ready content when tasks are disabled', () => {
render(
<EventDataProvider tasksEnabledFallback={false}>
<HomeScreen />
</EventDataProvider>
);
expect(screen.getByText('Capture ready')).toBeInTheDocument();
expect(screen.getByText('Upload / Take photo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({ ...rest }: { [key: string]: unknown }) => <input {...rest} />,
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('html5-qrcode', () => ({
Html5Qrcode: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
}));
vi.mock('lucide-react', () => ({
QrCode: () => <span>qr</span>,
ArrowRight: () => <span>arrow</span>,
}));
import LandingScreen from '../screens/LandingScreen';
describe('LandingScreen', () => {
it('renders join panel copy', () => {
render(
<LocaleProvider>
<LandingScreen />
</LocaleProvider>
);
expect(screen.getByRole('button', { name: 'Event beitreten' })).toBeInTheDocument();
expect(screen.getAllByText('Event beitreten').length).toBeGreaterThan(1);
expect(screen.getByPlaceholderText('Event-Code eingeben')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
import NotFoundScreen from '../screens/NotFoundScreen';
describe('NotFoundScreen', () => {
it('renders fallback copy', () => {
render(<NotFoundScreen />);
expect(screen.getByText('Seite nicht gefunden')).toBeInTheDocument();
expect(screen.getByText('Die Seite konnte nicht gefunden werden.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useParams: () => ({ photoId: '123' }),
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token' }),
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null }),
fetchPhoto: vi.fn().mockResolvedValue({ id: 123, file_path: 'storage/demo.jpg', likes_count: 5 }),
likePhoto: vi.fn().mockResolvedValue(6),
createPhotoShareLink: vi.fn().mockResolvedValue({ url: 'http://example.com' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import PhotoLightboxScreen from '../screens/PhotoLightboxScreen';
describe('PhotoLightboxScreen', () => {
it('renders lightbox layout', async () => {
render(<PhotoLightboxScreen />);
expect(await screen.findByText('Gallery')).toBeInTheDocument();
expect(await screen.findByText('Like')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
return { Sheet };
});
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams()],
}));
vi.mock('lucide-react', () => ({
Image: () => <span>image</span>,
Filter: () => <span>filter</span>,
Camera: () => <span>camera</span>,
Grid2x2: () => <span>grid</span>,
Zap: () => <span>zap</span>,
UploadCloud: () => <span>upload</span>,
ListVideo: () => <span>list</span>,
RefreshCcw: () => <span>refresh</span>,
FlipHorizontal: () => <span>flip</span>,
X: () => <span>close</span>,
Sparkles: () => <span>sparkles</span>,
Trophy: () => <span>trophy</span>,
Play: () => <span>play</span>,
Share2: () => <span>share</span>,
QrCode: () => <span>qr</span>,
Link: () => <span>link</span>,
Users: () => <span>users</span>,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../context/EventDataContext', () => ({
EventDataProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useEventData: () => ({ token: 'demo', tasksEnabled: true, event: null }),
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({ items: [], loading: false, add: vi.fn() }),
uploadPhoto: vi.fn(),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null, notModified: false }),
}));
vi.mock('../hooks/usePollGalleryDelta', () => ({
usePollGalleryDelta: () => ({ data: { photos: [], latestPhotoAt: null, nextCursor: null }, loading: false, error: null }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
locale: 'de',
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: vi.fn().mockResolvedValue([]),
}));
vi.mock('../services/emotionsApi', () => ({
fetchEmotions: vi.fn().mockResolvedValue([]),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ completedCount: 0 }),
}));
import GalleryScreen from '../screens/GalleryScreen';
import UploadScreen from '../screens/UploadScreen';
import TasksScreen from '../screens/TasksScreen';
import ShareScreen from '../screens/ShareScreen';
describe('Guest v2 screens copy', () => {
it('renders gallery header', () => {
render(
<EventDataProvider tasksEnabledFallback>
<GalleryScreen />
</EventDataProvider>
);
expect(screen.getByText('Gallery')).toBeInTheDocument();
});
it('renders upload preview prompt', () => {
render(
<EventDataProvider tasksEnabledFallback>
<UploadScreen />
</EventDataProvider>
);
expect(screen.getByText('Camera')).toBeInTheDocument();
});
it('renders tasks quest when enabled', () => {
render(
<EventDataProvider tasksEnabledFallback>
<TasksScreen />
</EventDataProvider>
);
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
});
it('renders share hub header', () => {
render(
<EventDataProvider tasksEnabledFallback>
<ShareScreen />
</EventDataProvider>
);
expect(screen.getByText('Invite guests')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
const updateAppearance = vi.fn();
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ appearance: 'dark', updateAppearance }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo-token' }),
}));
vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ hydrated: false, name: '', setName: vi.fn(), clearName: vi.fn() }),
}));
vi.mock('@/guest/hooks/useHapticsPreference', () => ({
useHapticsPreference: () => ({ enabled: false, setEnabled: vi.fn(), supported: true }),
}));
vi.mock('@/contexts/consent', () => ({
useConsent: () => ({ preferences: { analytics: false }, savePreferences: vi.fn() }),
}));
vi.mock('react-router-dom', () => ({
Link: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({
children,
onPress,
...rest
}: {
children: React.ReactNode;
onPress?: () => void;
}) => (
<button type="button" onClick={onPress} {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({
value,
onChange,
onChangeText,
...rest
}: React.InputHTMLAttributes<HTMLInputElement> & { onChangeText?: (next: string) => void }) => (
<input
value={value}
onChange={(event) => {
onChange?.(event);
onChangeText?.(event.target.value);
}}
readOnly={!onChange && !onChangeText}
{...rest}
/>
),
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({
checked,
onCheckedChange,
'aria-label': ariaLabel,
children,
}: {
checked?: boolean;
onCheckedChange?: (next: boolean) => void;
'aria-label'?: string;
children?: React.ReactNode;
}) => (
<label>
<input
type="checkbox"
aria-label={ariaLabel}
checked={checked}
onChange={(event) => onCheckedChange?.(event.target.checked)}
/>
{children}
</label>
),
{ Thumb: ({ children }: { children?: React.ReactNode }) => <span>{children}</span> },
),
}));
import SettingsContent from '../components/SettingsContent';
describe('SettingsContent', () => {
it('toggles appearance mode', () => {
render(<SettingsContent />);
const toggle = screen.getByLabelText('Dark mode');
fireEvent.click(toggle);
expect(updateAppearance).toHaveBeenCalledWith('light');
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'dark' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/components/legal-markdown', () => ({
LegalMarkdown: () => <div>Legal markdown</div>,
}));
vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
X: () => <span>x</span>,
}));
vi.mock('../components/SettingsContent', () => ({
default: () => <div>Settings content</div>,
}));
import SettingsSheet from '../components/SettingsSheet';
describe('SettingsSheet', () => {
it('renders settings content inside the sheet', () => {
render(<SettingsSheet open onOpenChange={vi.fn()} />);
expect(screen.getByText('Settings content')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
motion: { div: ({ children }: { children: React.ReactNode }) => <div>{children}</div> },
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: () => Promise.resolve({ data: [] }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
import SlideshowScreen from '../screens/SlideshowScreen';
describe('SlideshowScreen', () => {
it('shows empty state when no photos', async () => {
render(
<EventDataProvider token="token">
<SlideshowScreen />
</EventDataProvider>
);
expect(await screen.findByText('Noch keine Fotos')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useParams: () => ({ taskId: '12' }),
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: () => Promise.resolve([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import TaskDetailScreen from '../screens/TaskDetailScreen';
describe('TaskDetailScreen', () => {
it('renders task title', async () => {
render(
<EventDataProvider token="token">
<TaskDetailScreen />
</EventDataProvider>
);
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({
items: [],
loading: false,
retryAll: vi.fn(),
clearFinished: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token' }),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
locale: 'de',
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import UploadQueueScreen from '../screens/UploadQueueScreen';
describe('UploadQueueScreen', () => {
it('renders empty queue state', () => {
render(<UploadQueueScreen />);
expect(screen.getByText('Uploads')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams('taskId=12')],
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
uploadPhoto: vi.fn(),
useUploadQueue: () => ({ items: [], add: vi.fn() }),
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: vi.fn().mockResolvedValue([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ markCompleted: vi.fn() }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? _key) : (arg3 ?? _key),
locale: 'en',
}),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import UploadScreen from '../screens/UploadScreen';
describe('UploadScreen', () => {
it('renders queue entry point', () => {
render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
expect(screen.getByText('Queue')).toBeInTheDocument();
});
it('renders task summary when taskId is present', async () => {
render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { resolveGuestThemeName } from '../lib/brandingTheme';
import type { EventBranding } from '@/guest/types/event-branding';
const baseBranding: EventBranding = {
primaryColor: '#FF5A5F',
secondaryColor: '#F43F5E',
backgroundColor: '#ffffff',
fontFamily: 'Inter',
logoUrl: null,
palette: {
primary: '#FF5A5F',
secondary: '#F43F5E',
background: '#ffffff',
surface: '#ffffff',
},
typography: {
heading: 'Inter',
body: 'Inter',
sizePreset: 'm',
},
mode: 'auto',
};
const originalMatchMedia = window.matchMedia;
function mockMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
}
describe('resolveGuestThemeName', () => {
beforeEach(() => {
mockMatchMedia(false);
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
it('uses branding mode overrides', () => {
expect(resolveGuestThemeName({ ...baseBranding, mode: 'dark' }, 'light')).toBe('guestNight');
expect(resolveGuestThemeName({ ...baseBranding, mode: 'light' }, 'dark')).toBe('guestLight');
});
it('respects explicit appearance when mode is auto', () => {
expect(resolveGuestThemeName({ ...baseBranding, mode: 'auto' }, 'dark')).toBe('guestNight');
expect(resolveGuestThemeName({ ...baseBranding, mode: 'auto' }, 'light')).toBe('guestLight');
});
it('falls back to background luminance when appearance is system', () => {
const darkBackground = { ...baseBranding, backgroundColor: '#0a0f1f' };
expect(resolveGuestThemeName(darkBackground, 'system')).toBe('guestNight');
const lightBackground = { ...baseBranding, backgroundColor: '#fdf9f4' };
expect(resolveGuestThemeName(lightBackground, 'system')).toBe('guestLight');
});
it('uses system preference when background is neutral', () => {
const neutralBackground = { ...baseBranding, backgroundColor: '#b0b0b0' };
mockMatchMedia(true);
expect(resolveGuestThemeName(neutralBackground, 'system')).toBe('guestNight');
mockMatchMedia(false);
expect(resolveGuestThemeName(neutralBackground, 'system')).toBe('guestLight');
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { mapEventBranding } from '../lib/eventBranding';
describe('mapEventBranding', () => {
it('maps palette, typography, and buttons from payload', () => {
const result = mapEventBranding({
primary_color: '#112233',
secondary_color: '#445566',
background_color: '#000000',
font_family: 'Event Body',
heading_font: 'Event Heading',
button_radius: 16,
button_primary_color: '#abcdef',
palette: {
surface: '#111111',
},
typography: {
size: 'l',
},
});
expect(result?.primaryColor).toBe('#112233');
expect(result?.secondaryColor).toBe('#445566');
expect(result?.palette?.surface).toBe('#111111');
expect(result?.typography?.heading).toBe('Event Heading');
expect(result?.typography?.body).toBe('Event Body');
expect(result?.typography?.sizePreset).toBe('l');
expect(result?.buttons?.radius).toBe(16);
expect(result?.buttons?.primary).toBe('#abcdef');
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fetchEventStats, clearStatsCache } from '../services/statsApi';
const fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
describe('fetchEventStats', () => {
beforeEach(() => {
fetchMock.mockReset();
clearStatsCache();
});
afterEach(() => {
clearStatsCache();
});
it('returns cached stats on 304', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ online_guests: 4, tasks_solved: 1, latest_photo_at: '2024-01-01T00:00:00Z' }), {
status: 200,
headers: { ETag: '"demo"' },
})
);
const first = await fetchEventStats('demo');
expect(first.onlineGuests).toBe(4);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 304, headers: { ETag: '"demo"' } }));
const second = await fetchEventStats('demo');
expect(second).toEqual(first);
});
});