sunpeak 0.5.8 → 0.5.9

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 (40) hide show
  1. package/README.md +12 -10
  2. package/bin/sunpeak.js +3 -3
  3. package/dist/chatgpt/theme-provider.d.ts +2 -2
  4. package/dist/index.cjs +5 -14
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +5 -14
  7. package/dist/index.js.map +1 -1
  8. package/dist/mcp/index.cjs +80 -106
  9. package/dist/mcp/index.cjs.map +1 -1
  10. package/dist/mcp/index.js +80 -106
  11. package/dist/mcp/index.js.map +1 -1
  12. package/package.json +2 -1
  13. package/template/README.md +1 -0
  14. package/template/dev/main.tsx +6 -10
  15. package/template/package.json +1 -0
  16. package/template/scripts/build-all.mjs +19 -10
  17. package/template/scripts/validate.mjs +6 -0
  18. package/template/src/components/album/album-card.test.tsx +62 -0
  19. package/template/src/components/album/album-card.tsx +14 -16
  20. package/template/src/components/album/albums.test.tsx +88 -0
  21. package/template/src/components/album/albums.tsx +50 -64
  22. package/template/src/components/album/film-strip.test.tsx +64 -0
  23. package/template/src/components/album/film-strip.tsx +16 -16
  24. package/template/src/components/album/fullscreen-viewer.test.tsx +77 -0
  25. package/template/src/components/album/fullscreen-viewer.tsx +45 -50
  26. package/template/src/components/card/card.test.tsx +1 -4
  27. package/template/src/components/card/card.tsx +38 -46
  28. package/template/src/components/carousel/carousel.tsx +57 -67
  29. package/template/src/components/resources/{AlbumsResource.tsx → albums-resource.tsx} +5 -5
  30. package/template/src/components/resources/{CarouselResource.tsx → carousel-resource.tsx} +18 -18
  31. package/template/src/components/resources/{CounterResource.tsx → counter-resource.tsx} +11 -31
  32. package/template/src/components/resources/index.ts +3 -3
  33. package/template/src/simulations/albums-simulation.ts +71 -71
  34. package/template/src/simulations/carousel-simulation.ts +34 -34
  35. package/template/src/simulations/counter-simulation.ts +2 -2
  36. package/template/vite.config.build.ts +2 -2
  37. package/template/vite.config.ts +1 -1
  38. package/template/vitest.config.ts +1 -1
  39. package/dist/runtime/index.d.ts +0 -7
  40. /package/dist/{runtime → providers}/provider-detection.d.ts +0 -0
@@ -23,24 +23,33 @@ mkdirSync(tempDir, { recursive: true });
23
23
 
24
24
  // Auto-discover all resources
25
25
  const resourceFiles = readdirSync(resourcesDir)
26
- .filter(file => file.endsWith('Resource.tsx'))
26
+ .filter(file => file.endsWith('-resource.tsx'))
27
27
  .map(file => {
28
- const resourceName = file.replace('Resource.tsx', '');
28
+ // Extract kebab-case name: 'counter-resource.tsx' -> 'counter'
29
+ const kebabName = file.replace('-resource.tsx', '');
30
+
31
+ // Convert kebab-case to PascalCase: 'counter' -> 'Counter', 'my-widget' -> 'MyWidget'
32
+ const pascalName = kebabName
33
+ .split('-')
34
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
35
+ .join('');
36
+
29
37
  return {
30
- componentName: `${resourceName}Resource`,
31
- entry: `.tmp/index-${resourceName.toLowerCase()}.tsx`,
32
- output: `${resourceName.toLowerCase()}.js`,
33
- buildOutDir: path.join(buildDir, resourceName.toLowerCase()),
38
+ componentName: `${pascalName}Resource`,
39
+ componentFile: file.replace('.tsx', ''),
40
+ entry: `.tmp/index-${kebabName}.tsx`,
41
+ output: `${kebabName}.js`,
42
+ buildOutDir: path.join(buildDir, kebabName),
34
43
  };
35
44
  });
