// ============================================================ // Copyparty Content Tag — TSV Unit Tests (Node.js) // Run: node tests/tsv-test.js // Pure-function tests — no network required. // ============================================================ 'use strict'; let pass = 0; let fail = 0; function ok(cond, label) { if (cond) { pass++; console.log(` PASS ${label}`); } else { fail++; console.error(` FAIL ${label}`); } } // ------------------------------------------------------------------ // Inline the pure functions from TsvDb for isolated testing // ------------------------------------------------------------------ const TAB = '\t'; function escapeField(value) { return String(value) .replace(/\\/g, '\\\\') .replace(/\t/g, '\\t') .replace(/\n/g, '\\n'); } function unescapeField(raw) { if (!raw.includes('\\')) return raw; const out = []; let i = 0; while (i < raw.length) { if (raw[i] === '\\' && i + 1 < raw.length) { const next = raw[i + 1]; switch (next) { case 't': out.push('\t'); break; case 'n': out.push('\n'); break; case '\\': out.push('\\'); break; default: out.push('\\', next); break; } i += 2; } else { out.push(raw[i]); i++; } } return out.join(''); } function splitTsvLine(line) { const result = ['']; let i = 0; while (i < line.length) { if (line[i] === '\\' && i + 1 < line.length) { result[result.length - 1] += line[i] + line[i + 1]; i += 2; } else if (line[i] === TAB) { result.push(''); i++; } else { result[result.length - 1] += line[i]; i++; } } return result; } function normalizeTags(tags) { if (!tags || typeof tags !== 'string') return ''; return tags.toLowerCase(); } function parseLine(line) { const raw = splitTsvLine(line); if (raw.length < 9) return null; const [file_id, original_name, content_type, similarity_hash, base_colors, album_uuid, album_position, user_tags, modified_at] = raw.map(unescapeField); return { file_id: String(file_id), original_name: String(original_name), content_type: String(content_type), similarity_hash: String(similarity_hash), base_colors: String(base_colors), album_uuid: album_uuid || '', album_position: album_position !== '' ? Number(album_position) : '', user_tags: normalizeTags(String(user_tags)), modified_at: Number(modified_at), }; } function rowToLine(row) { const f = []; f.push(escapeField(row.file_id)); f.push(escapeField(row.original_name)); f.push(escapeField(String(row.content_type))); f.push(escapeField(String(row.similarity_hash))); f.push(escapeField(String(row.base_colors))); f.push(escapeField(String(row.album_uuid || ''))); f.push(escapeField(String(row.album_position !== '' ? row.album_position : ''))); f.push(escapeField(normalizeTags(String(row.user_tags)))); f.push(escapeField(String(Number(row.modified_at || (Date.now() / 1000 | 0))))); return f.join(TAB); } function parse(rawText) { const result = new Map(); if (!rawText || !rawText.trim()) return result; for (const line of rawText.split(/\r?\n/)) { if (!line.trim()) continue; const row = parseLine(line); if (row) result.set(row.file_id, row); } return result; } function serialize(map) { if (!map.size) return ''; const lines = []; for (const [, row] of map) { lines.push(rowToLine(row)); } return lines.join('\n') + '\n'; } function getByTag(map, tagString) { const needle = normalizeTags(String(tagString)); const result = []; for (const [, row] of map) { if (row.user_tags.includes(needle)) result.push(row); } return result; } function getByAlbum(map, album_uuid) { const needle = String(album_uuid); const result = []; for (const [, row] of map) { if (String(row.album_uuid) === needle) result.push(row); } return result; } function updateUserTags(map, file_id, newCommaSepTags) { const row = map.get(file_id); if (!row) return false; const oldTs = row.modified_at; row.user_tags = normalizeTags(String(newCommaSepTags)); row.modified_at = Date.now() / 1000 | 0; return row.modified_at > oldTs; } function deleteRow(map, file_id) { return map.delete(file_id); } // ------------------------------------------------------------------ // Tests // ------------------------------------------------------------------ console.log('\n=== TSV Unit Tests ===\n'); // --- Helper to build a test fixture --- const ALBUM_UUID = '550e8400-e29b-41d4-a716-446655440000'; const FIXTURE_ROWS = [ { file_id: 'AAA111', original_name: 'sunset.jpg', content_type: 'image', similarity_hash: '0a1b2c3d', base_colors: '#f5a623_45,#234_15', album_uuid: ALBUM_UUID, album_position: 0, user_tags: 'sunset, beach', modified_at: 1700000000 }, { file_id: 'BBB222', original_name: 'ocean.png', content_type: 'image', similarity_hash: '1b2c3d4e', base_colors: '#4a90d9_60,#fffef2_30', album_uuid: '', album_position: '', user_tags: 'ocean, waves, beach', modified_at: 1700001000 }, ]; // --- 1. Parse valid TSV --- console.log('--- Parse ---'); const fixtureTsv = FIXTURE_ROWS.map(rowToLine).join('\n') + '\n'; const parsed = parse(fixtureTsv); ok(parsed.size === 2, 'Parse two rows → Map size 2'); ok(parsed.has('AAA111'), 'Parsed row AAA111 exists'); ok(parsed.has('BBB222'), 'Parsed row BBB222 exists'); const rowA = parsed.get('AAA111'); ok(rowA.original_name === 'sunset.jpg', 'Row A original_name correct'); ok(rowA.content_type === 'image', 'Row A content_type = image'); ok(rowA.album_uuid === ALBUM_UUID, 'Row A album_uuid matches'); ok(rowA.album_position === 0, 'Row A album_position = 0'); ok(rowA.user_tags === 'sunset, beach', 'Row A user_tags normalized'); ok(rowA.modified_at === 1700000000, 'Row A modified_at correct'); // --- 2. Serialize roundtrip --- console.log('\n--- Serialize Roundtrip ---'); const serialized = serialize(parsed); const reparsed = parse(serialized); ok(reparsed.size === parsed.size, 'Roundtrip preserves Map size'); for (const [key, val] of parsed) { const again = reparsed.get(key); ok( JSON.stringify(val) === JSON.stringify(again), `Roundtrip row ${key} identical` ); } // --- 3. Escape / Unescape roundtrip --- console.log('\n--- Escape/Unescape ---'); ok(escapeField('hello\tworld') === 'hello\\tworld', 'Tab escaped'); ok(escapeField('line1\nline2') === 'line1\\nline2', 'Newline escaped'); ok(escapeField('back\\\\slash') === 'back\\\\\\\\slash', 'Backslash double-escaped'); ok(unescapeField('hello\\tworld') === 'hello\tworld', 'Tab unescaped'); ok(unescapeField('line1\\nline2') === 'line1\nline2', 'Newline unescaped'); ok(unescapeField('a\\\\b') === 'a\\b', 'Backslash roundtrip'); // Row with special chars in filename: const specialRow = { ...FIXTURE_ROWS[0], original_name: 'tab\there.txt' }; const specialLine = rowToLine(specialRow); const specialParsed = parseLine(specialLine); ok(specialParsed.original_name === 'tab\there.txt', 'Filename with tab survives roundtrip'); // --- 4. getByTag --- console.log('\n--- getByTag ---'); const beachRows = getByTag(parsed, 'beach'); ok(beachRows.length === 2, 'getByTag("beach") → 2 rows (both have beach)'); const oceanRows = getByTag(parsed, 'ocean'); ok(oceanRows.length === 1, 'getByTag("ocean") → 1 row'); ok(oceanRows[0].file_id === 'BBB222', 'getByTag("ocean") returns BBB222'); const noneRows = getByTag(parsed, 'mountain'); ok(noneRows.length === 0, 'getByTag("mountain") → 0 rows'); // --- 5. getByAlbum --- console.log('\n--- getByAlbum ---'); const albumRows = getByAlbum(parsed, ALBUM_UUID); ok(albumRows.length === 1, 'getByAlbum(uuid) → 1 row'); ok(albumRows[0].file_id === 'AAA111', 'getByAlbum returns AAA111'); ok(getByAlbum(parsed, 'nonexistent').length === 0, 'Unknown album → 0 rows'); // --- 6. updateUserTags --- console.log('\n--- updateUserTags ---'); const oldTs = parsed.get('AAA111').modified_at; updateUserTags(parsed, 'AAA111', 'new tag'); ok(parsed.get('AAA111').user_tags === 'new tag', 'Tags updated'); ok(parsed.get('AAA111').modified_at > oldTs, 'Timestamp bumped'); ok(!updateUserTags(parsed, 'NONEXIST'), 'Updating nonexistent row → false'); // --- 7. deleteRow --- console.log('\n--- deleteRow ---'); ok(deleteRow(parsed, 'BBB222') === true, 'Delete existing → true'); ok(!parsed.has('BBB222'), 'Row removed from Map'); ok(deleteRow(parsed, 'BBB222') === false, 'Delete again → false'); // --- 8. Malformed rows skipped --- console.log('\n--- Malformed Rows ---'); const badTsv = 'only\ttwo\tcolumns\n' + rowToLine(FIXTURE_ROWS[0]); const mixedParse = parse(badTsv); ok(mixedParse.size === 1, 'Malformed row skipped, valid row parsed'); // --- 9. Empty input --- console.log('\n--- Empty Input ---'); ok(parse('').size === 0, 'Empty string → empty Map'); ok(parse(null).size === 0, 'null → empty Map'); ok(parse(' \n \n ').size === 0, 'Whitespace-only → empty Map'); ok(serialize(new Map()) === '', 'Serialize empty Map → empty string'); // ------------------------------------------------------------------ // Summary // ------------------------------------------------------------------ console.log(`\n=== Results: ${pass} passed, ${fail} failed ===\n`); process.exit(fail > 0 ? 1 : 0);