ZDDC/tests/classify.spec.js
ZDDC 420f735e89 feat(classifier): copy-out with duplicate detection + map restore (phase 5)
The Copy button (enabled once >=1 file is fully classified) copies the mapped
files into a user-chosen output directory under their canonical names/layout
<party>/{received,issued}/<transmittal>/<filename> — reading the source, never
writing it.

- copy.js: plan() (complete, non-excluded files) → conflict scan (two sources
  → same output path are reported + skipped) → copyTo() engine on the generic
  FS-Access shape (ensureDir + getFileHandle + createWritable). Per-file dedup:
  identical target (sha256) is skipped; existing-but-different is left
  untouched and reported; live footer progress; completion toast.
- app.js: restores the saved map on launch (keyed by source-relative path, so
  it re-attaches when the same directory is re-opened) and persists the source
  handle on open; Copy button wired.
- target-tree.js: enables/labels the Copy button from the done count.
- 2 copy-engine tests with mock FS handles (copy/skip/differ + conflict);
  24 classify+classifier tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:37:44 -05:00

371 lines
18 KiB
JavaScript

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