/** * 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 nodes rendered. await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible(); await expect(page.locator('#trackingTree .tnode--leaf .tnode__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) adds a tracking node', async ({ page }) => { await page.click('#modeClassifyBtn'); page.once('dialog', (d) => d.accept('ACME-PROJ')); await page.click('#addTrackingRootBtn'); await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).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 .tnode--leaf .tnode__row'); 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('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, skips an identical re-copy, flags a differing 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()); // identical → skipped const tkey = Object.keys(store)[0]; store[tkey] = 'DIFFERENT'; // tamper target const third = await copy.copyTo(out, copy.plan()); // differs → left alone return { firstCopied: first.copied, secondSkipped: second.skipped, thirdDiffer: third.differ, keys: Object.keys(store) }; }); expect(res.firstCopied).toBe(1); expect(res.secondSkipped).toBe(1); expect(res.thirdDiffer).toBe(1); 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); }); 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'); });