/** * fat-image.test.ts — verifies buildFat16Image() produces a structurally valid, * mountable FAT16 image. A compact FAT16 reader (below) parses the image back * and we assert a full round-trip (names + bytes), plus BPB/structure checks. */ import { describe, it, expect } from 'vitest'; import { buildFat16Image, type SdFile } from '../utils/fatImage'; import { buildProjectSdImage, bytesToB64, decodeSdFiles } from '../utils/sdCardFiles'; // ── Minimal FAT16 reader (test-only) — parses root dir + FAT chains ────────── function readFat16(img: Uint8Array): { name: string; data: Uint8Array }[] { const u16 = (o: number) => img[o] | (img[o + 1] << 8); const u32 = (o: number) => img[o] | (img[o + 1] << 8) | (img[o + 2] << 16) | img[o + 3] * 0x1000000; const bps = u16(11); const spc = img[13]; const reserved = u16(14); const numFats = img[16]; const rootEntries = u16(17); const fatSz = u16(22); const fatStart = reserved * bps; const rootSectors = Math.ceil((rootEntries * 32) / bps); const rootStart = (reserved + numFats * fatSz) * bps; const dataStart = (reserved + numFats * fatSz + rootSectors) * bps; const clusterBytes = spc * bps; const fatEntry = (cl: number) => u16(fatStart + cl * 2); const out: { name: string; data: Uint8Array }[] = []; const lfnSlots = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30]; let lfn = ''; for (let i = 0; i < rootEntries; i++) { const e = rootStart + i * 32; const first = img[e]; if (first === 0x00) break; // end of directory if (first === 0xe5) { lfn = ''; continue; } // deleted const attr = img[e + 11]; if (attr === 0x0f) { let part = ''; for (const s of lfnSlots) { const c = img[e + s] | (img[e + s + 1] << 8); if (c === 0x0000 || c === 0xffff) break; part += String.fromCharCode(c); } lfn = part + lfn; // entries are physically reverse-ordered continue; } if (attr & 0x08) { lfn = ''; continue; } // volume label let name: string; if (lfn) { name = lfn; lfn = ''; } else { const base = String.fromCharCode(...img.slice(e, e + 8)).replace(/ +$/, ''); const ext = String.fromCharCode(...img.slice(e + 8, e + 11)).replace(/ +$/, ''); name = ext ? `${base}.${ext}` : base; } const startCluster = u16(e + 26); const size = u32(e + 28); const data = new Uint8Array(size); let cl = startCluster; let off = 0; while (cl >= 2 && cl < 0xfff8 && off < size) { const at = dataStart + (cl - 2) * clusterBytes; const n = Math.min(clusterBytes, size - off); data.set(img.slice(at, at + n), off); off += n; cl = fatEntry(cl); } out.push({ name, data }); } return out; } const bytes = (s: string) => new TextEncoder().encode(s); describe('buildFat16Image', () => { it('produces a valid FAT16 boot sector', () => { const img = buildFat16Image([]); expect(img[510]).toBe(0x55); expect(img[511]).toBe(0xaa); expect(img[11] | (img[12] << 8)).toBe(512); // bytes/sector expect(String.fromCharCode(...img.slice(54, 62))).toBe('FAT16 '); // cluster count must be in the FAT16 range (otherwise it'd be FAT12/FAT32) const fatSz = img[22] | (img[23] << 8); const totalSectors = img[19] | (img[20] << 8); const clusters = totalSectors - 1 - 2 * fatSz - 32; expect(clusters).toBeGreaterThanOrEqual(4085); expect(clusters).toBeLessThanOrEqual(65524); }); it('round-trips short (8.3) files: names case-insensitive, bytes exact', () => { const files: SdFile[] = [ { name: 'DATA.BIN', data: Uint8Array.from([1, 2, 3, 4, 250, 255]) }, { name: 'HELLO.TXT', data: bytes('hello sd world') }, ]; const got = readFat16(buildFat16Image(files)); expect(got.length).toBe(2); for (const f of files) { const r = got.find((g) => g.name.toLowerCase() === f.name.toLowerCase()); expect(r, `missing ${f.name}`).toBeTruthy(); expect(Array.from(r!.data)).toEqual(Array.from(f.data)); } }); it('preserves long file names exactly via LFN', () => { const files: SdFile[] = [{ name: 'my-long-config.json', data: bytes('{"ok":true}') }]; const got = readFat16(buildFat16Image(files)); expect(got.length).toBe(1); expect(got[0].name).toBe('my-long-config.json'); expect(new TextDecoder().decode(got[0].data)).toBe('{"ok":true}'); }); it('round-trips a multi-cluster (>512 byte) binary file', () => { const big = new Uint8Array(2000); for (let i = 0; i < big.length; i++) big[i] = (i * 31 + 7) & 0xff; const got = readFat16(buildFat16Image([{ name: 'IMG.RAW', data: big }])); expect(got.length).toBe(1); expect(got[0].data.length).toBe(2000); expect(Array.from(got[0].data)).toEqual(Array.from(big)); }); it('handles several files together', () => { const files: SdFile[] = [ { name: 'A.TXT', data: bytes('aaa') }, { name: 'photo.bmp', data: new Uint8Array(1500).fill(0xab) }, { name: 'B.DAT', data: Uint8Array.from([9, 8, 7]) }, ]; const got = readFat16(buildFat16Image(files)); expect(got.length).toBe(3); // photo.bmp fits 8.3, so it's stored case-folded (PHOTO.BMP) — match loosely. const photo = got.find((g) => g.name.toLowerCase() === 'photo.bmp'); expect(photo!.data.length).toBe(1500); expect(photo!.data.every((b) => b === 0xab)).toBe(true); }); it('throws when files exceed the volume capacity', () => { const huge = new Uint8Array(2 * 1024 * 1024); expect(() => buildFat16Image([{ name: 'BIG.BIN', data: huge }], { volumeBytes: 1024 * 1024 })).toThrow(); }); }); describe('buildProjectSdImage', () => { it('auto-copies workspace (text) files into the image', () => { const ws = [ { name: 'sketch.ino', content: 'void setup(){}' }, { name: 'notes.txt', content: 'hello from the card' }, ]; const got = readFat16(buildProjectSdImage(ws)); const notes = got.find((g) => g.name.toLowerCase() === 'notes.txt'); expect(notes).toBeTruthy(); expect(new TextDecoder().decode(notes!.data)).toBe('hello from the card'); expect(got.some((g) => g.name.toLowerCase() === 'sketch.ino')).toBe(true); }); it('uploaded (paid) files override same-named project files', () => { const ws = [{ name: 'data.bin', content: 'TEXT' }]; const uploaded = [{ name: 'data.bin', data: Uint8Array.from([0xde, 0xad, 0xbe, 0xef]) }]; const got = readFat16(buildProjectSdImage(ws, uploaded)); const f = got.find((g) => g.name.toLowerCase() === 'data.bin')!; expect(Array.from(f.data)).toEqual([0xde, 0xad, 0xbe, 0xef]); // binary, not "TEXT" }); it('adds uploaded binary files alongside project files', () => { const ws = [{ name: 'main.ino', content: 'x' }]; const uploaded = [{ name: 'logo.bmp', data: new Uint8Array(800).fill(0x42) }]; const got = readFat16(buildProjectSdImage(ws, uploaded)); expect(got.length).toBe(2); const logo = got.find((g) => g.name.toLowerCase() === 'logo.bmp')!; expect(logo.data.length).toBe(800); expect(logo.data.every((b) => b === 0x42)).toBe(true); }); }); describe('sdCardFiles upload helpers', () => { it('bytesToB64 / decodeSdFiles round-trip (binary-safe)', () => { const data = Uint8Array.from([0, 1, 2, 254, 255, 128, 64, 0, 13, 10]); const decoded = decodeSdFiles([{ name: 'x.bin', contentB64: bytesToB64(data) }]); expect(decoded.length).toBe(1); expect(decoded[0].name).toBe('x.bin'); expect(Array.from(decoded[0].data)).toEqual(Array.from(data)); }); it('decodeSdFiles ignores malformed entries', () => { expect(decodeSdFiles(undefined)).toEqual([]); expect(decodeSdFiles([{ name: 'a' }, { contentB64: 'AAA' }, 5, null]).length).toBe(0); }); it('decoded uploaded binaries land on the card', () => { const data = new Uint8Array(700).fill(0x7e); const uploaded = decodeSdFiles([{ name: 'snd.wav', contentB64: bytesToB64(data) }]); const f = readFat16(buildProjectSdImage([], uploaded)).find( (g) => g.name.toLowerCase() === 'snd.wav', )!; expect(f.data.length).toBe(700); expect(f.data.every((b) => b === 0x7e)).toBe(true); }); });