36
45
 
37
- console.log('Building all tools...\n');
46
+ console.log('Building all resources...\n');
38
47
 
39
48
  // Read the template
40
49
  const template = readFileSync(templateFile, 'utf-8');
41
50
 
42
51
  // Build all resources (but don't copy yet)
43
- resourceFiles.forEach(({ componentName, entry, output, buildOutDir }, index) => {
52
+ resourceFiles.forEach(({ componentName, componentFile, entry, output, buildOutDir }, index) => {
44
53
  console.log(`[${index + 1}/${resourceFiles.length}] Building ${output}...`);
45
54
 
46
55
  try {
@@ -51,7 +60,7 @@ resourceFiles.forEach(({ componentName, entry, output, buildOutDir }, index) =>
51
60
 
52
61
  // Create entry file from template in temp directory
53
62
  const entryContent = template
54
- .replace('// RESOURCE_IMPORT', `import { ${componentName} } from '../src/components/resources/${componentName}';`)
63
+ .replace('// RESOURCE_IMPORT', `import { ${componentName} } from '../src/components/resources/${componentFile}';`)
55
64
  .replace('// RESOURCE_MOUNT', `createRoot(root).render(<${componentName} />);`);
56
65
 
57
66
  const entryPath = path.join(__dirname, '..', entry);
@@ -104,5 +113,5 @@ if (existsSync(buildDir)) {
104
113
  rmSync(buildDir, { recursive: true });
105
114
  }
106
115
 
107
- console.log('\n✓ All tools built successfully!');
116
+ console.log('\n✓ All resources built successfully!');
108
117
  console.log(`\nBuilt files:`, readdirSync(distDir));
@@ -77,6 +77,12 @@ try {
77
77
  console.log()
78
78
  printSuccess('pnpm install');
79
79
 
80
+ console.log('\nRunning: pnpm format');
81
+ if (!runCommand('pnpm format', PROJECT_ROOT)) {
82
+ throw new Error('pnpm format failed');
83
+ }
84
+ printSuccess('pnpm format');
85
+
80
86
  console.log('\nRunning: pnpm lint');
81
87
  if (!runCommand('pnpm lint', PROJECT_ROOT)) {
82
88
  throw new Error('pnpm lint failed');
@@ -0,0 +1,62 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { AlbumCard } from './album-card';
4
+ import type { Album } from './albums';
5
+
6
+ describe('AlbumCard', () => {
7
+ const mockAlbum: Album = {
8
+ id: 'test-album',
9
+ title: 'Test Album',
10
+ cover: 'https://example.com/cover.jpg',
11
+ photos: [
12
+ { id: '1', title: 'Photo 1', url: 'https://example.com/1.jpg' },
13
+ { id: '2', title: 'Photo 2', url: 'https://example.com/2.jpg' },
14
+ ],
15
+ };
16
+
17
+ it('correctly pluralizes photo count', () => {
18
+ // Test plural (2 photos)
19
+ const { rerender } = render(<AlbumCard album={mockAlbum} />);
20
+ expect(screen.getByText('2 photos')).toBeInTheDocument();
21
+
22
+ // Test singular (1 photo)
23
+ const singlePhotoAlbum: Album = {
24
+ ...mockAlbum,
25
+ photos: [{ id: '1', title: 'Photo 1', url: 'https://example.com/1.jpg' }],
26
+ };
27
+ rerender(<AlbumCard album={singlePhotoAlbum} />);
28
+ expect(screen.getByText('1 photo')).toBeInTheDocument();
29
+
30
+ // Test zero photos
31
+ const emptyAlbum: Album = {
32
+ ...mockAlbum,
33
+ photos: [],
34
+ };
35
+ rerender(<AlbumCard album={emptyAlbum} />);
36
+ expect(screen.getByText('0 photos')).toBeInTheDocument();
37
+ });
38
+
39
+ it('calls onSelect with correct album when clicked', () => {
40
+ const onSelect = vi.fn();
41
+ render(<AlbumCard album={mockAlbum} onSelect={onSelect} />);
42
+
43
+ const card = screen.getByRole('button');
44
+ fireEvent.click(card);
45
+
46
+ expect(onSelect).toHaveBeenCalledTimes(1);
47
+ expect(onSelect).toHaveBeenCalledWith(mockAlbum);
48
+ });
49
+
50
+ it('renders album title and cover image with correct attributes', () => {
51
+ render(<AlbumCard album={mockAlbum} />);
52
+
53
+ // Check title is displayed
54
+ expect(screen.getByText('Test Album')).toBeInTheDocument();
55
+
56
+ // Check image has correct src and alt
57
+ const image = screen.getByRole('img');
58
+ expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg');
59
+ expect(image).toHaveAttribute('alt', 'Test Album');
60
+ expect(image).toHaveAttribute('loading', 'lazy');
61
+ });
62
+ });
@@ -1,13 +1,13 @@
1
- import * as React from "react"
2
- import { Button } from "@openai/apps-sdk-ui/components/Button"
3
- import { cn } from "../../lib/index"
4
- import type { Album } from "./albums"
1
+ import * as React from 'react';
2
+ import { Button } from '@openai/apps-sdk-ui/components/Button';
3
+ import { cn } from '../../lib/index';
4
+ import type { Album } from './albums';
5
5
 
6
6
  export type AlbumCardProps = {
7
- album: Album
8
- onSelect?: (album: Album) => void
9
- className?: string
10
- }
7
+ album: Album;
8
+ onSelect?: (album: Album) => void;
9
+ className?: string;
10
+ };
11
11
 
12
12
  export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
13
13
  ({ album, onSelect, className }, ref) => {
@@ -17,7 +17,7 @@ export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
17
17
  variant="ghost"
18
18
  color="secondary"
19
19
  className={cn(
20
- "rounded-xl flex-shrink-0 w-full h-full p-0 text-left flex flex-col [&:hover]:bg-transparent hover:bg-transparent cursor-pointer",
20
+ 'rounded-xl flex-shrink-0 w-full h-full p-0 text-left flex flex-col [&:hover]:bg-transparent hover:bg-transparent cursor-pointer',
21
21
  className
22
22
  )}
23
23
  onClick={() => onSelect?.(album)}
@@ -31,15 +31,13 @@ export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
31
31
  />
32
32
  </div>
33
33
  <div className="flex-shrink-0 w-full p-2">
34
- <div className="text-base font-normal text-primary">
35
- {album.title}
36
- </div>
34
+ <div className="text-base font-normal text-primary">{album.title}</div>
37
35
  <div className="text-sm text-secondary">
38
- {album.photos.length} {album.photos.length === 1 ? "photo" : "photos"}
36
+ {album.photos.length} {album.photos.length === 1 ? 'photo' : 'photos'}
39
37
  </div>
40
38
  </div>
41
39
  </Button>
42
- )
40
+ );
43
41
  }
44
- )
45
- AlbumCard.displayName = "AlbumCard"
42
+ );
43
+ AlbumCard.displayName = 'AlbumCard';
@@ -0,0 +1,88 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { Albums, type AlbumsData } from './albums';
4
+
5
+ // Mock sunpeak hooks
6
+ const mockSetWidgetState = vi.fn();
7
+ const mockRequestDisplayMode = vi.fn();
8
+ let mockWidgetData: AlbumsData = { albums: [] };
9
+
10
+ vi.mock('sunpeak', () => ({
11
+ useWidgetProps: () => mockWidgetData,
12
+ useWidgetState: () => [{ selectedAlbumId: null }, mockSetWidgetState],
13
+ useDisplayMode: () => 'default',
14
+ useWidgetAPI: () => ({ requestDisplayMode: mockRequestDisplayMode }),
15
+ }));
16
+
17
+ // Mock child components to simplify testing
18
+ vi.mock('./fullscreen-viewer', () => ({
19
+ FullscreenViewer: ({ album }: { album: { title: string } }) => (
20
+ <div data-testid="fullscreen-viewer">{album.title}</div>
21
+ ),
22
+ }));
23
+
24
+ vi.mock('../carousel', () => ({
25
+ Carousel: ({ children }: { children: React.ReactNode }) => (
26
+ <div data-testid="carousel">{children}</div>
27
+ ),
28
+ }));
29
+
30
+ describe('Albums', () => {
31
+ const mockAlbums = [
32
+ {
33
+ id: 'album-1',
34
+ title: 'Summer Vacation',
35
+ cover: 'https://example.com/1.jpg',
36
+ photos: [{ id: 'p1', title: 'Beach', url: 'https://example.com/p1.jpg' }],
37
+ },
38
+ {
39
+ id: 'album-2',
40
+ title: 'City Trip',
41
+ cover: 'https://example.com/2.jpg',
42
+ photos: [{ id: 'p2', title: 'Downtown', url: 'https://example.com/p2.jpg' }],
43
+ },
44
+ ];
45
+
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ mockWidgetData = { albums: mockAlbums };
49
+ });
50
+
51
+ it('renders Carousel with all albums in default mode', () => {
52
+ render(<Albums />);
53
+
54
+ // Should render carousel
55
+ expect(screen.getByTestId('carousel')).toBeInTheDocument();
56
+
57
+ // Should render both album titles
58
+ expect(screen.getByText('Summer Vacation')).toBeInTheDocument();
59
+ expect(screen.getByText('City Trip')).toBeInTheDocument();
60
+ });
61
+
62
+ it('calls setWidgetState and requestDisplayMode when album is selected', () => {
63
+ render(<Albums />);
64
+
65
+ // Find and click the first album
66
+ const firstAlbum = screen.getByText('Summer Vacation').closest('button')!;
67
+ fireEvent.click(firstAlbum);
68
+
69
+ // Should update widget state with selected album ID
70
+ expect(mockSetWidgetState).toHaveBeenCalledWith({ selectedAlbumId: 'album-1' });
71
+
72
+ // Should request fullscreen mode
73
+ expect(mockRequestDisplayMode).toHaveBeenCalledWith({ mode: 'fullscreen' });
74
+ });
75
+
76
+ it('renders empty carousel when no albums provided', () => {
77
+ mockWidgetData = { albums: [] };
78
+
79
+ const { container } = render(<Albums />);
80
+
81
+ // Should render carousel (even if empty)
82
+ expect(screen.getByTestId('carousel')).toBeInTheDocument();
83
+
84
+ // Should not render any album cards
85
+ const buttons = container.querySelectorAll('button');
86
+ expect(buttons.length).toBe(0);
87
+ });
88
+ });
@@ -1,77 +1,63 @@
1
- import * as React from "react"
2
- import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from "sunpeak"
3
- import { Carousel } from "../carousel"
4
- import { AlbumCard } from "./album-card"
5
- import { FullscreenViewer } from "./fullscreen-viewer"
1
+ import * as React from 'react';
2
+ import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from 'sunpeak';
3
+ import { Carousel } from '../carousel';
4
+ import { AlbumCard } from './album-card';
5
+ import { FullscreenViewer } from './fullscreen-viewer';
6
6
 
7
7
  export interface Album {
8
- id: string
9
- title: string
10
- cover: string
8
+ id: string;
9
+ title: string;
10
+ cover: string;
11
11
  photos: Array<{
12
- id: string
13
- title: string
14
- url: string
15
- }>
12
+ id: string;
13
+ title: string;
14
+ url: string;
15
+ }>;
16
16
  }
