270 lines
9.1 KiB
JavaScript
270 lines
9.1 KiB
JavaScript
// ============================================================
|
|
// 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);
|