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
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ See `.llm/procedure/` for workflows (e.g., `import-tasks-github.md`)

## Coding Best Practices

### Testing Requirements
**CRITICAL:** Before implementing any feature:
1. **Run existing tests:** `npm run test:e2e` - All tests must pass
2. **Add tests for new features:** Create focused Playwright tests in `e2e/`
3. **Regression testing:** New implementations shouldn't break old tests unless it's a complete change of functionality
4. **Test file naming:** Match functionality (e.g., `image-upload.spec.ts` for image upload features)
5. **Keep tests focused:** Each test should verify one specific behavior

**When to add tests:**
- New user-facing features (camera, gallery, import/export)
- Critical workflows (photo capture, data persistence)
- Bug fixes (prevent regression)

**Test structure:** Use page objects (`e2e/utils/page-objects.ts`), test data generators (`e2e/utils/test-data.ts`), and fixtures.

### Component Composition
**DO:** Use shared from `src/components/common/` (DialogHeader, EmptyStateDisplay, ConfirmationDialog, EntityContextMenu, LensSelector, ApertureSelector, ShutterSpeedSelector). Extract when pattern appears 2+ times.

Expand Down Expand Up @@ -98,14 +113,14 @@ npm run version:{patch|minor|major}

### Testing
```bash
npm run test:e2e # 80 tests across 5 browsers
npm run test:e2e # All E2E tests across 5 browsers
npm run test:e2e:ui # Interactive mode
npm run test:e2e:headed # See browser
npm run test:e2e:debug # Inspector
npm run test:e2e:report # HTML report
```

**Test files:** app-navigation, camera-management, film-roll-management, photography-workflow
**Test files:** app-navigation, camera-management, film-roll-management, photography-workflow, lens-management, import-export, image-upload
**Browsers:** Chromium, Firefox, WebKit, Mobile Chrome, Mobile Safari
**Page Objects:** `e2e/utils/page-objects.ts`

Expand Down
167 changes: 167 additions & 0 deletions e2e/image-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { test, expect } from './fixtures/test-fixtures';
import { TEST_DATA } from './utils/test-data';

/**
* Image Upload Tests
* Tests gallery image upload functionality and image scaling
*/
test.describe('Image Upload and Scaling', () => {

test('should upload image from gallery and create exposure', async ({ filmTrackerPage, cleanApp, page }) => {
// Create film roll
await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic);

// Verify we're in camera screen
await expect(filmTrackerPage.cameraButton).toBeVisible();
await expect(filmTrackerPage.page.getByText(/1\/36/)).toBeVisible();

// Navigate to gallery screen
await filmTrackerPage.galleryButton.click();
await expect(filmTrackerPage.page.getByText(/No exposures yet/)).toBeVisible();

// Create a test image file (small 10x10 red square)
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC',
'base64'
);

// Set up file chooser listener before clicking Add From Gallery button
const fileChooserPromise = page.waitForEvent('filechooser');

// Click Add From Gallery button to trigger file picker
await filmTrackerPage.addFromGalleryButton.click();

const fileChooser = await fileChooserPromise;

// Upload the test image
await fileChooser.setFiles({
name: 'test-image.png',
mimeType: 'image/png',
buffer: testImageBuffer
});

// Wait for exposure to be created and verify it appears in the gallery
await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 });
});

test('should upload multiple images from gallery', async ({ filmTrackerPage, cleanApp, page }) => {
// Create film roll
await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic);

// Navigate to gallery screen
await filmTrackerPage.galleryButton.click();

const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC',
'base64'
);

// Upload first image
const fileChooserPromise1 = page.waitForEvent('filechooser');
await filmTrackerPage.addFromGalleryButton.click();
const fileChooser1 = await fileChooserPromise1;
await fileChooser1.setFiles({
name: 'test-image-1.png',
mimeType: 'image/png',
buffer: testImageBuffer
});
await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 });