17
17
 
18
18
  export interface AlbumsData extends Record<string, unknown> {
19
- albums: Album[]
19
+ albums: Album[];
20
20
  }
21
21
 
22
22
  export interface AlbumsState extends Record<string, unknown> {
23
- selectedAlbumId?: string | null
23
+ selectedAlbumId?: string | null;
24
24
  }
25
25
 
26
26
  export type AlbumsProps = {
27
- className?: string
28
- }
29
-
30
- export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(
31
- ({ className }, ref) => {
32
- const data = useWidgetProps<AlbumsData>(() => ({ albums: [] }))
33
- const [widgetState, setWidgetState] = useWidgetState<AlbumsState>(() => ({
34
- selectedAlbumId: null,
35
- }))
36
- const displayMode = useDisplayMode()
37
- const api = useWidgetAPI()
38
-
39
- const albums = data.albums || []
40
- const selectedAlbum = albums.find(
41
- (album) => album.id === widgetState?.selectedAlbumId
42
- )
43
-
44
- const handleSelectAlbum = React.useCallback(
45
- (album: Album) => {
46
- setWidgetState({ selectedAlbumId: album.id })
47
- api?.requestDisplayMode?.({ mode: "fullscreen" })
48
- },
49
- [setWidgetState, api]
50
- )
51
-
52
- if (displayMode === "fullscreen" && selectedAlbum) {
53
- return (
54
- <FullscreenViewer
55
- ref={ref}
56
- album={selectedAlbum}
57
- />
58
- )
59
- }
60
-
61
- return (
62
- <div ref={ref} className={className}>
63
- <Carousel
64
- gap={20}
65
- showArrows={false}
66
- showEdgeGradients={false}
67
- cardWidth={272}
68
- >
69
- {albums.map((album) => (
70
- <AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
71
- ))}
72
- </Carousel>
73
- </div>
74
- )
27
+ className?: string;
28
+ };
29
+
30
+ export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(({ className }, ref) => {
31
+ const data = useWidgetProps<AlbumsData>(() => ({ albums: [] }));
32
+ const [widgetState, setWidgetState] = useWidgetState<AlbumsState>(() => ({
33
+ selectedAlbumId: null,
34
+ }));
35
+ const displayMode = useDisplayMode();
36
+ const api = useWidgetAPI();
37
+
38
+ const albums = data.albums || [];
39
+ const selectedAlbum = albums.find((album) => album.id === widgetState?.selectedAlbumId);
40
+
41
+ const handleSelectAlbum = React.useCallback(
42
+ (album: Album) => {
43
+ setWidgetState({ selectedAlbumId: album.id });
44
+ api?.requestDisplayMode?.({ mode: 'fullscreen' });
45
+ },
46
+ [setWidgetState, api]
47
+ );
48
+
49
+ if (displayMode === 'fullscreen' && selectedAlbum) {
50
+ return <FullscreenViewer ref={ref} album={selectedAlbum} />;
75
51
  }
76
- )
77
- Albums.displayName = "Albums"
52
+
53
+ return (
54
+ <div ref={ref} className={className}>
55
+ <Carousel gap={20} showArrows={false} showEdgeGradients={false} cardWidth={272}>
56
+ {albums.map((album) => (
57
+ <AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
58
+ ))}
59
+ </Carousel>
60
+ </div>
61
+ );
62
+ });
63
+ Albums.displayName = 'Albums';
@@ -0,0 +1,64 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { FilmStrip } from './film-strip';
4
+ import type { Album } from './albums';
5
+
6
+ describe('FilmStrip', () => {
7
+ const mockAlbum: Album = {
8
+ id: 'test-album',
9
+ title: 'Test Album',
10
+ cover: 'https://example.com/cover.jpg',
11
+ photos: [
12
+ { id: '1', title: 'Sunset', url: 'https://example.com/1.jpg' },
13
+ { id: '2', title: '', url: 'https://example.com/2.jpg' },
14
+ { id: '3', title: 'Mountains', url: 'https://example.com/3.jpg' },
15
+ ],
16
+ };
17
+
18
+ it('applies correct styling to selected photo', () => {
19
+ const { container } = render(<FilmStrip album={mockAlbum} selectedIndex={1} />);
20
+
21
+ const buttons = container.querySelectorAll('button');
22
+
23
+ // Selected photo (index 1) should have border-primary
24
+ expect(buttons[1].className).toContain('border-primary');
25
+ expect(buttons[1].className).toContain('shadow-md');
26
+
27
+ // Non-selected photos should have border-transparent
28
+ expect(buttons[0].className).toContain('border-transparent');
29
+ expect(buttons[0].className).toContain('opacity-60');
30
+ expect(buttons[2].className).toContain('border-transparent');
31
+ });
32
+
33
+ it('calls onSelect with correct index when photo is clicked', () => {
34
+ const onSelect = vi.fn();
35
+ render(<FilmStrip album={mockAlbum} selectedIndex={0} onSelect={onSelect} />);
36
+
37
+ const images = screen.getAllByRole('img');
38
+
39
+ // Click on the second photo (index 1)
40
+ fireEvent.click(images[1].closest('button')!);
41
+ expect(onSelect).toHaveBeenCalledTimes(1);
42
+ expect(onSelect).toHaveBeenCalledWith(1);
43
+
44
+ // Click on the third photo (index 2)
45
+ fireEvent.click(images[2].closest('button')!);
46
+ expect(onSelect).toHaveBeenCalledTimes(2);
47
+ expect(onSelect).toHaveBeenCalledWith(2);
48
+ });
49
+
50
+ it('renders alt text with fallback for photos without titles', () => {
51
+ render(<FilmStrip album={mockAlbum} selectedIndex={0} />);
52
+
53
+ const images = screen.getAllByRole('img');
54
+
55
+ // Photo with title
56
+ expect(images[0]).toHaveAttribute('alt', 'Sunset');
57
+
58
+ // Photo without title should fall back to "Photo N"
59
+ expect(images[1]).toHaveAttribute('alt', 'Photo 2');
60
+
61
+ // Photo with title
62
+ expect(images[2]).toHaveAttribute('alt', 'Mountains');
63
+ });
64
+ });
@@ -1,14 +1,14 @@
1
- import * as React from "react"
2
- import { Button } from "@openai/apps-sdk-ui/components/Button"
3
- import { cn } from "../../lib/index"
4
- import type { Album } from "./albums"
1
+ import * as React from 'react';
2
+ import { Button } from '@openai/apps-sdk-ui/components/Button';
3
+ import { cn } from '../../lib/index';
4
+ import type { Album } from './albums';
5
5
 
