/** * 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); }); 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'); });