Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,065 changes: 651 additions & 414 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,22 @@
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "^9.0.0",
"@tailwindcss/vite": "4.1.17",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/node": "22.19.17",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"eslint": "^10.14.0",
"eslint": "^9.0.0",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^30.1.3",
"prettier": "^3.9.10",
"tailwindcss": "4.1.17",
"typescript": "5.9.3",
"typescript-eslint": "^8.72.1",
"vite": "7.2.4",
"vite": "^7.3.7",
"vite-plugin-singlefile": "2.3.0",
"vitest": "^5.1.0"
}
Expand Down
42 changes: 42 additions & 0 deletions src/components/GalleryView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { GalleryView } from './GalleryView';

afterEach(() => cleanup());

describe('GalleryView', () => {
it('renders without crashing', () => {
render(<GalleryView />);
expect(screen.getByText('Gallery')).toBeDefined();
});

it('displays shot count in header', () => {
render(<GalleryView />);
expect(screen.getByText(/shots/)).toBeDefined();
});

it('toggles selection mode and shows selected count', () => {
render(<GalleryView />);
const selectButton = screen.getByRole('button', { name: 'Select' });
fireEvent.click(selectButton);
expect(screen.getByText(/0 Selected/)).toBeDefined();
});

it('filters shots by mode', () => {
render(<GalleryView />);
const photoButton = screen.getByRole('button', { name: /Photo/ });
fireEvent.click(photoButton);
expect(screen.getByText(/shots/)).toBeDefined();
});

it('opens shot detail on thumbnail click', () => {
render(<GalleryView />);
// Thumbnail buttons have the aspect-square class on the button itself
const thumbnails = screen.getAllByRole('button').filter((b) =>
b.classList.contains('aspect-square')
);
expect(thumbnails.length).toBeGreaterThan(0);
fireEvent.click(thumbnails[0]);
expect(screen.getByText(/Mode/)).toBeDefined();
});
});
27 changes: 24 additions & 3 deletions src/components/ModeCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,24 @@ export function ModeCarousel({ current, onChange }: Props) {

const handleMouseUp = () => setDragStart(null);

const currentIdx = MODES.findIndex(m => m.id === current);
const handleTouchStart = (e: React.TouchEvent) => {
const touch = e.touches[0];
setDragStart(touch.clientX);
setScrollStart(containerRef.current?.scrollLeft ?? 0);
};

const handleTouchMove = (e: React.TouchEvent) => {
if (dragStart === null || !containerRef.current) return;
const touch = e.touches[0];
const dx = touch.clientX - dragStart;
// Prevent the page from scrolling while dragging the carousel horizontally.
e.preventDefault();
containerRef.current.scrollLeft = scrollStart - dx;
};

const handleTouchEnd = () => setDragStart(null);

const currentIdx = MODES.findIndex((m) => m.id === current);

return (
<div className="relative w-full bg-black/40 backdrop-blur-sm border-t border-white/10">
Expand All @@ -34,12 +51,16 @@ export function ModeCarousel({ current, onChange }: Props) {

<div
ref={containerRef}
className="flex items-center gap-1 overflow-x-auto scrollbar-hide px-[calc(50%-60px)] py-2 cursor-grab active:cursor-grabbing select-none"
className="flex items-center gap-1 overflow-x-auto scrollbar-hide px-[calc(50%-60px)] py-2 cursor-grab active:cursor-grabbing select-none touch-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ scrollBehavior: 'smooth' }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
style={{ scrollBehavior: 'smooth', WebkitOverflowScrolling: 'touch' }}
>
{MODES.map((mode, idx) => {
const isActive = mode.id === current;
Expand Down
85 changes: 85 additions & 0 deletions src/components/ProControlBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { ProControlBar } from './ProControlBar';
import type { CameraSettings } from '../types/camera';

const settings: CameraSettings = {
iso: 100,
shutterSpeed: '1/125',
ev: 0,
whiteBalance: 'Auto',
kelvin: 5500,
tint: 0,
focusMode: 'AF-S',
focusPeaking: false,
focusPeakingColor: 'red',
manualFocus: 50,
meteringMode: 'Matrix',
driveMode: 'Single',
flashMode: 'Auto',
aspectRatio: '4:3',
fileFormat: 'JPEG',
colorProfile: 'Natural',
zebraStripes: false,
zebraThreshold: 95,
histogram: true,
gridOverlay: 'RuleOfThirds',
horizonLevel: false,
liveNoise: false,
stabilization: 'Standard',
anamorphic: false,
videoResolution: '4K',
videoFps: '30',
audioLevel: 75,
windFilter: true,
stereoAudio: true,
zoomLevel: 1,
eyeAF: true,
faceDetection: true,
subjectTracking: false,
nightModeFrames: 8,
hdrMode: false,
location: null,
};

afterEach(() => cleanup());

describe('ProControlBar', () => {
it('renders without crashing in photo mode', () => {
render(<ProControlBar settings={settings} updateSetting={vi.fn()} mode="photo" />);
expect(screen.getByText('ISO')).toBeDefined();
});

it('hides shutter control in photo mode', () => {
render(<ProControlBar settings={settings} updateSetting={vi.fn()} mode="photo" />);
expect(screen.queryByText('SHUTTER')).toBeNull();
});

it('shows shutter control in pro mode', () => {
render(<ProControlBar settings={settings} updateSetting={vi.fn()} mode="pro" />);
expect(screen.getByText('SHUTTER')).toBeDefined();
});

it('opens ISO panel on click', () => {
render(<ProControlBar settings={settings} updateSetting={vi.fn()} mode="pro" />);
const isoButton = screen.getByRole('button', { name: /ISO/ });
fireEvent.click(isoButton);
expect(screen.getByText('ISO SENSITIVITY')).toBeDefined();
});

it('updates ISO setting when a value is clicked in panel', () => {
const updateSetting = vi.fn();
render(<ProControlBar settings={settings} updateSetting={updateSetting} mode="pro" />);
const isoButton = screen.getByRole('button', { name: /ISO/ });
fireEvent.click(isoButton);
const iso400Button = screen.getByText('400');
fireEvent.click(iso400Button);
expect(updateSetting).toHaveBeenCalledWith('iso', 400);
});

it('shows audio and stabilization controls in video mode', () => {
render(<ProControlBar settings={settings} updateSetting={vi.fn()} mode="video" />);
expect(screen.getByText('AUDIO')).toBeDefined();
expect(screen.getByText('STAB')).toBeDefined();
});
});
122 changes: 122 additions & 0 deletions src/components/SettingsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { SettingsPanel } from './SettingsPanel';
import type { CameraSettings, CustomPreset } from '../types/camera';

const defaultSettings: CameraSettings = {
iso: 100,
shutterSpeed: '1/125',
ev: 0,
whiteBalance: 'Auto',
kelvin: 5500,
tint: 0,
focusMode: 'AF-S',
focusPeaking: false,
focusPeakingColor: 'red',
manualFocus: 50,
meteringMode: 'Matrix',
driveMode: 'Single',
flashMode: 'Auto',
aspectRatio: '4:3',
fileFormat: 'JPEG',
colorProfile: 'Natural',
zebraStripes: false,
zebraThreshold: 95,
histogram: true,
gridOverlay: 'RuleOfThirds',
horizonLevel: false,
liveNoise: false,
stabilization: 'Standard',
anamorphic: false,
videoResolution: '4K',
videoFps: '30',
audioLevel: 75,
windFilter: true,
stereoAudio: true,
zoomLevel: 1,
eyeAF: true,
faceDetection: true,
subjectTracking: false,
nightModeFrames: 8,
hdrMode: false,
location: null,
};

const defaultPresets: CustomPreset[] = [];

function renderSettingsPanel(overrides: Partial<Parameters<typeof SettingsPanel>[0]> = {}) {
return render(
<SettingsPanel
settings={defaultSettings}
updateSetting={vi.fn()}
presets={defaultPresets}
applyPreset={vi.fn()}
savePreset={vi.fn()}
deletePreset={vi.fn()}
{...overrides}
/>
);
}

afterEach(() => cleanup());

describe('SettingsPanel', () => {
it('renders without crashing', () => {
renderSettingsPanel();
expect(screen.getByText('Settings')).toBeDefined();
});

it('switches to Geo section', () => {
renderSettingsPanel();
const geoTab = screen.getByRole('button', { name: 'Geo' });
fireEvent.click(geoTab);
expect(screen.getByText('Geotagging & Metadata')).toBeDefined();
});

it('switches to Presets section', () => {
renderSettingsPanel();
const presetsTab = screen.getByRole('button', { name: 'Presets' });
fireEvent.click(presetsTab);
expect(screen.getByText('Custom Presets')).toBeDefined();
});

it('toggles an overlay setting', () => {
const updateSetting = vi.fn();
renderSettingsPanel({ updateSetting });
const overlaysTab = screen.getByRole('button', { name: 'Overlays' });
fireEvent.click(overlaysTab);
const label = screen.getByText('Live Histogram');
const toggle = label.parentElement?.parentElement?.querySelector('button');
expect(toggle).toBeDefined();
fireEvent.click(toggle!);
expect(updateSetting).toHaveBeenCalledWith('histogram', false);
});

it('saves a new preset', () => {
const savePreset = vi.fn();
renderSettingsPanel({ savePreset });
const presetsTab = screen.getByRole('button', { name: 'Presets' });
fireEvent.click(presetsTab);
const presetNameInput = screen.getByPlaceholderText('Preset name...');
fireEvent.change(presetNameInput, { target: { value: 'My Preset' } });
// The save button has an SVG icon (Save) and no text
const saveButton = screen.getAllByRole('button').find((b) =>
b.querySelector('svg') !== null && b.textContent === ''
);
expect(saveButton).toBeDefined();
fireEvent.click(saveButton!);
expect(savePreset).toHaveBeenCalledWith('My Preset');
});

it('does not save preset with empty name', () => {
const savePreset = vi.fn();
renderSettingsPanel({ savePreset });
const presetsTab = screen.getByRole('button', { name: 'Presets' });
fireEvent.click(presetsTab);
const saveButton = screen.getAllByRole('button').find((b) =>
b.querySelector('svg') !== null && b.textContent === ''
);
fireEvent.click(saveButton!);
expect(savePreset).not.toHaveBeenCalled();
});
});
24 changes: 24 additions & 0 deletions src/components/ShotLog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { ShotLog } from './ShotLog';

afterEach(() => cleanup());

describe('ShotLog', () => {
it('renders without crashing', () => {
render(<ShotLog />);
expect(screen.getByText('Shot Log')).toBeDefined();
});

it('displays session count', () => {
render(<ShotLog />);
expect(screen.getByText(/sessions recorded/)).toBeDefined();
});

it('toggles entry expansion', () => {
render(<ShotLog />);
const proEntry = screen.getByText('PRO');
fireEvent.click(proEntry.closest('button')!);
expect(screen.getByText('Golden Gate Bridge, SF')).toBeDefined();
});
});
Loading
Loading