/** * Tests for the classifier "Classify & Copy" state model * (classifier/js/classify.js) — the pure derive/assignment logic. * * Runs against the compiled classifier/dist/classifier.html, driving * window.app.modules.classify via page.evaluate. No File System Access API is * needed: synthetic file objects ({folderPath, originalFilename, extension}) * carry everything deriveTarget consults. Drag-and-drop and the actual copy * stay manual (Playwright can't drive the directory picker). * * Build first: sh classifier/build.sh */ import { test, expect } from '@playwright/test'; import * as path from 'path'; const PAGE = 'file://' + path.resolve('classifier/dist/classifier.html'); test.beforeEach(async ({ page }) => { await page.goto(PAGE, { waitUntil: 'load' }); const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify)); expect(ok).toBe(true); await page.evaluate(() => { window.app.modules.classify.reset(); // No directory is opened in these tests; dismiss the welcome overlay so // it doesn't intercept clicks on the in-pane controls. const w = document.getElementById('welcomeScreen'); if (w) w.classList.add('hidden'); }); }); // Build a tracking chain of folders and place one file in the deepest; // return the derived target. async function deriveInTracking(page, segments, file) { return page.evaluate(({ segments, file }) => { const c = window.app.modules.classify; let parent = null; for (const name of segments) parent = c.addTrackingNode(parent, name); const key = c.srcKeyForFile(file); c.place([key], parent, 'tracking'); return c.deriveTarget(file); }, { segments, file }); } const FILE = { folderPath: 'Root/Sub', originalFilename: 'Foundation Plan', extension: 'pdf' }; test('tracking: ancestors join with "-", parent leaf = REV (STATUS), title from name', async ({ page }) => { const d = await deriveInTracking(page, ['ACME-PROJ', 'MECH', '0001', 'A (IFR)'], FILE); expect(d.tracking).toBe('ACME-PROJ-MECH-0001'); expect(d.revision).toBe('A'); expect(d.status).toBe('IFR'); expect(d.title).toBe('Foundation Plan'); expect(d.filename).toBe('ACME-PROJ-MECH-0001_A (IFR) - Foundation Plan.pdf'); expect(d.trackingLeaf).toBe(true); }); test('tracking: a single full-number folder also works', async ({ page }) => { const d = await deriveInTracking(page, ['ACME-PROJ-MECH-0001', 'B (IFC)'], FILE); expect(d.tracking).toBe('ACME-PROJ-MECH-0001'); expect(d.revision).toBe('B'); expect(d.status).toBe('IFC'); expect(d.filename).toBe('ACME-PROJ-MECH-0001_B (IFC) - Foundation Plan.pdf'); }); test('tracking: parked in an intermediate (non-leaf) folder is flagged incomplete', async ({ page }) => { const d = await page.evaluate((file) => { const c = window.app.modules.classify; const proj = c.addTrackingNode(null, 'ACME-PROJ'); const mech = c.addTrackingNode(proj, 'MECH'); c.addTrackingNode(mech, '0001'); // child exists → MECH is not a leaf const key = c.srcKeyForFile(file); c.place([key], mech, 'tracking'); // file parked at MECH return c.deriveTarget(file); }, FILE); expect(d.trackingLeaf).toBe(false); expect(d.errors.join(' ')).toContain('leaf'); expect(d.complete).toBe(false); }); test('tracking: unknown status code is reported', async ({ page }) => { const d = await deriveInTracking(page, ['ACME', 'Z (BOGUS)'], FILE); expect(d.status).toBe('BOGUS'); expect(d.errors.join(' ')).toContain('unknown status'); expect(d.complete).toBe(false); }); test('tracking: leaf with no "(STATUS)" parens is flagged', async ({ page }) => { const d = await deriveInTracking(page, ['ACME', '0001'], FILE); expect(d.status).toBe(''); expect(d.filename).toBe(''); // formatFilename needs a status expect(d.errors.join(' ')).toContain('STATUS'); }); test('title: original ZDDC name reuses its title; titleOverride wins', async ({ page }) => { const zddcFile = { folderPath: 'Root', originalFilename: 'X-1_A (IFR) - Real Title', extension: 'pdf' }; const def = await page.evaluate((f) => window.app.modules.classify.defaultTitle(f), zddcFile); expect(def).toBe('Real Title'); const overridden = await page.evaluate((f) => { const c = window.app.modules.classify; const key = c.srcKeyForFile(f); const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)'); c.place([key], leaf, 'tracking'); c.setTitleOverride(key, 'Custom Title'); return c.deriveTarget(f); }, FILE); expect(overridden.title).toBe('Custom Title'); expect(overridden.filename).toContain(' - Custom Title.pdf'); }); test('transmittal: party/slot/bin → output path; full target composes', async ({ page }) => { const d = await page.evaluate((file) => { const c = window.app.modules.classify; const key = c.srcKeyForFile(file); // axis 1 const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); c.place([key], leaf, 'tracking'); // axis 2 const party = c.addParty('ClientCorp'); const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([key], bin, 'transmittal'); return { d: c.deriveTarget(file), binName: c.getNode(bin).name }; }, FILE); expect(d.binName).toBe('2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal'); expect(d.d.party).toBe('ClientCorp'); expect(d.d.slot).toBe('received'); expect(d.d.outPath).toBe('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal'); expect(d.d.complete).toBe(true); }); test('exclude clears placements and reports excluded state', async ({ page }) => { const r = await page.evaluate((file) => { const c = window.app.modules.classify; const key = c.srcKeyForFile(file); const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)'); c.place([key], leaf, 'tracking'); c.setExcluded([key], true); return { state: c.fileState(file), d: c.deriveTarget(file) }; }, FILE); expect(r.state).toBe('excluded'); expect(r.d.excluded).toBe(true); }); // ── Phase 2: mode toggle + target-tree rendering (UI) ────────────────────── test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => { await page.click('#modeClassifyBtn'); expect(await page.locator('#targetPane').isHidden()).toBe(false); expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true); await page.click('#modeRenameBtn'); expect(await page.locator('#targetPane').isHidden()).toBe(true); expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false); }); test('target tree renders structure and tabs switch', async ({ page }) => { await page.click('#modeClassifyBtn'); await page.evaluate(() => { const c = window.app.modules.classify; const acme = c.addTrackingNode(null, 'ACME-PROJ'); c.addTrackingNode(acme, 'A (IFR)'); const party = c.addParty('ClientCorp'); c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); }); // Tracking panel visible by default with the table rendered. await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible(); await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible(); // Switch to transmittal tab. await page.click('#transmittalTab'); expect(await page.locator('#transmittalPanel').isHidden()).toBe(false); await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible(); }); test('"+ Root folder" button (prompt) parses a name into nested levels', async ({ page }) => { await page.click('#modeClassifyBtn'); page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)')); await page.click('#addTrackingRootBtn'); // "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell. await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible(); await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible(); await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible(); }); // ── Phase 3: drag-and-drop assignment (drop handler) ─────────────────────── test('dropping a file onto a tracking leaf assigns it', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify; const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); window.app.modules.targetTree.render(); const row = document.querySelector('#trackingTree .ttable__rev[data-id]'); const key = 'Sub/foundation.pdf'; window.app.modules.dnd.setDrag([key]); row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); return { assigned: c.assignmentFor(key).trackingNodeId, leaf }; }); expect(r.assigned).toBe(r.leaf); }); test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => { await page.click('#modeClassifyBtn'); await page.click('#transmittalTab'); const r = await page.evaluate(() => { const c = window.app.modules.classify; const party = c.addParty('ClientCorp'); const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); window.app.modules.targetTree.render(); const key = 'Sub/foundation.pdf'; // Drop on the bin → assigned. const binRow = document.querySelector('#transmittalTree .tnode--bin .tnode__row'); window.app.modules.dnd.setDrag([key]); binRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); const afterBin = c.assignmentFor(key).transmittalNodeId; // Reset, then drop on the party row → ignored (only bins are targets). c.place([key], null, 'transmittal'); const partyRow = document.querySelector('#transmittalTree .tnode--party > .tnode__row'); window.app.modules.dnd.setDrag([key]); partyRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); const afterParty = c.assignmentFor(key).transmittalNodeId; return { afterBin, bin, afterParty }; }); expect(r.afterBin).toBe(r.bin); expect(r.afterParty).toBe(null); }); // ── Phase 4: left-tree markers, exclude, cross-tree find ─────────────────── // Inject a synthetic scanned tree (no FS Access needed) and render it. async function withSourceTree(page) { await page.click('#modeClassifyBtn'); await page.evaluate(() => { window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }], children: [], fileCount: 1, subdirCount: 0, runFiles: 1, runDirs: 0, }]; window.app.modules.tree.render(); }); } test('source file rows render with a state dot in classify mode', async ({ page }) => { await withSourceTree(page); await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'Foundation Plan.pdf' })).toBeVisible(); await expect(page.locator('#folderTree .file-item .cl-dot--none')).toBeVisible(); }); test('classify: single-click a source file triggers preview', async ({ page }) => { await page.click('#modeClassifyBtn'); const previewed = await page.evaluate(() => { let got = null; window.app.modules.preview.previewFile = (f) => { got = f.originalFilename; }; // capture, skip popup window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }], children: [], }]; window.app.modules.tree.render(); document.querySelector('#folderTree .file-item').click(); return got; }); expect(previewed).toBe('Foundation Plan'); }); test('classify: a folder with files but no subfolders is expandable (drag source)', async ({ page }) => { await page.click('#modeClassifyBtn'); await page.evaluate(() => { window.app.folderTree = [{ name: 'Leaf', path: 'Leaf', expanded: false, scanState: 'done', files: [{ originalFilename: 'x', extension: 'pdf', folderPath: 'Leaf' }], children: [], }]; window.app.modules.tree.render(); }); const toggle = page.locator('#folderTree .folder-item .folder-toggle').first(); await expect(toggle).toHaveText('▶'); // file-only folder still gets an expand arrow await toggle.click(); await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'x.pdf' })).toBeVisible(); }); test('placing a file turns its dot (and the folder aggregate) done', async ({ page }) => { await withSourceTree(page); await page.evaluate(() => { const c = window.app.modules.classify; const realKey = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }); const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([realKey], leaf, 'tracking'); c.place([realKey], bin, 'transmittal'); window.app.modules.tree.render(); }); await expect(page.locator('#folderTree .file-item .cl-dot--done')).toBeVisible(); await expect(page.locator('#folderTree .folder-item .cl-dot--done')).toBeVisible(); }); test('context-menu exclude marks the file excluded', async ({ page }) => { await withSourceTree(page); await page.locator('#folderTree .file-item').click({ button: 'right' }); await expect(page.locator('.cl-menu')).toBeVisible(); await page.locator('.cl-menu__item', { hasText: 'Exclude from copy' }).click(); await expect(page.locator('#folderTree .file-item.excluded')).toBeVisible(); const excluded = await page.evaluate(() => { const c = window.app.modules.classify; const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }); return c.getAssignment(key).excluded; }); expect(excluded).toBe(true); }); test('cross-tree reveal: source→target switches to the placed axis', async ({ page }) => { await withSourceTree(page); const ok = await page.evaluate(() => { const c = window.app.modules.classify; const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'issued', { date: '2026-03-14', type: 'SUB', seq: '0001' }); c.place([key], bin, 'transmittal'); window.app.modules.targetTree.reveal(key); // should switch to transmittal tab return !document.getElementById('transmittalPanel').hidden; }); expect(ok).toBe(true); }); // ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ─────── test('copy: writes the file, then resumes by skipping an existing target', async ({ page }) => { await page.click('#modeClassifyBtn'); const res = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; const store = {}; const fileHandleFor = (full) => ({ getFile: async () => new File([store[full] != null ? store[full] : ''], full.split('/').pop()), createWritable: async () => ({ write: async (d) => { store[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }), }); const mockDir = (prefix) => ({ name: prefix || 'out', getDirectoryHandle: async (name) => mockDir((prefix ? prefix + '/' : '') + name), getFileHandle: async (name, opts) => { const full = (prefix ? prefix + '/' : '') + name; if (!opts || !opts.create) { if (!(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } } return fileHandleFor(full); }, }); const srcFile = (name, content) => { const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.'); return { originalFilename: stem, extension: ext, folderPath: 'Root', handle: { getFile: async () => new File([content], name) } }; }; const f = srcFile('foundation.pdf', 'AAA'); window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [f], children: [], runFiles: 1 }]; const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); const key = c.srcKeyForFile(f); c.place([key], leaf, 'tracking'); c.place([key], bin, 'transmittal'); const out = mockDir(''); const first = await copy.copyTo(out, copy.plan()); const second = await copy.copyTo(out, copy.plan()); // already present → skipped (resume) return { firstCopied: first.copied, secondSkipped: second.skipped, secondCopied: second.copied, keys: Object.keys(store) }; }); expect(res.firstCopied).toBe(1); expect(res.secondSkipped).toBe(1); // re-run resumes: the existing target is skipped expect(res.secondCopied).toBe(0); // …and not re-written expect(res.keys.some((k) => k.endsWith('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal/ACME-MECH-0001_A (IFR) - foundation.pdf'))).toBe(true); }); test('copy: two sources mapping to the same output path are a conflict', async ({ page }) => { await page.click('#modeClassifyBtn'); const conflicts = await page.evaluate(() => { const c = window.app.modules.classify, copy = window.app.modules.copy; const srcFile = (name, folder) => { const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.'); return { originalFilename: stem, extension: ext, folderPath: folder, handle: { getFile: async () => new File(['x'], name) } }; }; const f1 = srcFile('plan.pdf', 'Root/a'); const f2 = srcFile('plan.pdf', 'Root/b'); // same name, different folder → same derived output window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [], children: [ { name: 'a', path: 'Root/a', files: [f1], children: [] }, { name: 'b', path: 'Root/b', files: [f2], children: [] }, ], }]; const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([c.srcKeyForFile(f1)], leaf, 'tracking'); c.place([c.srcKeyForFile(f1)], bin, 'transmittal'); c.place([c.srcKeyForFile(f2)], leaf, 'tracking'); c.place([c.srcKeyForFile(f2)], bin, 'transmittal'); return copy.conflictsIn(copy.plan()).conflicts.length; }); expect(conflicts).toBe(1); }); // ── Workspaces: snapshot, persistence, copy-from-snapshot ────────────────── test('snapshot: serialize + rebuild preserves structure, marks done, drops handles', async ({ page }) => { const r = await page.evaluate(() => { const sc = window.app.modules.scanner; window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Root' }], children: [{ name: 'sub', path: 'Root/sub', scanState: 'done', files: [{ originalFilename: 'b', extension: 'txt', folderPath: 'Root/sub' }], children: [], }], }]; const json = JSON.stringify(sc.snapshotTree()); window.app.folderTree = []; sc.loadSnapshot(JSON.parse(json)); const root = window.app.folderTree[0]; return { rootName: root.name, done: root.scanState === 'done', runFiles: root.runFiles, subFile: root.children[0].files[0].originalFilename, subPath: root.children[0].files[0].folderPath, handleNull: root.children[0].files[0].handle === null, }; }); expect(r.rootName).toBe('Root'); expect(r.done).toBe(true); expect(r.runFiles).toBe(2); expect(r.subFile).toBe('b'); expect(r.subPath).toBe('Root/sub'); expect(r.handleNull).toBe(true); }); test('scan: resume scans only the pending folders from a snapshot', async ({ page }) => { const r = await page.evaluate(async () => { const sc = window.app.modules.scanner; // Snapshot: Root (done) with a child 'sub' left pending. sc.loadSnapshot([{ n: 'Root', p: 'Root', c: [{ n: 'sub', p: 'Root/sub', s: 'pending' }] }]); // Mock root handle: Root/sub contains one file. const subDir = { kind: 'directory', name: 'sub', values: async function* () { yield { kind: 'file', name: 'x.pdf' }; } }; const root = { kind: 'directory', name: 'Root', getDirectoryHandle: async (n) => { if (n === 'sub') return subDir; const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }, }; window.app.rootHandle = root; const did = await sc.resumeScan(root); const sub = window.app.folderTree[0].children[0]; return { did, subState: sub.scanState, subFiles: sub.files.length, name: sub.files[0] && sub.files[0].originalFilename }; }); expect(r.did).toBe(true); expect(r.subState).toBe('done'); expect(r.subFiles).toBe(1); expect(r.name).toBe('x'); }); test('persist: workspace put / list / get / delete round-trip', async ({ page }) => { const r = await page.evaluate(async () => { const P = window.app.modules.persist; const id = 'test-ws-1'; await P.putWorkspace( { id, name: 'WS', rootName: 'Root', createdAt: 1, updatedAt: 2, summary: { files: 3, done: 1, excluded: 0 } }, { id, rootHandle: null, tree: [{ n: 'Root', p: 'Root' }], classify: { assignments: {}, trackingTree: [], transmittalTree: [] } }); const meta = (await P.listWorkspaces()).filter((w) => w.id === id)[0]; const data = await P.getWorkspace(id); await P.deleteWorkspace(id); const goneAfter = (await P.listWorkspaces()).filter((w) => w.id === id).length; return { name: meta && meta.name, files: meta && meta.summary.files, treeLen: data && data.tree.length, goneAfter }; }); expect(r.name).toBe('WS'); expect(r.files).toBe(3); expect(r.treeLen).toBe(1); expect(r.goneAfter).toBe(0); }); test('persist: classify-only autosave preserves the stored snapshot', async ({ page }) => { const r = await page.evaluate(async () => { const P = window.app.modules.persist; const id = 'test-ws-2'; await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 1, summary: { files: 1, done: 0, excluded: 0 } }, { id, rootHandle: null, tree: [{ n: 'R', p: 'R' }], classify: {} }); await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 2, summary: { files: 1, done: 1, excluded: 0 } }, { id, classify: { assignments: { x: {} } } }); // no tree → must preserve const data = await P.getWorkspace(id); await P.deleteWorkspace(id); return { treePreserved: !!(data && data.tree && data.tree.length === 1), hasClassify: !!(data && data.classify.assignments) }; }); expect(r.treePreserved).toBe(true); expect(r.hasClassify).toBe(true); }); test('copy: snapshot files (no handle) resolve from the workspace root', async ({ page }) => { await page.click('#modeClassifyBtn'); const res = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; const srcStore = { 'Sub/foundation.pdf': 'AAA' }; const mkSrcDir = (prefix) => ({ name: prefix || 'Root', getDirectoryHandle: async (n) => mkSrcDir((prefix ? prefix + '/' : '') + n), getFileHandle: async (n) => { const full = (prefix ? prefix + '/' : '') + n; if (!(full in srcStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } return { getFile: async () => new File([srcStore[full]], n) }; }, queryPermission: async () => 'granted', requestPermission: async () => 'granted', }); window.app.rootHandle = mkSrcDir(''); const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root/Sub', handle: null }; window.app.folderTree = [{ name: 'Root', path: 'Root', files: [], children: [{ name: 'Sub', path: 'Root/Sub', files: [f], children: [] }] }]; const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal'); const outStore = {}; const mkOut = (prefix) => ({ name: prefix || 'out', getDirectoryHandle: async (n) => mkOut((prefix ? prefix + '/' : '') + n), getFileHandle: async (n, opts) => { const full = (prefix ? prefix + '/' : '') + n; if (!opts || !opts.create) { if (!(full in outStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } } return { getFile: async () => new File([outStore[full] != null ? outStore[full] : ''], n), createWritable: async () => ({ write: async (d) => { outStore[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }), }; }, }); const s = await copy.copyTo(mkOut(''), copy.plan()); return { copied: s.copied, content: Object.values(outStore)[0], wrote: Object.keys(outStore).some((k) => k.endsWith('foundation.pdf')) }; }); expect(res.copied).toBe(1); expect(res.wrote).toBe(true); expect(res.content).toBe('AAA'); }); test('deleting a tracking node clears the files placed in it', async ({ page }) => { const after = await page.evaluate((file) => { const c = window.app.modules.classify; const key = c.srcKeyForFile(file); const acme = c.addTrackingNode(null, 'ACME'); const leaf = c.addTrackingNode(acme, 'A (IFR)'); c.place([key], leaf, 'tracking'); c.deleteNode(acme); // removes leaf too return c.fileState(file); }, FILE); expect(after).toBe('none'); }); test('expandFolderPattern: alternation, zero-padded ranges, cartesian product', async ({ page }) => { const r = await page.evaluate(() => { const c = window.app.modules.classify; return { plain: c.expandFolderPattern('Plain'), alt: c.expandFolderPattern('A-{PM,EL,EM}'), range: c.expandFolderPattern('X-{0001-0002,0005}'), full: c.expandFolderPattern('BMB-187023-{PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)'), unbalanced: c.expandFolderPattern('Lit-{oops'), }; }); expect(r.plain).toEqual(['Plain']); expect(r.alt).toEqual(['A-PM', 'A-EL', 'A-EM']); expect(r.range).toEqual(['X-0001', 'X-0002', 'X-0005']); expect(r.full.length).toBe(9); expect(r.full[0]).toBe('BMB-187023-PM-MOM-0001_A (IFR)'); expect(r.full).toContain('BMB-187023-EM-MOM-0005_A (IFR)'); expect(r.unbalanced).toEqual(['Lit-{oops']); // unbalanced brace kept literal }); test('Hide Assigned: hides files dealt-with on the active axis and folders left empty', async ({ page }) => { await page.click('#modeClassifyBtn'); const before = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); const fA = { originalFilename: 'a1', extension: 'pdf', folderPath: 'A' }; window.app.folderTree = [ { name: 'A', path: 'A', expanded: true, scanState: 'done', files: [fA], children: [] }, { name: 'B', path: 'B', expanded: true, scanState: 'done', files: [{ originalFilename: 'b1', extension: 'pdf', folderPath: 'B' }], children: [] }, ]; const t = c.addTrackingNode(null, 'TN'); // assign A's file on the tracking axis c.place([c.srcKeyForFile(fA)], t, 'tracking'); window.app.modules.tree.render(); return document.querySelectorAll('#folderTree .file-item').length; }); expect(before).toBe(2); // nothing hidden yet const after = await page.evaluate(() => { window.app.modules.tree.setShowFilters({ unassigned: true, assigned: false, excluded: false }); return { files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map(e => e.textContent), folderA: !!document.querySelector('#folderTree .folder-item[data-path="A"]'), folderB: !!document.querySelector('#folderTree .folder-item[data-path="B"]'), }; }); expect(after.folderA).toBe(false); // A's only file is assigned on the active (tracking) axis → folder hidden expect(after.folderB).toBe(true); // B still needs a tracking number → stays expect(after.files).toEqual(['b1.pdf']); }); test('parseFolderLevels: split by - then a final _ into nested levels', async ({ page }) => { const r = await page.evaluate(() => { const c = window.app.modules.classify; return { three: c.parseFolderLevels('CPO-0001_0 (IFU)'), full: c.parseFolderLevels('BMB-187023-PM-MOM-0001_A (IFR)'), leafOnly: c.parseFolderLevels('A (IFR)'), noRev: c.parseFolderLevels('CPO-0001'), dateRev: c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)'), }; }); expect(r.three).toEqual(['CPO', '0001', '0 (IFU)']); expect(r.full).toEqual(['BMB', '187023', 'PM', 'MOM', '0001', 'A (IFR)']); expect(r.leafOnly).toEqual(['A (IFR)']); expect(r.noRev).toEqual(['CPO', '0001']); // A date revision after "_" stays one leaf — its hyphens are NOT split. expect(r.dateRev).toEqual(['LKU', '123456', 'PM', 'SCH', '0001', '2025-11-17 (IFI)']); }); test('add-folder builds a nested chain sharing common ancestors', async ({ page }) => { const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); // Brace-expand then nest: two leaves under shared CPO/000x ancestors. c.expandFolderPattern('CPO-{0001,0002}_0 (IFU)').forEach((nm) => c.addTrackingPath(null, c.parseFolderLevels(nm))); return JSON.parse(JSON.stringify(c.getTrackingTree(), (k, v) => (k === 'id' ? undefined : v))); }); expect(r.length).toBe(1); // one shared CPO root expect(r[0].name).toBe('CPO'); expect(r[0].children.map((n) => n.name)).toEqual(['0001', '0002']); expect(r[0].children[0].children[0].name).toBe('0 (IFU)'); // leaf rev under each number }); test('trackingNodeComplete: true only for a leaf with a valid status', async ({ page }) => { const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); const parent = c.addTrackingNode(null, 'CPO'); const num = c.addTrackingNode(parent, '0001'); const leaf = c.addTrackingNode(num, '0 (IFU)'); const bare = c.addTrackingNode(c.addTrackingNode(null, 'X'), '0001'); // leaf, no status return { root: c.trackingNodeComplete(parent), // has children num: c.trackingNodeComplete(num), // has a child leaf leaf: c.trackingNodeComplete(leaf), // leaf + valid status bare: c.trackingNodeComplete(bare), // leaf, no "(STATUS)" }; }); expect(r).toEqual({ root: false, num: false, leaf: true, bare: false }); }); test('editing a placed file’s filename re-files it onto the parsed tracking path', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); const leaf = c.addTrackingNode(c.addTrackingNode(null, 'OLD'), 'A (IFR)'); const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' }; const key = c.srcKeyForFile(file); c.place([key], leaf, 'tracking'); window.app.folderTree = [{ name: 'Sub', path: 'Sub', expanded: true, scanState: 'done', children: [], files: [file], }]; window.app.modules.targetTree.render(); const input = document.querySelector('#trackingTree .tfile__name'); input.value = 'CPO-0002_0 (IFU) - New Title.pdf'; input.dispatchEvent(new Event('change', { bubbles: true })); const d = c.deriveTarget(file); return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete }; }); expect(r.tracking).toBe('CPO-0002'); expect(r.revision).toBe('0'); expect(r.status).toBe('IFU'); expect(r.title).toBe('New Title'); }); test('dataset (filename-based): import reconstruction rebuilds tracking + shared transmittals', async ({ page }) => { const r = await page.evaluate(() => { const c = window.app.modules.classify; const z = window.zddc; c.reset(); // Mirrors app.importDataset's per-record reconstruction: two docs sharing // one transmittal package, plus an excluded junk file. const recs = [ { source: 'a.pdf', filename: 'CPO-0001_0 (IFU) - Doc A.pdf', excluded: false, transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } }, { source: 'b.pdf', filename: 'CPO-0002_0 (IFU) - Doc B.pdf', excluded: false, transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } }, { source: 'junk.tmp', filename: '', excluded: true }, ]; recs.forEach((rec) => { if (rec.excluded) { c.setExcluded([rec.source], true); return; } const p = z.parseFilename(rec.filename); c.place([rec.source], c.addTrackingPath(null, c.parseFolderLevels(p.trackingNumber + '_' + p.revision + ' (' + p.status + ')')), 'tracking'); c.setTitleOverride(rec.source, p.title); const t = rec.transmittal; const bid = c.findOrAddTransmittalBin(c.findOrAddParty(t.party), t.slot, t); c.place([rec.source], bid, 'transmittal'); }); const da = c.deriveTarget({ folderPath: '', originalFilename: 'a', extension: 'pdf' }); // key 'a.pdf' const tree = c.getTransmittalTree(); return { tracking: da.tracking, rev: da.revision, status: da.status, title: da.title, parties: tree.length, bins: tree[0] ? tree[0].children.filter((s) => s.slot === 'received')[0].children.length : -1, excluded: c.getAssignment('junk.tmp').excluded, }; }); expect(r.tracking).toBe('CPO-0001'); expect(r.rev).toBe('0'); expect(r.status).toBe('IFU'); expect(r.title).toBe('Doc A'); expect(r.parties).toBe(1); // one Acme party expect(r.bins).toBe(1); // shared transmittal → single bin (dedup) expect(r.excluded).toBe(true); }); test('source-tree filter reveals matches with their folder hierarchy', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { window.app.folderTree = [{ name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ { name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [ { originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' }, { originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' }, ] }, { name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [ { originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' }, ] }, ], }]; window.app.modules.tree.render(); window.app.modules.tree.setNameFilter('master deliverables'); return { files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent), folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path), }; }); expect(r.files).toEqual(['Master Deliverables List.xlsx']); // only the match shown expect(r.folders).toEqual(['Project', 'Project/Electrical']); // path revealed; Civil hidden }); test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => { await page.click('#modeClassifyBtn'); const names = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)')); c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)')); window.app.modules.targetTree.render(); window.app.modules.targetTree.setNameFilter('CPO'); return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent); }); expect(names).toContain('CPO'); expect(names).toContain('0001'); expect(names).not.toContain('XYZ'); }); test('Show Empty off hides folders that contain no files', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { window.app.folderTree = [ { name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }] }, { name: 'EmptyDir', path: 'EmptyDir', expanded: true, scanState: 'done', children: [], files: [] }, ]; window.app.modules.tree.render(); const before = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort(); window.app.modules.tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false }); const after = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort(); return { before, after }; }); expect(r.before).toEqual(['Docs', 'EmptyDir']); // both shown by default expect(r.after).toEqual(['Docs']); // empty folder hidden when Show Empty off }); test('toggling a Show filter preserves collapse state (no force-expand)', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { window.app.folderTree = [{ name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ { name: 'Sub', path: 'Project/Sub', expanded: false, scanState: 'done', children: [], files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Project/Sub' }] }, ], }]; const tree = window.app.modules.tree; tree.render(); const collapsed = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path); // A Show toggle must not expand the collapsed parent… tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false }); const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path); // …whereas a name search still reveals the match by auto-expanding. tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true }); tree.setNameFilter('a.pdf'); const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path); return { collapsed, afterToggle, afterSearch }; }); expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed expect(r.afterSearch).toEqual(['Project', 'Project/Sub']); // name search auto-expands to the match }); test('search opens only the branch with a hit, leaving siblings collapsed', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { window.app.folderTree = [{ name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ { name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [ { originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' }, ] }, { name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [ { originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' }, ] }, ], }]; const tree = window.app.modules.tree; tree.render(); tree.setNameFilter('switchgear'); // a file deep in the Electrical branch return { folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path), files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent), }; }); // Path to the hit opens; the unrelated Civil sibling is not force-opened (stays out). expect(r.folders).toEqual(['Project', 'Project/Electrical']); expect(r.files).toEqual(['Switchgear Spec.pdf']); }); test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => { const r = await page.evaluate(() => { const sc = window.app.modules.scanner; window.app.folderTree = [{ name: 'Root', path: 'Root', scanState: 'done', files: [], children: [{ name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, zipPath: 'Root/docs.zip', scanState: 'done', children: [], files: [{ originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf', }], }], }]; const json = JSON.stringify(sc.snapshotTree()); window.app.folderTree = []; sc.loadSnapshot(JSON.parse(json)); const zip = window.app.folderTree[0].children[0]; const m = zip.files[0]; return { isZipRoot: zip.isZipRoot, zipPath: zip.zipPath, done: zip.scanState === 'done', virtual: m.isVirtual, mZip: m.zipPath, entry: m.zipEntryPath, handleNull: m.handle === null, }; }); expect(r.isZipRoot).toBe(true); // archive preserved as an expandable folder expect(r.zipPath).toBe('Root/docs.zip'); expect(r.done).toBe(true); expect(r.virtual).toBe(true); // member flagged virtual… expect(r.mZip).toBe('Root/docs.zip'); // …with enough to re-extract expect(r.entry).toBe('spec.pdf'); expect(r.handleNull).toBe(true); }); test('copy: a zip member is extracted from its archive and written out', async ({ page }) => { await page.click('#modeClassifyBtn'); const res = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; const f = { originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf', handle: null, }; window.app.folderTree = [{ name: 'Root', path: 'Root', files: [], children: [ { name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, files: [f], children: [] }, ] }]; // Stub archive extraction — return the member's bytes as a Blob. window.app.rootHandle = {}; window.app.modules.scanner.extractZipMember = async () => new File(['ZIPBYTES'], 'spec.pdf'); const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal'); const outStore = {}; const mkOut = (prefix) => ({ name: prefix || 'out', getDirectoryHandle: async (n) => mkOut((prefix ? prefix + '/' : '') + n), getFileHandle: async (n, opts) => { const full = (prefix ? prefix + '/' : '') + n; if (!opts || !opts.create) { if (!(full in outStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } } return { getFile: async () => new File([outStore[full] != null ? outStore[full] : ''], n), createWritable: async () => ({ write: async (d) => { outStore[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }), }; }, }); const s = await copy.copyTo(mkOut(''), copy.plan()); return { copied: s.copied, content: Object.values(outStore)[0], wrote: Object.keys(outStore).some((k) => k.endsWith('spec.pdf')) }; }); expect(res.copied).toBe(1); expect(res.wrote).toBe(true); expect(res.content).toBe('ZIPBYTES'); }); test('workspace: import recreates a transferable record (snapshot + map, no handle)', async ({ page }) => { const r = await page.evaluate(async () => { const ws = window.app.modules.workspace, P = window.app.modules.persist; const json = JSON.stringify({ zddcWorkspace: 1, meta: { name: 'Transferred', rootName: 'BigProject', summary: { files: 3, done: 1, excluded: 0 } }, tree: [{ n: 'BigProject', p: 'BigProject', f: [{ o: 'a', e: 'pdf', p: 'BigProject' }] }], classify: { assignments: { 'a.pdf': { trackingNodeId: null, transmittalNodeId: null, excluded: true, titleOverride: null } }, trackingTree: [], transmittalTree: [], }, }); const file = new File([json], 'x.zddc-workspace.json', { type: 'application/json' }); const id = await ws.importWorkspace(file); const rec = id && await P.getWorkspace(id); const rows = await P.listWorkspaces(); return { listed: rows.some((x) => x.id === id && x.name === 'Transferred' && x.rootName === 'BigProject'), hasTree: !!(rec && rec.tree && rec.tree.length), excluded: !!(rec && rec.classify && rec.classify.assignments['a.pdf'] && rec.classify.assignments['a.pdf'].excluded), noHandle: rec ? (rec.rootHandle == null) : false, }; }); expect(r.listed).toBe(true); // appears in the welcome list expect(r.hasTree).toBe(true); // the expensive scan came across expect(r.excluded).toBe(true); // classifications came across expect(r.noHandle).toBe(true); // source handle intentionally absent (re-attach on this browser) }); test('zip mode: collapse turns an expanded archive back into one .zip file', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, sc = window.app.modules.scanner; const member = { originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf' }; const zipNode = { name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, zipPath: 'Root/docs.zip', files: [member], children: [] }; const root = { name: 'Root', path: 'Root', files: [], children: [zipNode] }; zipNode.parent = root; window.app.folderTree = [root]; c.setExcluded([c.srcKeyForFile(member)], true); const hadAssign = !!(c.getAssignment('docs.zip/spec.pdf') || {}).excluded; sc.collapseZipToFile(zipNode); const file = root.files[0]; return { hadAssign, noChild: root.children.length === 0, fileName: file && (file.originalFilename + '.' + file.extension), droppedMemberAssign: !c.getAssignment('docs.zip/spec.pdf'), }; }); expect(r.hadAssign).toBe(true); // member had an assignment expect(r.noChild).toBe(true); // archive folder removed expect(r.fileName).toBe('docs.zip'); // single .zip file restored expect(r.droppedMemberAssign).toBe(true); // member assignment cleared }); test('a fully-excluded folder is struck through like its files', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, tree = window.app.modules.tree; const f1 = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }; const f2 = { originalFilename: 'b', extension: 'pdf', folderPath: 'Docs' }; window.app.folderTree = [{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], files: [f1, f2] }]; tree.render(); const sel = '#folderTree .folder-item[data-path="Docs"]'; const before = document.querySelector(sel).classList.contains('excluded'); c.setExcluded([c.srcKeyForFile(f1), c.srcKeyForFile(f2)], true); tree.render(); const after = document.querySelector(sel).classList.contains('excluded'); return { before, after }; }); expect(r.before).toBe(false); // not struck through while active expect(r.after).toBe(true); // struck through once the whole subtree is excluded }); test('By-tracking table merges shared ancestors and aligns revisions', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)')); c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_A (IFR)')); c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)')); tt.render(); const cellByName = (n) => Array.from(document.querySelectorAll('#trackingTree .ttable__cell .tcell__name')) .filter((e) => e.textContent === n).map((e) => e.closest('td'))[0]; const lku = cellByName('LKU'), cpo = cellByName('CPO'); return { lkuSpan: lku ? lku.rowSpan : 0, cpoSpan: cpo ? cpo.rowSpan : 0, revs: Array.from(document.querySelectorAll('#trackingTree .ttable__rev .tcell__name')).map((e) => e.textContent), }; }); expect(r.lkuSpan).toBe(2); // the LKU ancestor cell spans its two revisions (merged) expect(r.cpoSpan).toBe(1); // The revisions live in one aligned column; the date revision stays intact. expect(r.revs).toEqual(['2025-11-17 (IFI)', 'A (IFR)', '0 (IFU)']); }); test('revision cell links to preview its file and shows no count bubble', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root' }; window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }]; const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)')); c.place([c.srcKeyForFile(f)], leaf, 'tracking'); tt.render(); const rev = document.querySelector('#trackingTree .ttable__rev'); const link = rev.querySelector('.tcell__preview[data-preview-key]'); return { hasPreview: !!link, previewKey: link && link.dataset.previewKey, hasBadge: !!rev.querySelector('.tnode__badge'), }; }); expect(r.hasPreview).toBe(true); // revision name is a preview link expect(r.previewKey).toBe('foundation.pdf'); expect(r.hasBadge).toBe(false); // no count bubble }); test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, tree = window.app.modules.tree, tt = window.app.modules.targetTree; c.reset(); const f = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }; window.app.folderTree = [{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], files: [f] }]; // Assign tracking only, then view the TRANSMITTAL tab → it reads as "partial" there. const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)')); c.place([c.srcKeyForFile(f)], leaf, 'tracking'); tt.showTab('transmittal'); tree.render(); const withPartial = !!document.querySelector('#folderTree .file-item'); tree.setShowFilters({ unassigned: true, partial: false, assigned: true, excluded: true }); const withoutPartial = !!document.querySelector('#folderTree .file-item'); return { withPartial, withoutPartial }; }); expect(r.withPartial).toBe(true); // shown while Partial is on (to-do for this tab) expect(r.withoutPartial).toBe(false); // hidden once Partial is off }); test('copy: PUTs into a server-style handle, then resumes by skipping existing', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; c.reset(); const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root', handle: { getFile: async () => new File(['AAA'], 'foundation.pdf') }, }; window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }]; const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal'); // Server-style handle: getDirectoryHandle never verifies (like the HTTP // polyfill); getFileHandle does a HEAD-style existence check. const store = {}, mkdirs = []; const srvDir = (base) => ({ getDirectoryHandle: async (n, opts) => { if (opts && opts.create) mkdirs.push(base + n); return srvDir(base + n + '/'); }, getFileHandle: async (n, opts) => { const full = base + n; if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } return { createWritable: async () => ({ write: async (d) => { store[full] = d; }, close: async () => {} }) }; }, }); const out = srvDir(''); const first = await copy.copyTo(out, copy.plan()); const second = await copy.copyTo(out, copy.plan()); // existing → skipped (resume) return { firstCopied: first.copied, secondSkipped: second.skipped, paths: Object.keys(store) }; }); expect(r.firstCopied).toBe(1); expect(r.secondSkipped).toBe(1); expect(r.paths[0].startsWith('ClientCorp/received/')).toBe(true); expect(r.paths[0].endsWith('ACME-MECH-0001_A (IFR) - foundation.pdf')).toBe(true); }); test('copy audit: same name+rev — identical content dedups, different content conflicts', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; c.reset(); const mk = (folder, content) => ({ originalFilename: 'doc', extension: 'pdf', folderPath: 'R/' + folder, handle: { getFile: async () => new File([content], 'doc.pdf') }, }); const s1 = mk('S1', 'SAME'), s2 = mk('S2', 'SAME'), d1 = mk('D1', 'AAA'), d2 = mk('D2', 'BBB'); window.app.folderTree = [{ name: 'R', path: 'R', files: [], children: [ { name: 'S1', path: 'R/S1', files: [s1], children: [] }, { name: 'S2', path: 'R/S2', files: [s2], children: [] }, { name: 'D1', path: 'R/D1', files: [d1], children: [] }, { name: 'D2', path: 'R/D2', files: [d2], children: [] }, ] }]; const L1 = c.addTrackingNode(c.addTrackingNode(null, 'ACME-0001'), 'A (IFR)'); const L2 = c.addTrackingNode(c.addTrackingNode(null, 'ACME-0002'), 'A (IFR)'); const T = c.addTransmittalBin(c.addParty('CC'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); [[s1, L1], [s2, L1], [d1, L2], [d2, L2]].forEach(([f, leaf]) => { c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], T, 'transmittal'); }); const res = await copy.resolvePlan(copy.plan()); return { todo: res.todo.length, dupes: res.dupeCount, conflicts: res.conflicts.length, s1Flagged: !!res.conflictKeys[c.srcKeyForFile(s1)], d1Flagged: !!res.conflictKeys[c.srcKeyForFile(d1)], }; }); expect(r.todo).toBe(1); // the identical pair collapses to one; the conflicting pair is excluded expect(r.dupes).toBe(1); // one duplicate collapsed expect(r.conflicts).toBe(1); // one same-name/different-content group expect(r.s1Flagged).toBe(false); expect(r.d1Flagged).toBe(true); }); test('copy: verifies copied bytes; a bad write fails verification and is removed', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; c.reset(); const f = { originalFilename: 'doc', extension: 'pdf', folderPath: 'R', handle: { getFile: async () => new File(['GOOD'], 'doc.pdf') } }; window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-0001'), 'A (IFR)'); const bin = c.addTransmittalBin(c.addParty('CC'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal'); // A dir whose writes CORRUPT the content → verification must catch it. const store = {}, removed = []; const mkdir = (base) => ({ getDirectoryHandle: async (n) => mkdir(base + n + '/'), getFileHandle: async (n, opts) => { const full = base + n; if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } return { getFile: async () => new File([store[full]], n), createWritable: async () => ({ write: async () => { store[full] = 'CORRUPT'; }, close: async () => {} }), }; }, removeEntry: async (n) => { delete store[base + n]; removed.push(base + n); }, }); const s = await copy.copyTo(mkdir(''), copy.plan()); return { copied: s.copied, verifyFailed: s.verifyFailed, removed: removed.length, left: Object.keys(store).length }; }); expect(r.copied).toBe(1); expect(r.verifyFailed).toBe(1); // SHA mismatch caught expect(r.removed).toBe(1); // bad copy removed… expect(r.left).toBe(0); // …so a re-run re-copies it }); test('transmittal: rename a bin (feeds the folder), remove and move a placed file', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(async () => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); const f = { originalFilename: 'doc', extension: 'pdf', folderPath: 'R' }; window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; const key = c.srcKeyForFile(f); const party = c.addParty('CC'); const bin1 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); const bin2 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0008' }); c.place([key], bin1, 'transmittal'); tt.showTab('transmittal'); tt.render(); // Rename the bin → it becomes the copy folder name. c.renameNode(bin1, 'My Custom Transmittal'); const renamed = c.getNode(bin1).name === 'My Custom Transmittal'; const folder = c.deriveTarget(f).transmittalFolder; // The placed-file row is draggable (move) and carries a remove button. tt.render(); const row = document.querySelector('#transmittalTree .tfile[data-key]'); const draggable = !!(row && row.draggable); const hasRemove = !!(row && row.querySelector('.tfile__remove[data-act="untransmit"]')); // Remove from the transmittal (click ✕). row.querySelector('.tfile__remove').click(); const a1 = c.getAssignment(key); const removed = !(a1 && a1.transmittalNodeId); // Move = re-place onto another bin (what dropping on bin2 does). c.place([key], bin2, 'transmittal'); const movedTo = (c.getAssignment(key) || {}).transmittalNodeId === bin2; return { renamed, folder, draggable, hasRemove, removed, movedTo }; }); expect(r.renamed).toBe(true); expect(r.folder).toBe('My Custom Transmittal'); // rename drives the filing folder expect(r.draggable).toBe(true); expect(r.hasRemove).toBe(true); expect(r.removed).toBe(true); expect(r.movedTo).toBe(true); });