ZDDC/tests/classify.spec.js
ZDDC bc762a7d74 feat(classifier): rename transmittals; remove/move files already in one
By-transmittal pane gains the editing it was missing:
- Rename a transmittal after creation (✎ on the bin → prompt). The name IS the
  filing folder (deriveTarget reads bin.name), so renaming changes where copies
  land, not just the label.
- Each placed file row is now draggable — drop it on another transmittal to MOVE
  it — and carries an ✕ to remove it from the transmittal (clears that axis).
  previewFromTarget now lets action buttons through so ✕ doesn't trigger preview.

Test: rename feeds the derived folder; the row is draggable + has remove; ✕
clears the transmittal; re-place moves it to another bin (55 green).

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

1179 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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