6
6
  export type FilmStripProps = {
7
- album: Album
8
- selectedIndex: number
9
- onSelect?: (index: number) => void
10
- className?: string
11
- }
7
+ album: Album;
8
+ selectedIndex: number;
9
+ onSelect?: (index: number) => void;
10
+ className?: string;
11
+ };
12
12
 
13
13
  export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
14
14
  ({ album, selectedIndex, onSelect, className }, ref) => {
@@ -16,7 +16,7 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
16
16
  <div
17
17
  ref={ref}
18
18
  className={cn(
19
- "h-full w-full overflow-auto flex flex-col items-center justify-center p-5 space-y-5",
19
+ 'h-full w-full overflow-auto flex flex-col items-center justify-center p-5 space-y-5',
20
20
  className
21
21
  )}
22
22
  >
@@ -27,10 +27,10 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
27
27
  color="secondary"
28
28
  onClick={() => onSelect?.(idx)}
29
29
  className={cn(
30
- "block w-full h-auto p-[1px] pointer-events-auto rounded-[10px] border transition-all",
30
+ 'block w-full h-auto p-[1px] pointer-events-auto rounded-[10px] border transition-all',
31
31
  idx === selectedIndex
32
- ? "border-primary shadow-md"
33
- : "border-transparent hover:border-primary/30 opacity-60 hover:opacity-100"
32
+ ? 'border-primary shadow-md'
33
+ : 'border-transparent hover:border-primary/30 opacity-60 hover:opacity-100'
34
34
  )}
35
35
  >
36
36
  <div className="aspect-[5/3] rounded-lg overflow-hidden w-full">
@@ -44,7 +44,7 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
44
44
  </Button>
45
45
  ))}