// Upload second image
const fileChooserPromise2 = page.waitForEvent('filechooser');
await filmTrackerPage.addFromGalleryButton.click();
const fileChooser2 = await fileChooserPromise2;
await fileChooser2.setFiles({
name: 'test-image-2.png',
mimeType: 'image/png',
buffer: testImageBuffer
});
await expect(filmTrackerPage.page.getByText(/#2/)).toBeVisible({ timeout: 5000 });

// Verify both exposures are visible in gallery
await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible();
await expect(filmTrackerPage.page.getByText(/#2/)).toBeVisible();
});

test('should replace image in exposure details', async ({ filmTrackerPage, cleanApp, page }) => {
// Create film roll
await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic);

// Navigate to gallery screen
await filmTrackerPage.galleryButton.click();

const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC',
'base64'
);

// Upload first image
const fileChooserPromise1 = page.waitForEvent('filechooser');
await filmTrackerPage.addFromGalleryButton.click();
const fileChooser1 = await fileChooserPromise1;
await fileChooser1.setFiles({
name: 'original.png',
mimeType: 'image/png',
buffer: testImageBuffer
});
await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 });

// Click on exposure to open details
await filmTrackerPage.page.getByText(/#1/).click();
await expect(filmTrackerPage.page.getByText(/Exposure #1/)).toBeVisible();

// Click edit button (first icon button in the header actions area)
const editButton = filmTrackerPage.page.locator('button:has(svg[data-testid="EditIcon"])');
await editButton.click();

// Click gallery button in the image overlay
const fileChooserPromise2 = page.waitForEvent('filechooser');
const galleryButton = filmTrackerPage.page.locator('button:has(svg[data-testid="PhotoLibraryIcon"])');
await galleryButton.click();
const fileChooser2 = await fileChooserPromise2;

// Upload replacement image
await fileChooser2.setFiles({
name: 'replacement.png',
mimeType: 'image/png',
buffer: testImageBuffer
});

// Wait a moment for the image to be processed
await page.waitForTimeout(500);

// Save changes
await filmTrackerPage.page.getByRole('button', { name: /save/i }).click();

// Verify we're still on details screen (image was replaced successfully)
await expect(filmTrackerPage.page.getByText(/Exposure #1/)).toBeVisible();
});

test('should handle large image upload gracefully', async ({ filmTrackerPage, cleanApp, page }) => {
// Create film roll
await filmTrackerPage.createFilmRoll(TEST_DATA.filmRolls.basic);

// Navigate to gallery screen
await filmTrackerPage.galleryButton.click();

// Create a larger test image (100x100 PNG)
// This is still small for testing, but larger than the 10x10 used in other tests
const largeImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAXklEQVR42u3BAQ0AAADCoPdPbQ8HFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwZMgAAAenKbBIAAAAASUVORK5CYII=',
'base64'
);

const fileChooserPromise = page.waitForEvent('filechooser');
await filmTrackerPage.addFromGalleryButton.click();
const fileChooser = await fileChooserPromise;

await fileChooser.setFiles({
name: 'large-test-image.png',
mimeType: 'image/png',
buffer: largeImageBuffer
});

// Should successfully create exposure even with larger image
await expect(filmTrackerPage.page.getByText(/#1/)).toBeVisible({ timeout: 5000 });
});
});
8 changes: 8 additions & 0 deletions e2e/utils/page-objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ export class FilmTrackerPage {
return this.page.getByRole('button', { name: /view gallery/i });
}

get addFromGalleryButton() {
return this.page.getByRole('button', { name: /add from gallery/i });
}

get apertureChip() {
return this.page.locator('.MuiChip-root').filter({ hasText: /f\// }).first();
}
Expand Down Expand Up @@ -176,6 +180,10 @@ export class FilmTrackerPage {
camera?: string;
}) {
await this.createFilmRollButton.click();

// Wait for the dialog/form to be visible
await this.filmNameInput.waitFor({ state: 'visible', timeout: 5000 });

await this.filmNameInput.fill(data.name);

if (data.iso) {
Expand Down
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,9 @@ function App() {
onExposureUpdate={handleExposureUpdate}
onBack={() => navigateToScreen('camera')}
onHome={() => navigateToScreen('filmrolls')}
onDataImported={handleDataImported}
onExposureTaken={handleExposureTaken}
currentSettings={exposureSettings}
setCurrentSettings={setExposureSettings}
/>
);

Expand Down
90 changes: 1 addition & 89 deletions src/components/CameraScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
Close,
ArrowBack
} from '@mui/icons-material';
import { camera, geolocation, fileUtils } from '../utils/camera';
import { camera, geolocation } from '../utils/camera';
import type { FilmRoll, Exposure, ExposureSettings, Lens } from '../types';
import { APERTURE, APERTURE_VALUES, SHUTTER_SPEED, SHUTTER_SPEED_VALUES, EI_VALUES } from '../types';
import { FocalLengthSlider } from './FocalLengthSlider';
Expand Down Expand Up @@ -73,7 +73,6 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
setCurrentSettings
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const streamRef = useRef<MediaStream | null>(null);

const [isCameraActive, setIsCameraActive] = useState(false);
Expand Down Expand Up @@ -246,76 +245,6 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
}
};

const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];

// Clear the input immediately to allow reselection of the same file
const target = event.target;
setTimeout(() => {
target.value = '';
}, 100);

if (!file) {
console.log('No file selected');
return;
}

console.log('Processing file:', file.name, 'Size:', file.size, 'Type:', file.type);

try {
// Validate file before processing
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}

if (file.size > 10 * 1024 * 1024) { // 10MB limit
alert('Image too large. Please select a smaller image.');
return;
}

const imageData = await fileUtils.fileToBase64(file);

if (!imageData || imageData.length < 100) {
throw new Error('Invalid image data generated');
}

console.log('Image processed successfully, size:', imageData.length);

const location = geolocation.isSupported()
? await geolocation.getCurrentPosition().catch((err) => {
console.warn('Location not available:', err);
return undefined;
})
: undefined;

const exposure: Exposure = {
id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // More unique ID
filmRollId: filmRoll.id,
exposureNumber: currentExposureNumber,
aperture: currentSettings.aperture,
shutterSpeed: currentSettings.shutterSpeed,
additionalInfo: currentSettings.additionalInfo,
imageData,
location,
capturedAt: new Date(),
ei: currentSettings.ei,
lensId: currentSettings.lensId,
focalLength: currentSettings.focalLength
};

console.log('Creating exposure:', exposure.id);
onExposureTaken(exposure);

// Reset additional info for next shot
setCurrentSettings(prev => ({ ...prev, additionalInfo: '' }));

} catch (error) {
console.error('Error processing file:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
alert(`Error processing selected image: ${errorMessage}`);
}
};

const openSettingsDialog = () => {
setShowSettingsDialog(true);
Expand Down Expand Up @@ -632,14 +561,6 @@ getUserMedia: ${!!navigator.mediaDevices?.getUserMedia}

{/* Controls */}
<Stack direction="row" spacing={2} alignItems="center" justifyContent="center">
<Button
variant="outlined"
onClick={() => fileInputRef.current?.click()}
startIcon={<PhotoLibrary />}
>
Gallery
</Button>

<Fab
color="primary"
size="large"
Expand Down Expand Up @@ -685,15 +606,6 @@ getUserMedia: ${!!navigator.mediaDevices?.getUserMedia}
</Button>
</Stack>

{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>

{/* Settings Dialog */}
<Dialog open={showSettingsDialog} onClose={() => setShowSettingsDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>
Expand Down
2 changes: 1 addition & 1 deletion src/components/DetailsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const DetailsScreen: React.FC<DetailsScreenProps> = ({

const handleImageChange = async (file: File) => {
try {
const imageData = await fileUtils.fileToBase64(file);
const imageData = await fileUtils.scaleImageFile(file);
setEditedExposure(prev => ({ ...prev, imageData }));
} catch (error) {
console.error('Error processing image:', error);
Expand Down
Loading