From 8780459851d10216b9602fdf1d4da0d93133d8a6 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Sat, 20 Dec 2025 03:29:38 -0500 Subject: [PATCH 1/3] WIP: SLOP that needs validation (1) --- .../shapefile/file-introspection.stories.tsx | 224 ++++++++++++++++++ .../lib/shapefile/file-introspection.test.ts | 194 +++++++++++++++ apps/src/lib/shapefile/file-introspection.ts | 101 ++++++++ apps/src/lib/shapefile/file-loader.test.ts | 184 ++++++++++++++ apps/src/lib/shapefile/file-loader.ts | 167 +++++++++++++ apps/src/lib/shapefile/index.ts | 1 + 6 files changed, 871 insertions(+) create mode 100644 apps/src/lib/shapefile/file-introspection.stories.tsx create mode 100644 apps/src/lib/shapefile/file-introspection.test.ts create mode 100644 apps/src/lib/shapefile/file-introspection.ts create mode 100644 apps/src/lib/shapefile/file-loader.test.ts create mode 100644 apps/src/lib/shapefile/file-loader.ts create mode 100644 apps/src/lib/shapefile/index.ts diff --git a/apps/src/lib/shapefile/file-introspection.stories.tsx b/apps/src/lib/shapefile/file-introspection.stories.tsx new file mode 100644 index 000000000..4f6b46133 --- /dev/null +++ b/apps/src/lib/shapefile/file-introspection.stories.tsx @@ -0,0 +1,224 @@ +/** + * Ladle stories for shapefile file upload - exploring native File API capabilities. + */ + +import type { Story } from '@ladle/react'; +import { useState } from 'react'; +import { + introspectFile, + type FileIntrospection +} from './file-introspection'; + +const FileExplorer = () => { + const [file, setFile] = useState(null); + const [introspection, setIntrospection] = useState( + null + ); + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const files = event.target.files; + if (!files || files.length === 0) { + setFile(null); + setIntrospection(null); + return; + } + + const selectedFile = files[0]; + setFile(selectedFile); + + const result = await introspectFile(selectedFile); + setIntrospection(result); + }; + + return ( +
+

File API Explorer

+

+ Upload any file to explore what the native File API can tell us +

+ + + + {file && introspection && ( +
+
+

+ File Properties (native File object) +

+ + + + + + + + + + + + + + + + + + + +
+ name: + {file.name}
+ size: + {file.size.toLocaleString()} bytes
+ type: + {file.type || '(empty)'}
+ lastModified: + {new Date(file.lastModified).toLocaleString()}
+
+ +
+

Validation (native introspection)

+ + + + + + + + + + + +
+ Is ZIP: + + + {introspection.isZip ? '✓ Yes' : '✗ No'} + +
+ Is Empty: + + + {introspection.isEmpty ? '✗ Yes' : '✓ No'} + +
+
+ +
+

Magic Bytes (first 16 bytes)

+

+ {introspection.firstBytes} +

+

+ ZIP files start with: 50 4b 03 04 (ASCII "PK..") +

+
+ +
+

Raw Data Preview (first 100 bytes)

+
+							{introspection.rawDataPreview}
+						
+
+
+ )} + +
+

Native capabilities (no libraries):

+
    +
  • + Use native File object directly - no need to copy + properties +
  • +
  • + file.arrayBuffer() - read entire file as binary +
  • +
  • Validate ZIP magic bytes (50 4B 03 04) - no library needed
  • +
  • Check if file is empty (size === 0 or buffer.length === 0)
  • +
  • + MIME type (file.type) is often unreliable - prefer + magic bytes +
  • +
+

What requires a library:

+
    +
  • Parsing ZIP central directory to list file entries
  • +
  • Extracting individual files from ZIP archive
  • +
  • Handling compression (DEFLATE, etc.)
  • +
+
+
+ ); +}; + +export const NativeFileAPI: Story = () => ; + +NativeFileAPI.storyName = 'Shapefile File Loader'; diff --git a/apps/src/lib/shapefile/file-introspection.test.ts b/apps/src/lib/shapefile/file-introspection.test.ts new file mode 100644 index 000000000..63d3188af --- /dev/null +++ b/apps/src/lib/shapefile/file-introspection.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for native file introspection. + * + * @module lib/shapefile/file-introspection.test + */ + +import { describe, it, expect } from 'vitest'; +import { introspectFile } from './file-introspection'; + +/** + * Create a mock File object for testing + */ +const createMockFile = ( + name: string, + content: Uint8Array | string, + type = 'application/octet-stream', +): File => { + const blob = new Blob( + [content], + { type }, + ); + return new File( + [blob], + name, + { type }, + ); +}; + +/** + * Create a minimal valid ZIP file (PK magic bytes) + */ +const createMinimalZipFile = (): File => { + // ZIP local file header signature: 50 4B 03 04 + const zipHeader = new Uint8Array([ + 0x50, 0x4B, 0x03, 0x04, // ZIP signature (PK..) + 0x14, 0x00, // Version needed to extract + 0x00, 0x00, // General purpose bit flag + 0x00, 0x00, // Compression method (stored) + 0x00, 0x00, // Last mod file time + 0x00, 0x00, // Last mod file date + 0x00, 0x00, 0x00, 0x00, // CRC-32 + 0x00, 0x00, 0x00, 0x00, // Compressed size + 0x00, 0x00, 0x00, 0x00, // Uncompressed size + ]); + + return createMockFile('test.zip', zipHeader, 'application/zip'); +}; + +describe('introspectFile', () => { + describe('ZIP detection', () => { + it('should detect valid ZIP files by magic bytes', async () => { + const file = createMinimalZipFile(); + const result = await introspectFile(file); + + expect(result.isZip).toBe(true); + expect(result.isEmpty).toBe(false); + expect(result.firstBytes).toMatch(/^50 4b 03 04/); + }); + + it('should reject files without ZIP magic bytes', async () => { + const file = createMockFile( + 'test.txt', + 'This is just text content', + 'text/plain', + ); + const result = await introspectFile(file); + + expect(result.isZip).toBe(false); + expect(result.isEmpty).toBe(false); + }); + + it('should reject files renamed to .zip without ZIP content', async () => { + const file = createMockFile( + 'fake.zip', + 'not a real zip file', + 'application/zip', + ); + const result = await introspectFile(file); + + expect(result.isZip).toBe(false); + // MIME type is unreliable - we check magic bytes + }); + + it('should reject files too small to have ZIP header', async () => { + const file = createMockFile( + 'tiny.zip', + new Uint8Array([0x50, 0x4B]), // Only 2 bytes + ); + const result = await introspectFile(file); + + expect(result.isZip).toBe(false); + // Need at least 4 bytes for magic number + }); + }); + + describe('empty file detection', () => { + it('should detect empty files', async () => { + const file = createMockFile('empty.zip', new Uint8Array([])); + const result = await introspectFile(file); + + expect(result.isEmpty).toBe(true); + expect(result.isZip).toBe(false); + expect(result.firstBytes).toBe(''); + }); + + it('should not flag non-empty files as empty', async () => { + const file = createMinimalZipFile(); + const result = await introspectFile(file); + + expect(result.isEmpty).toBe(false); + }); + }); + + describe('diagnostic data', () => { + it('should provide first 16 bytes as hex string', async () => { + const data = new Uint8Array([ + 0x50, 0x4B, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xDD, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + ]); + const file = createMockFile('test.zip', data); + const result = await introspectFile(file); + + expect(result.firstBytes).toBe( + '50 4b 03 04 aa bb cc dd 11 22 33 44 55 66 77 88' + ); + }); + + it('should handle files smaller than 16 bytes', async () => { + const data = new Uint8Array([0x50, 0x4B, 0x03, 0x04]); + const file = createMockFile('small.zip', data); + const result = await introspectFile(file); + + expect(result.firstBytes).toBe('50 4b 03 04'); + }); + + it('should provide raw data preview with escaped non-printable chars', async () => { + // Mix of printable and non-printable characters + const data = new Uint8Array([ + 0x50, 0x4B, 0x03, 0x04, // PK.. (P and K are printable, 0x03 and 0x04 are not) + 0x48, 0x65, 0x6C, 0x6C, 0x6F, // "Hello" + 0x00, 0x01, 0x02, // Non-printable + ]); + const file = createMockFile('test.zip', data); + const result = await introspectFile(file); + + expect(result.rawDataPreview).toContain('PK'); + expect(result.rawDataPreview).toContain('Hello'); + expect(result.rawDataPreview).toContain('\\x03'); + expect(result.rawDataPreview).toContain('\\x04'); + expect(result.rawDataPreview).toContain('\\x00'); + }); + + it('should limit preview to first 100 bytes', async () => { + const data = new Uint8Array(200).fill(0x41); // 200 'A' characters + const file = createMockFile('large.txt', data); + const result = await introspectFile(file); + + // Preview should only show first 100 characters + expect(result.rawDataPreview.length).toBeLessThanOrEqual(100); + }); + }); + + describe('edge cases', () => { + it('should handle binary data correctly', async () => { + const data = new Uint8Array(256); + // Fill with all possible byte values + for (let i = 0; i < 256; i++) { + data[i] = i; + } + + const file = createMockFile('binary.dat', data); + const result = await introspectFile(file); + + expect(result).toHaveProperty('isZip'); + expect(result).toHaveProperty('isEmpty'); + expect(result).toHaveProperty('firstBytes'); + expect(result).toHaveProperty('rawDataPreview'); + }); + + it('should handle UTF-8 text files', async () => { + const file = createMockFile( + 'text.txt', + 'Hello 世界 🌍', + 'text/plain', + ); + const result = await introspectFile(file); + + expect(result.isZip).toBe(false); + expect(result.isEmpty).toBe(false); + // Text decoder should handle UTF-8 + expect(result.rawDataPreview).toContain('Hello'); + }); + }); +}); diff --git a/apps/src/lib/shapefile/file-introspection.ts b/apps/src/lib/shapefile/file-introspection.ts new file mode 100644 index 000000000..0ab2ecd6e --- /dev/null +++ b/apps/src/lib/shapefile/file-introspection.ts @@ -0,0 +1,101 @@ +/** + * Native file introspection utilities. + * + * Validates file type and basic properties using only native File API. + * No external libraries required. + */ + +/** + * Results from native file introspection. + */ +export interface FileIntrospection { + /** + * Whether file has valid ZIP magic bytes (50 4B 03 04) + */ + isZip: boolean; + + /** + * Whether file is empty (zero bytes) + */ + isEmpty: boolean; + + /** + * First 16 bytes as hex string (for diagnostics) + */ + firstBytes: string; + + /** + * Raw data preview (first 100 bytes, escaped) + */ + rawDataPreview: string; +} + +/** + * Introspect file using only native File API. + * + * This function performs basic validation without external libraries: + * - Checks ZIP magic bytes (50 4B 03 04 = "PK..") + * - Detects empty files + * - Provides diagnostic data for debugging + * + * Use this for initial file type validation before passing to + * libraries like JSZip for actual extraction. + * + * @param file - File to introspect + * @returns Introspection results with validation status + * + * @example + * ```typescript + * const file = fileInput.files[0]; + * const info = await introspectFile(file); + * + * if (!info.isZip) { + * throw new Error('File must be a ZIP archive'); + * } + * + * if (info.isEmpty) { + * throw new Error('File is empty'); + * } + * ``` + */ +export const introspectFile = async (file: File): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + + // Check if file is empty + const isEmpty = bytes.length === 0; + + // Read first 16 bytes for magic byte detection + const headerBytes = bytes.slice(0, 16); + const firstBytes = Array.from(headerBytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(' '); + + // ZIP magic bytes: 50 4B 03 04 (PK..) + const isZip = bytes.length >= 4 && + bytes[0] === 0x50 && + bytes[1] === 0x4B && + bytes[2] === 0x03 && + bytes[3] === 0x04; + + // Raw data preview for visualization + const decoder = new TextDecoder('utf-8', { fatal: false }); + const previewBytes = bytes.slice(0, 100); + const textPreview = decoder.decode(previewBytes); + const rawDataPreview = textPreview + .split('') + .map(char => { + const code = char.charCodeAt(0); + return code < 32 || code > 126 + ? `\\x${code.toString(16).padStart(2, '0')}` + : char; + }) + .join(''); + + return { + isZip, + isEmpty, + firstBytes, + rawDataPreview, + }; +}; diff --git a/apps/src/lib/shapefile/file-loader.test.ts b/apps/src/lib/shapefile/file-loader.test.ts new file mode 100644 index 000000000..188b4867f --- /dev/null +++ b/apps/src/lib/shapefile/file-loader.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for shapefile file loading and extraction. + * + * @module lib/shapefile/file-loader.test + */ + +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { + loadShapeFile, + ShapefileLoadError, +} from './file-loader'; + +/** + * Create a mock File object for testing + */ +const createMockFile = ( + name: string, + content: ArrayBuffer | string, + type = 'application/zip', +): File => { + const blob = new Blob( + [content], + { type }, + ); + return new File( + [blob], + name, + { type }, + ); +}; + +/** + * Create a valid ZIP file containing .shp and .prj files + */ +const createValidShapefileZip = async (): Promise => { + const zip = new JSZip(); + + // Mock binary .shp file + const shpData = new Uint8Array([ + 0x00, 0x00, 0x27, 0x0a, // File code + 0x00, 0x00, 0x00, 0x00, // Unused + ]); + zip.file('test.shp', shpData); + + // Mock .prj file (WGS84 projection) + const prjData = 'GEOGCS["WGS 84",DATUM["WGS_1984"]]'; + zip.file('test.prj', prjData); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + return createMockFile('test.zip', zipBlob); +}; + +describe('loadShapeFile', () => { + describe('happy path', () => { + it('should extract .shp and .prj files from valid ZIP', async () => { + const file = await createValidShapefileZip(); + const result = await loadShapeFile(file); + + expect(result).toHaveProperty('file.shp'); + expect(result).toHaveProperty('file.prj'); + expect(result['file.shp']).toBeInstanceOf(ArrayBuffer); + expect(typeof result['file.prj']).toBe('string'); + }); + + it('should ignore other files in ZIP (dbf, shx, etc)', async () => { + const zip = new JSZip(); + zip.file('test.shp', new Uint8Array([0x00])); + zip.file('test.prj', 'GEOGCS["WGS 84"]'); + zip.file('test.dbf', 'ignored data'); + zip.file('test.shx', 'ignored data'); + zip.file('readme.txt', 'ignored data'); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('test.zip', zipBlob); + + const result = await loadShapeFile(file); + + // Should only have .shp and .prj + expect(Object.keys(result)).toHaveLength(2); + expect(result).toHaveProperty('file.shp'); + expect(result).toHaveProperty('file.prj'); + }); + }); + + describe('error cases', () => { + it('should reject non-ZIP files', async () => { + const file = createMockFile( + 'test.txt', + 'not a zip', + 'text/plain', + ); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('must be a ZIP file'); + }); + + it('should reject ZIP without .shp file', async () => { + const zip = new JSZip(); + zip.file('test.prj', 'GEOGCS["WGS 84"]'); + zip.file('readme.txt', 'data'); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('test.zip', zipBlob); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('must contain both .shp and .prj'); + }); + + it('should reject ZIP without .prj file', async () => { + const zip = new JSZip(); + zip.file('test.shp', new Uint8Array([0x00])); + zip.file('readme.txt', 'data'); + + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('test.zip', zipBlob); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('must contain both .shp and .prj'); + }); + + it('should reject empty ZIP', async () => { + const zip = new JSZip(); + const zipBlob = await zip.generateAsync({ type: 'arraybuffer' }); + const file = createMockFile('empty.zip', zipBlob); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('does not contain a valid shapefile'); + }); + + it('should reject invalid ZIP data', async () => { + const file = createMockFile( + 'corrupt.zip', + 'not valid zip data', + ); + + await expect(loadShapeFile(file)) + .rejects + .toThrow(ShapefileLoadError); + + await expect(loadShapeFile(file)) + .rejects + .toThrow('Failed to read ZIP file'); + }); + }); + + describe('ShapefileLoadError', () => { + it('should have correct name for debugging', () => { + const error = new ShapefileLoadError('test error'); + expect(error.name).toBe('ShapefileLoadError'); + expect(error.message).toBe('test error'); + }); + + it('should support error chaining', () => { + const cause = new Error('underlying error'); + const error = new ShapefileLoadError( + 'wrapper error', + { cause }, + ); + + expect(error.cause).toBe(cause); + }); + }); +}); diff --git a/apps/src/lib/shapefile/file-loader.ts b/apps/src/lib/shapefile/file-loader.ts new file mode 100644 index 000000000..44d1df8d2 --- /dev/null +++ b/apps/src/lib/shapefile/file-loader.ts @@ -0,0 +1,167 @@ +/** + * Shapefile file loading and extraction utilities. + * + * Extracts .shp (binary geometry) and .prj (projection) files from ZIP archives. + * + * @module lib/shapefile/file-loader + */ + +import JSZip from 'jszip'; + +/** + * Extracted shapefile data. + */ +export interface ShapefileData { + /** + * Binary shapefile geometry data + */ + 'file.shp': ArrayBuffer; + /** + * Projection definition string + */ + 'file.prj': string; +} + +/** + * Custom error for shapefile loading failures. + */ +export class ShapefileLoadError extends Error { + constructor( + message: string, + options?: ErrorOptions, + ) { + super(message, options); + // Explicit name - survives minification + this.name = 'ShapefileLoadError'; + } +} + +/** + * Validates file extension is .zip + * + * @param file - File to validate + * @throws {ShapefileLoadError} If file extension is not .zip + */ +const assertIsZipFile = ( + file: File, +): void => { + const fileExt = file.name + .toLowerCase() + .split('.') + .pop(); + + if (fileExt !== 'zip') { + throw new ShapefileLoadError( + 'Shapefile must be a ZIP file containing at least .shp and .prj files', + ); + } +}; + +/** + * Validates extracted data contains required files + * + * @param data - Extracted shapefile data + * @throws {ShapefileLoadError} If required files are missing + */ +const assertHasRequiredFiles = ( + data: Partial, +): asserts data is ShapefileData => { + if (!data['file.shp'] || !data['file.prj']) { + throw new ShapefileLoadError( + 'ZIP file must contain both .shp and .prj files', + ); + } +}; + +/** + * Load and extract shapefile data from a ZIP file. + * + * A valid shapefile ZIP must contain: + * - `.shp` file: Binary geometry data + * - `.prj` file: Projection definition + * + * Other files in the ZIP are ignored to minimize data exposure. + * + * @param file - ZIP file containing shapefile data + * @returns Promise resolving to extracted shapefile data + * @throws {ShapefileLoadError} If file is invalid or missing required files + * + * @example + * ```typescript + * const fileInput = document.querySelector('input[type="file"]'); + * const file = fileInput.files[0]; + * + * try { + * const data = await loadShapeFile(file); + * console.log('Loaded shapefile:', data); + * } catch (error) { + * if (error instanceof ShapefileLoadError) { + * console.error('Invalid shapefile:', error.message); + * } + * } + * ``` + */ +export const loadShapeFile = async ( + file: File, +): Promise => { + assertIsZipFile(file); + + try { + const arrayBuffer = await file.arrayBuffer(); + const zip = await JSZip.loadAsync(arrayBuffer); + + const data: Partial = {}; + const promises: Promise[] = []; + + // Extract only .shp and .prj files + for (const filename in zip.files) { + const extension = filename + .toLowerCase() + .split('.') + .pop(); + + if (extension === 'shp') { + const promise = zip.files[filename] + .async('arraybuffer') + .then((buffer) => { + data['file.shp'] = buffer; + }); + promises.push(promise); + } else if (extension === 'prj') { + const promise = zip.files[filename] + .async('string') + .then((content) => { + data['file.prj'] = content; + }); + promises.push(promise); + } + } + + if (promises.length === 0) { + throw new ShapefileLoadError( + 'ZIP file does not contain a valid shapefile', + ); + } + + await Promise.all(promises); + assertHasRequiredFiles(data); + + return data; + } catch (error) { + if (error instanceof ShapefileLoadError) { + throw error; + } + + // JSZip or other errors + if (error instanceof Error) { + throw new ShapefileLoadError( + 'Failed to read ZIP file', + { cause: error }, + ); + } + + throw new ShapefileLoadError( + 'An unexpected error occurred while loading shapefile', + ); + } +}; diff --git a/apps/src/lib/shapefile/index.ts b/apps/src/lib/shapefile/index.ts new file mode 100644 index 000000000..0af0c8e7d --- /dev/null +++ b/apps/src/lib/shapefile/index.ts @@ -0,0 +1 @@ +export * from './file-loader'; From 5b4142894269c3404d8b2c17bd090965ac1d9104 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Sat, 20 Dec 2025 03:38:35 -0500 Subject: [PATCH 2/3] WIP: SLOP that needs validation (2) --- .../lib/shapefile/file-introspection.test.ts | 192 ++++++------------ apps/src/lib/shapefile/file-introspection.ts | 24 ++- apps/src/lib/shapefile/file-loader.test.ts | 31 +-- apps/src/lib/shapefile/file-loader.ts | 18 +- apps/src/lib/shapefile/index.ts | 1 + 5 files changed, 104 insertions(+), 162 deletions(-) diff --git a/apps/src/lib/shapefile/file-introspection.test.ts b/apps/src/lib/shapefile/file-introspection.test.ts index 63d3188af..169556754 100644 --- a/apps/src/lib/shapefile/file-introspection.test.ts +++ b/apps/src/lib/shapefile/file-introspection.test.ts @@ -1,194 +1,128 @@ /** - * Tests for native file introspection. + * Tests for file introspection validation logic. * - * @module lib/shapefile/file-introspection.test + * Focus: Test our validation decisions (isZip, isEmpty) based on byte content. + * Not tested: Browser File API, TextDecoder, or platform string operations. + * + * We mock File to provide controlled byte sequences, then verify our logic + * correctly identifies ZIP magic bytes and empty files. */ import { describe, it, expect } from 'vitest'; import { introspectFile } from './file-introspection'; /** - * Create a mock File object for testing + * Mock File with controlled byte content. + * + * Provides only what our code needs: arrayBuffer() returning specific bytes. + * This isolates our validation logic from platform File API implementation. */ const createMockFile = ( name: string, content: Uint8Array | string, type = 'application/octet-stream', ): File => { - const blob = new Blob( - [content], - { type }, - ); - return new File( - [blob], + const buffer = + typeof content === 'string' + ? new TextEncoder().encode(content).buffer + : content.buffer; + + const file = { name, - { type }, - ); -}; + size: buffer.byteLength, + type, + arrayBuffer: async (): Promise => buffer, + } as File; -/** - * Create a minimal valid ZIP file (PK magic bytes) - */ -const createMinimalZipFile = (): File => { - // ZIP local file header signature: 50 4B 03 04 - const zipHeader = new Uint8Array([ - 0x50, 0x4B, 0x03, 0x04, // ZIP signature (PK..) - 0x14, 0x00, // Version needed to extract - 0x00, 0x00, // General purpose bit flag - 0x00, 0x00, // Compression method (stored) - 0x00, 0x00, // Last mod file time - 0x00, 0x00, // Last mod file date - 0x00, 0x00, 0x00, 0x00, // CRC-32 - 0x00, 0x00, 0x00, 0x00, // Compressed size - 0x00, 0x00, 0x00, 0x00, // Uncompressed size - ]); - - return createMockFile('test.zip', zipHeader, 'application/zip'); + return file; }; describe('introspectFile', () => { - describe('ZIP detection', () => { - it('should detect valid ZIP files by magic bytes', async () => { - const file = createMinimalZipFile(); + describe('validation logic - ZIP detection', () => { + it('should detect ZIP magic bytes: 50 4B 03 04', async () => { + const zipBytes = new Uint8Array([ + 0x50, + 0x4b, + 0x03, + 0x04, + ]); + const file = createMockFile('test.zip', zipBytes); const result = await introspectFile(file); + // Our validation logic expect(result.isZip).toBe(true); - expect(result.isEmpty).toBe(false); - expect(result.firstBytes).toMatch(/^50 4b 03 04/); }); - it('should reject files without ZIP magic bytes', async () => { - const file = createMockFile( - 'test.txt', - 'This is just text content', - 'text/plain', - ); + it('should reject non-ZIP magic bytes', async () => { + const file = createMockFile('test.txt', 'plain text', 'text/plain'); const result = await introspectFile(file); + // Our validation logic expect(result.isZip).toBe(false); - expect(result.isEmpty).toBe(false); }); - it('should reject files renamed to .zip without ZIP content', async () => { - const file = createMockFile( - 'fake.zip', - 'not a real zip file', - 'application/zip', - ); + it('should reject files with incomplete magic bytes', async () => { + const twoBytes = new Uint8Array([0x50, 0x4b]); + const file = createMockFile('tiny.zip', twoBytes); const result = await introspectFile(file); + // Our validation logic - need all 4 bytes expect(result.isZip).toBe(false); - // MIME type is unreliable - we check magic bytes }); - it('should reject files too small to have ZIP header', async () => { - const file = createMockFile( - 'tiny.zip', - new Uint8Array([0x50, 0x4B]), // Only 2 bytes - ); + it('should ignore MIME type and check bytes only', async () => { + // File claims to be ZIP but has wrong bytes + const file = createMockFile('fake.zip', 'not a zip', 'application/zip'); const result = await introspectFile(file); + // Our validation logic - MIME type ignored expect(result.isZip).toBe(false); - // Need at least 4 bytes for magic number }); }); - describe('empty file detection', () => { + describe('validation logic - empty file detection', () => { it('should detect empty files', async () => { - const file = createMockFile('empty.zip', new Uint8Array([])); + const emptyBytes = new Uint8Array([]); + const file = createMockFile('empty.zip', emptyBytes); const result = await introspectFile(file); + // Our validation logic expect(result.isEmpty).toBe(true); expect(result.isZip).toBe(false); - expect(result.firstBytes).toBe(''); }); it('should not flag non-empty files as empty', async () => { - const file = createMinimalZipFile(); + const someBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + const file = createMockFile('test.zip', someBytes); const result = await introspectFile(file); + // Our validation logic expect(result.isEmpty).toBe(false); - }); - }); - - describe('diagnostic data', () => { - it('should provide first 16 bytes as hex string', async () => { - const data = new Uint8Array([ - 0x50, 0x4B, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xDD, - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, - ]); - const file = createMockFile('test.zip', data); - const result = await introspectFile(file); - - expect(result.firstBytes).toBe( - '50 4b 03 04 aa bb cc dd 11 22 33 44 55 66 77 88' - ); - }); - it('should handle files smaller than 16 bytes', async () => { - const data = new Uint8Array([0x50, 0x4B, 0x03, 0x04]); - const file = createMockFile('small.zip', data); - const result = await introspectFile(file); - - expect(result.firstBytes).toBe('50 4b 03 04'); - }); - - it('should provide raw data preview with escaped non-printable chars', async () => { - // Mix of printable and non-printable characters - const data = new Uint8Array([ - 0x50, 0x4B, 0x03, 0x04, // PK.. (P and K are printable, 0x03 and 0x04 are not) - 0x48, 0x65, 0x6C, 0x6C, 0x6F, // "Hello" - 0x00, 0x01, 0x02, // Non-printable - ]); - const file = createMockFile('test.zip', data); - const result = await introspectFile(file); - - expect(result.rawDataPreview).toContain('PK'); - expect(result.rawDataPreview).toContain('Hello'); - expect(result.rawDataPreview).toContain('\\x03'); - expect(result.rawDataPreview).toContain('\\x04'); - expect(result.rawDataPreview).toContain('\\x00'); - }); - - it('should limit preview to first 100 bytes', async () => { - const data = new Uint8Array(200).fill(0x41); // 200 'A' characters - const file = createMockFile('large.txt', data); - const result = await introspectFile(file); - - // Preview should only show first 100 characters - expect(result.rawDataPreview.length).toBeLessThanOrEqual(100); }); }); - describe('edge cases', () => { - it('should handle binary data correctly', async () => { - const data = new Uint8Array(256); - // Fill with all possible byte values - for (let i = 0; i < 256; i++) { - data[i] = i; - } + describe('diagnostic data format - platform-dependent', () => { + // These tests verify format/structure but rely on platform APIs + // (TextDecoder, Array.from, string operations) + // We test the contract, not the platform implementation - const file = createMockFile('binary.dat', data); + it('should return hex string format for firstBytes', async () => { + const data = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + const file = createMockFile('test.zip', data); const result = await introspectFile(file); - expect(result).toHaveProperty('isZip'); - expect(result).toHaveProperty('isEmpty'); - expect(result).toHaveProperty('firstBytes'); - expect(result).toHaveProperty('rawDataPreview'); + // Format: space-separated hex pairs + expect(result.firstBytes).toBe('50 4b 03 04'); }); - it('should handle UTF-8 text files', async () => { - const file = createMockFile( - 'text.txt', - 'Hello 世界 🌍', - 'text/plain', - ); + it('should return string for rawDataPreview', async () => { + const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" + const file = createMockFile('test.txt', data); const result = await introspectFile(file); - expect(result.isZip).toBe(false); - expect(result.isEmpty).toBe(false); - // Text decoder should handle UTF-8 - expect(result.rawDataPreview).toContain('Hello'); + // Returns a string (platform handles decoding) + expect(typeof result.rawDataPreview).toBe('string'); }); }); }); diff --git a/apps/src/lib/shapefile/file-introspection.ts b/apps/src/lib/shapefile/file-introspection.ts index 0ab2ecd6e..ac0d257bd 100644 --- a/apps/src/lib/shapefile/file-introspection.ts +++ b/apps/src/lib/shapefile/file-introspection.ts @@ -58,7 +58,9 @@ export interface FileIntrospection { * } * ``` */ -export const introspectFile = async (file: File): Promise => { +export const introspectFile = async ( + file: File, +): Promise => { const arrayBuffer = await file.arrayBuffer(); const bytes = new Uint8Array(arrayBuffer); @@ -68,24 +70,26 @@ export const introspectFile = async (file: File): Promise => // Read first 16 bytes for magic byte detection const headerBytes = bytes.slice(0, 16); const firstBytes = Array.from(headerBytes) - .map(b => b.toString(16).padStart(2, '0')) + .map((b) => b.toString(16).padStart(2, '0')) .join(' '); - // ZIP magic bytes: 50 4B 03 04 (PK..) - const isZip = bytes.length >= 4 && - bytes[0] === 0x50 && - bytes[1] === 0x4B && - bytes[2] === 0x03 && - bytes[3] === 0x04; + // ZIP magic bytes: 50 4B 03 04 ("PK" for Phil Katz, creator of PKZIP) + const isZip = + bytes.length >= 4 && + bytes[0] === 0x50 && + bytes[1] === 0x4b && + bytes[2] === 0x03 && + bytes[3] === 0x04; - // Raw data preview for visualization + // Raw data preview for visualization - escape non-printable characters const decoder = new TextDecoder('utf-8', { fatal: false }); const previewBytes = bytes.slice(0, 100); const textPreview = decoder.decode(previewBytes); const rawDataPreview = textPreview .split('') - .map(char => { + .map((char) => { const code = char.charCodeAt(0); + // Non-printable characters (below space or above tilde) return code < 32 || code > 126 ? `\\x${code.toString(16).padStart(2, '0')}` : char; diff --git a/apps/src/lib/shapefile/file-loader.test.ts b/apps/src/lib/shapefile/file-loader.test.ts index 188b4867f..25e36daa2 100644 --- a/apps/src/lib/shapefile/file-loader.test.ts +++ b/apps/src/lib/shapefile/file-loader.test.ts @@ -1,7 +1,9 @@ /** * Tests for shapefile file loading and extraction. * - * @module lib/shapefile/file-loader.test + * These tests verify our extraction logic and error handling, + * not the browser File API. We mock File.arrayBuffer() to test + * our code's behavior with different inputs. */ import { describe, it, expect } from 'vitest'; @@ -12,25 +14,30 @@ import { } from './file-loader'; /** - * Create a mock File object for testing + * Create a mock File with controlled arrayBuffer() response. + * + * This allows us to test our JSZip integration and validation + * logic without relying on browser File API implementation. */ const createMockFile = ( name: string, content: ArrayBuffer | string, type = 'application/zip', ): File => { - const blob = new Blob( - [content], - { type }, - ); - return new File( - [blob], + const buffer = + typeof content === 'string' + ? new TextEncoder().encode(content).buffer + : content; + + const file = { name, - { type }, - ); -}; + size: buffer.byteLength, + type, + arrayBuffer: async (): Promise => buffer, + } as File; -/** + return file; +};/** * Create a valid ZIP file containing .shp and .prj files */ const createValidShapefileZip = async (): Promise => { diff --git a/apps/src/lib/shapefile/file-loader.ts b/apps/src/lib/shapefile/file-loader.ts index 44d1df8d2..34677b28f 100644 --- a/apps/src/lib/shapefile/file-loader.ts +++ b/apps/src/lib/shapefile/file-loader.ts @@ -2,8 +2,6 @@ * Shapefile file loading and extraction utilities. * * Extracts .shp (binary geometry) and .prj (projection) files from ZIP archives. - * - * @module lib/shapefile/file-loader */ import JSZip from 'jszip'; @@ -45,12 +43,11 @@ export class ShapefileLoadError extends Error { const assertIsZipFile = ( file: File, ): void => { - const fileExt = file.name - .toLowerCase() - .split('.') - .pop(); + const fileName = file.name.toLowerCase(); + const parts = fileName.split('.'); + const extension = parts.pop(); - if (fileExt !== 'zip') { + if (extension !== 'zip') { throw new ShapefileLoadError( 'Shapefile must be a ZIP file containing at least .shp and .prj files', ); @@ -115,10 +112,9 @@ export const loadShapeFile = async ( // Extract only .shp and .prj files for (const filename in zip.files) { - const extension = filename - .toLowerCase() - .split('.') - .pop(); + const lowerName = filename.toLowerCase(); + const parts = lowerName.split('.'); + const extension = parts.pop(); if (extension === 'shp') { const promise = zip.files[filename] diff --git a/apps/src/lib/shapefile/index.ts b/apps/src/lib/shapefile/index.ts index 0af0c8e7d..17f0febc2 100644 --- a/apps/src/lib/shapefile/index.ts +++ b/apps/src/lib/shapefile/index.ts @@ -1 +1,2 @@ +export * from './file-introspection'; export * from './file-loader'; From 4428471b977067947e55c7aecc8e675447511568 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Sat, 20 Dec 2025 03:56:50 -0500 Subject: [PATCH 3/3] WIP: SLOP that needs validation (3) --- apps/src/components/file-upload.stories.tsx | 192 ++++++++++++++++++++ apps/src/components/file-upload.test.tsx | 139 ++++++++++++++ apps/src/components/file-upload.tsx | 125 +++++++++++++ apps/src/lib/shapefile/file-loader.ts | 48 ++--- 4 files changed, 480 insertions(+), 24 deletions(-) create mode 100644 apps/src/components/file-upload.stories.tsx create mode 100644 apps/src/components/file-upload.test.tsx create mode 100644 apps/src/components/file-upload.tsx diff --git a/apps/src/components/file-upload.stories.tsx b/apps/src/components/file-upload.stories.tsx new file mode 100644 index 000000000..4c186cb29 --- /dev/null +++ b/apps/src/components/file-upload.stories.tsx @@ -0,0 +1,192 @@ +/** + * Ladle stories for FileUpload component + */ + +import { useState } from 'react'; +import type { Story } from '@ladle/react'; +import { FileUpload } from './file-upload'; +import { introspectFile } from '../lib/shapefile/file-introspection'; + +export const Basic: Story = () => { + const [file, setFile] = useState(null); + + return ( +
+

Basic File Upload

+ + {file && ( +
+ Selected: {file.name} ({file.size} bytes) +
+ )} +
+ ); +}; + +export const WithValidation: Story = () => { + const [file, setFile] = useState(null); + const [error, setError] = useState(); + + const handleFileChange = async (selectedFile: File | null) => { + setFile(selectedFile); + + if (!selectedFile) { + setError(undefined); + return; + } + + try { + const inspection = await introspectFile(selectedFile); + + if (inspection.isEmpty) { + setError('File is empty'); + } else if (!inspection.isZip) { + setError('File must be a ZIP archive'); + } else { + setError(undefined); + } + } catch (err) { + setError('Failed to read file'); + } + }; + + return ( +
+

File Upload with Validation

+

Select a file to validate it's a non-empty ZIP archive.

+ + {file && !error && ( +
+ ✓ Valid ZIP file: {file.name} +
+ )} +
+ ); +}; + +export const WithCustomStyling: Story = () => { + const [file, setFile] = useState(null); + const [error, setError] = useState(); + + const handleFileChange = async (selectedFile: File | null) => { + setFile(selectedFile); + + if (!selectedFile) { + setError(undefined); + return; + } + + // Simulate validation + if (!selectedFile.name.toLowerCase().endsWith('.zip')) { + setError('Please select a ZIP file'); + } else { + setError(undefined); + } + }; + + return ( +
+

Custom Styled File Upload

+ + + {file && !error && ( +
+ Selected: {file.name} +
+ )} +
+ ); +}; + +export const MultipleStates: Story = () => { + return ( +
+

File Upload States

+ +
+

Default State

+ +
+ +
+

Error State

+ +
+ +
+

Disabled State

+ +
+ +
+

With Custom Styling

+ + +
+
+ ); +}; diff --git a/apps/src/components/file-upload.test.tsx b/apps/src/components/file-upload.test.tsx new file mode 100644 index 000000000..9f03f0d7f --- /dev/null +++ b/apps/src/components/file-upload.test.tsx @@ -0,0 +1,139 @@ +/** + * Tests for FileUpload component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FileUpload } from './file-upload'; + +describe('FileUpload', () => { + it('renders a file input', () => { + render(); + + const input = screen.getByRole('button', { name: /choose file|browse|select/i }); + expect(input).toBeInTheDocument(); + }); + + it('applies accept attribute', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('accept', 'application/zip'); + }); + + it('calls onChange when file is selected', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + const file = new File(['content'], 'test.zip', { type: 'application/zip' }); + + render(); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, file); + + expect(handleChange).toHaveBeenCalledWith(file); + }); + + it('calls onChange with null when selection is cleared', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + // Simulate clearing the selection + await user.click(input); + // In real browser, user can cancel the file picker + // Simulate by triggering change with no files + Object.defineProperty(input, 'files', { + value: null, + writable: true, + }); + input.dispatchEvent(new Event('change', { bubbles: true })); + + expect(handleChange).toHaveBeenCalledWith(null); + }); + + it('displays error message when error prop is set', () => { + const errorMessage = 'File must be a ZIP archive'; + + render(); + + const error = screen.getByRole('alert'); + expect(error).toHaveTextContent(errorMessage); + }); + + it('sets aria-invalid when error is present', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('links error message with aria-describedby', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + const errorId = input?.getAttribute('aria-describedby'); + + expect(errorId).toBe('test-upload-error'); + + const error = document.getElementById(errorId!); + expect(error).toHaveTextContent('Invalid file'); + }); + + it('applies error className when error is present', () => { + render( + , + ); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveClass('base-class', 'error-class'); + }); + + it('does not apply error className when no error', () => { + render( + , + ); + + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveClass('base-class'); + expect(input).not.toHaveClass('error-class'); + }); + + it('applies custom error message className', () => { + render( + , + ); + + const error = screen.getByRole('alert'); + expect(error).toHaveClass('custom-error-message'); + }); + + it('generates unique id when not provided', () => { + const { container } = render(); + + const input = container.querySelector('input[type="file"]'); + expect(input?.id).toMatch(/^file-upload-/); + }); + + it('uses provided id', () => { + render(); + + const input = document.querySelector('input[type="file"]'); + expect(input?.id).toBe('custom-id'); + }); +}); diff --git a/apps/src/components/file-upload.tsx b/apps/src/components/file-upload.tsx new file mode 100644 index 000000000..df523f09c --- /dev/null +++ b/apps/src/components/file-upload.tsx @@ -0,0 +1,125 @@ +/** + * FileUpload component + * + * Wraps native HTML file input with error state management. + * Uses HTML5 Constraint Validation API for error handling. + */ + +import { useRef, useState, type ChangeEvent, type InputHTMLAttributes } from 'react'; + +export interface FileUploadProps extends Omit, 'type' | 'onChange'> { + /** + * Callback when file selection changes + */ + onChange?: (file: File | null) => void; + /** + * Custom error message to display + */ + error?: string; + /** + * Class name for the input element + */ + className?: string; + /** + * Class name for the input when in error state + */ + errorClassName?: string; + /** + * Class name for the error message container + */ + errorMessageClassName?: string; +} + +/** + * File upload component with native HTML input and error state support. + * + * Features: + * - Uses native `` element + * - HTML5 Constraint Validation API for error states + * - Flexible styling via className props + * - Accessible error messages linked via aria-describedby + * + * @example + * ```typescript + * const [error, setError] = useState(); + * + * const handleFileChange = async (file: File | null) => { + * if (!file) { + * setError(undefined); + * return; + * } + * + * try { + * const inspection = await introspectFile(file); + * if (!inspection.isZip) { + * setError('File must be a ZIP archive'); + * } else { + * setError(undefined); + * } + * } catch (err) { + * setError('Failed to read file'); + * } + * }; + * + * + * ``` + */ +export const FileUpload = ({ + onChange, + error, + className = '', + errorClassName = '', + errorMessageClassName = '', + id, + ...props +}: FileUploadProps) => { + const inputRef = useRef(null); + const [inputId] = useState(() => id || `file-upload-${Math.random().toString(36).slice(2)}`); + const errorId = `${inputId}-error`; + + const handleChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] || null; + + if (onChange) { + onChange(file); + } + }; + + // Update custom validity when error changes + if (inputRef.current) { + inputRef.current.setCustomValidity(error || ''); + } + + const hasError = Boolean(error); + const inputClassName = hasError + ? `${className} ${errorClassName}`.trim() + : className; + + return ( +
+ + {hasError && ( + + )} +
+ ); +}; diff --git a/apps/src/lib/shapefile/file-loader.ts b/apps/src/lib/shapefile/file-loader.ts index 34677b28f..f94ae5c0e 100644 --- a/apps/src/lib/shapefile/file-loader.ts +++ b/apps/src/lib/shapefile/file-loader.ts @@ -5,6 +5,7 @@ */ import JSZip from 'jszip'; +import { introspectFile } from './file-introspection'; /** * Extracted shapefile data. @@ -34,26 +35,6 @@ export class ShapefileLoadError extends Error { } } -/** - * Validates file extension is .zip - * - * @param file - File to validate - * @throws {ShapefileLoadError} If file extension is not .zip - */ -const assertIsZipFile = ( - file: File, -): void => { - const fileName = file.name.toLowerCase(); - const parts = fileName.split('.'); - const extension = parts.pop(); - - if (extension !== 'zip') { - throw new ShapefileLoadError( - 'Shapefile must be a ZIP file containing at least .shp and .prj files', - ); - } -}; - /** * Validates extracted data contains required files * @@ -73,6 +54,11 @@ const assertHasRequiredFiles = ( /** * Load and extract shapefile data from a ZIP file. * + * Validation steps: + * 1. Verify file is not empty (native introspection) + * 2. Verify ZIP magic bytes (native introspection) + * 3. Extract .shp and .prj files with JSZip + * * A valid shapefile ZIP must contain: * - `.shp` file: Binary geometry data * - `.prj` file: Projection definition @@ -101,12 +87,26 @@ const assertHasRequiredFiles = ( export const loadShapeFile = async ( file: File, ): Promise => { - assertIsZipFile(file); + // Step 1-2: Validate file content (not empty + ZIP magic bytes) + const inspection = await introspectFile(file); - try { - const arrayBuffer = await file.arrayBuffer(); - const zip = await JSZip.loadAsync(arrayBuffer); + if (inspection.isEmpty) { + throw new ShapefileLoadError( + 'File is empty', + ); + } + + if (!inspection.isZip) { + throw new ShapefileLoadError( + 'File is not a valid ZIP archive (invalid magic bytes)', + if (!inspection.isZip) { + throw new ShapefileLoadError( + 'File is not a valid ZIP archive (invalid magic bytes)', + ); + } + // Step 3: Extract files with JSZip + try { const data: Partial = {}; const promises: Promise[] = [];