46
46
  </div>
47
- )
47
+ );
48
48
  }
49
- )
50
- FilmStrip.displayName = "FilmStrip"
49
+ );
50
+ FilmStrip.displayName = 'FilmStrip';
@@ -0,0 +1,77 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { FullscreenViewer } from './fullscreen-viewer';
4
+ import type { Album } from './albums';
5
+
6
+ // Mock sunpeak hooks
7
+ vi.mock('sunpeak', () => ({
8
+ useMaxHeight: () => 800,
9
+ }));
10
+
11
+ describe('FullscreenViewer', () => {
12
+ const mockAlbum: Album = {
13
+ id: 'album-1',
14
+ title: 'Test Album',
15
+ cover: 'https://example.com/cover.jpg',
16
+ photos: [
17
+ { id: '1', title: 'First Photo', url: 'https://example.com/1.jpg' },
18
+ { id: '2', title: 'Second Photo', url: 'https://example.com/2.jpg' },
19
+ { id: '3', title: 'Third Photo', url: 'https://example.com/3.jpg' },
20
+ ],
21
+ };
22
+
23
+ it('resets to first photo when album changes', () => {
24
+ const { rerender, container } = render(<FullscreenViewer album={mockAlbum} />);
25
+
26
+ // Get the main photo area (not the film strip)
27
+ const mainPhotoArea = container.querySelector('.flex-1.min-w-0');
28
+ let mainPhoto = mainPhotoArea?.querySelector('img');
29
+ expect(mainPhoto).toHaveAttribute('alt', 'First Photo');
30
+ expect(mainPhoto).toHaveAttribute('src', 'https://example.com/1.jpg');
31
+
32
+ // Create a different album
33
+ const differentAlbum: Album = {
34
+ id: 'album-2',
35
+ title: 'Different Album',
36
+ cover: 'https://example.com/cover2.jpg',
37
+ photos: [
38
+ { id: '4', title: 'New First Photo', url: 'https://example.com/4.jpg' },
39
+ { id: '5', title: 'New Second Photo', url: 'https://example.com/5.jpg' },
40
+ ],
41
+ };
42
+
43
+ // Rerender with different album
44
+ rerender(<FullscreenViewer album={differentAlbum} />);
45
+
46
+ // Should show the first photo of the new album
47
+ mainPhoto = mainPhotoArea?.querySelector('img');
48
+ expect(mainPhoto).toHaveAttribute('alt', 'New First Photo');
49
+ expect(mainPhoto).toHaveAttribute('src', 'https://example.com/4.jpg');
50
+ });
51
+
52
+ it('displays correct photo based on selected index from FilmStrip', () => {
53
+ const { container } = render(<FullscreenViewer album={mockAlbum} />);
54
+
55
+ // Get the main photo (not from film strip)
56
+ const mainPhotoArea = container.querySelector('.flex-1.min-w-0');
57
+ const firstPhoto = mainPhotoArea?.querySelector('img');
58
+
59
+ expect(firstPhoto).toHaveAttribute('alt', 'First Photo');
60
+ expect(firstPhoto).toHaveAttribute('src', 'https://example.com/1.jpg');
61
+ });
62
+
63
+ it('handles empty photos array gracefully', () => {
64
+ const emptyAlbum: Album = {
65
+ id: 'empty-album',
66
+ title: 'Empty Album',
67
+ cover: 'https://example.com/cover.jpg',
68
+ photos: [],
69
+ };
70
+
71
+ const { container } = render(<FullscreenViewer album={emptyAlbum} />);
72
+
73
+ // Should not render any img element in the main photo area
74
+ const images = container.querySelectorAll('img');
75
+ expect(images.length).toBe(0);
76
+ });
77
+ });