printhive/test/duplicates.test.js
dlawler489 00a4b4ba07 Add duplicate finder, trash, notes, and library search/filters
- Fingerprint every file in the background: SHA-256 for all formats plus a
  trimesh geometry fingerprint for STL/OBJ/3MF, so renamed, re-exported or
  converted copies are still matched. Backfills existing files on startup.
- Duplicates view groups models that share files (exact copies vs same
  shape), recommends which to keep, merges tags/collections/notes into the
  kept model and trashes the rest; groups can be dismissed.
- Deleting now moves models to a restorable trash; files are only removed
  from disk on permanent delete or when the trash is emptied.
- Per-model notes with autosave.
- Search across names, notes, tags, files and designers; filter by format,
  source and license; include/exclude tags with any/all matching; sort and
  group by source or format. Preferences persist per browser.
- Add node:test unit tests for duplicate grouping and fingerprints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:06:09 +10:00

63 lines
2.8 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const { groupDuplicates, signature, recommendKeep } = require('../lib/duplicates');
const { geometryKey } = require('../lib/fingerprint');
const file = (id, model_id, sha256, geometry_hash = null) => ({ id, model_id, sha256, geometry_hash });
test('identical content in two models is an exact group', () => {
const groups = groupDuplicates([file(1, 10, 'aaa'), file(2, 20, 'aaa'), file(3, 30, 'zzz')]);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].modelIds, [10, 20]);
assert.equal(groups[0].exact, true);
assert.deepEqual(groups[0].matchedFiles, { 10: 1, 20: 1 });
});
test('same geometry with different bytes is flagged for review', () => {
const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'bbb', 'g1')]);
assert.deepEqual(group.modelIds, [10, 20]);
assert.equal(group.exact, false);
});
test('same geometry and same bytes stays exact', () => {
const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'aaa', 'g1')]);
assert.equal(group.exact, true);
});
test('a file repeated inside one model is not a duplicate group', () => {
assert.deepEqual(groupDuplicates([file(1, 10, 'aaa'), file(2, 10, 'aaa')]), []);
});
test('links are transitive, so packs collapse into one group', () => {
const groups = groupDuplicates([
file(1, 10, 'a'), file(2, 10, 'b'),
file(3, 20, 'a'), file(4, 20, 'b', 'g'),
file(5, 30, 'c', 'g'),
]);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].modelIds, [10, 20, 30]);
assert.equal(groups[0].exact, false);
assert.deepEqual(groups[0].matchedFiles, { 10: 2, 20: 2, 30: 1 });
});
test('signature ignores order and repeats', () => {
assert.equal(signature([3, 1, 2, 1]), '1,2,3');
assert.equal(signature(['20', '10']), '10,20');
});
test('recommendKeep prefers the more complete model, then the older one', () => {
const bare = { id: 1, date_added: '2026-01-01', files: [{}], tags: [], projects: [] };
const sourced = { id: 2, date_added: '2026-02-01', source_url: 'https://x', license: 'CC-BY', files: [{}], tags: ['a'], projects: [] };
assert.equal(recommendKeep([bare, sourced]), 2);
const older = { ...bare, id: 3, date_added: '2025-12-01' };
assert.equal(recommendKeep([bare, older]), 3);
});
test('geometryKey ignores axis order, position and float noise', () => {
const a = { faces: 1200, area: 5321.4567, volume: -18342.12, extents: [20, 40.02, 10] };
const b = { faces: 1200, area: 5321.4571, volume: 18342.09, extents: [40.019, 10.001, 20] };
assert.equal(geometryKey(a), geometryKey(b));
assert.notEqual(geometryKey(a), geometryKey({ ...a, faces: 1202 }));
assert.notEqual(geometryKey(a), geometryKey({ ...a, extents: [20, 40.02, 12] }));
assert.equal(geometryKey({ faces: 0 }), null);
});