ZDDC/tests/classify.spec.js
ZDDC 2b32aced6d feat(classifier): By Tracking Number is now a flat editable grid (one row per file)
Replace the merged-cell positional table (one column per tracking-number segment,
hierarchy via shared ancestors, built by creating folders) with a plain editable
spreadsheet: one row per file, with the tracking number, the rev (status), and
the title as three separate editable columns. Columns are hideable + resizable.

The storage model is unchanged — a file's tracking identity is still its
placement in the tracking-folder tree. The grid is a flat presentation + inline-
edit layer over it; editing a cell re-materializes the placement via the existing
path (addTrackingPath → place(…,'tracking') → setTitleOverride), generalized to
per-field.

- classify.js: `trackingWorkset` (serialized) so a dropped file is a row before
  it has a number; `addToTrackingGrid`/`removeFromTrackingGrid`/`trackingGridKeys`
  (union with files that have a tracking placement — incl. ones named via "From a
  list"); `setFileIdentity(key, {tracking, rev, title})` re-files + prunes the old
  leaf; blank tracking = an unfilled row, blank rev = a PENDING_REV leaf.
- target-tree.js: `renderTrackingGrid` (Status badge · Original name preview ·
  Tracking number · Rev (status) · Title · ✕); drag onto the grid adds rows and
  auto-fills any file whose own name already parses as ZDDC; a "Columns ▾" chooser
  + drag-resize (resize.js, now parameterized) persisted to localStorage. The
  status badge validates the NAME only (the transmittal is a different tab).
  Removed the merged-cell machinery + per-node CRUD (+ Root folder, ✎/🗑, brace
  expansion) and the now-dead drop-on-node path.
- template/css: tracking toolbar → Columns chooser + hint; flat-grid + chooser CSS.

Tests: replaced the merged-cell/+Root-folder/drop-on-leaf/filename-edit tests with
grid tests (render, drop+auto-fill, per-cell re-file, filter, hide/persist,
preview link). Suite 342 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:51:39 -05:00

1568 lines
86 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 the By-tracking grid and tabs switch', async ({ page }) => {
await page.click('#modeClassifyBtn');
await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-PROJ-EL-DWG-0001', rev: 'A (IFR)', title: 'Spec' });
const party = c.addParty('ClientCorp');
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
window.app.modules.targetTree.render();
});
// The grid shows the file's tracking number in an editable cell.
await expect(page.locator('#trackingTree .ttable--grid')).toBeVisible();
await expect(page.locator('#trackingTree .tg-tn .tg-input')).toHaveValue('ACME-PROJ-EL-DWG-0001');
// 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();
});
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC names', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify; c.reset();
const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' };
const named = { originalFilename: 'ACME-MECH-0001_A (IFR) - Pump', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [plain, named], children: [] }];
const tt = window.app.modules.targetTree; tt.render();
const grid = document.querySelector('#trackingTree');
function drop(key) { window.app.modules.dnd.setDrag([key]); grid.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); }
drop(c.srcKeyForFile(plain));
drop(c.srcKeyForFile(named));
return {
rows: c.trackingGridKeys().length,
namedTn: c.deriveTarget(named).tracking, namedRev: c.deriveTarget(named).revision,
plainTn: c.deriveTarget(plain).tracking,
};
});
expect(r.rows).toBe(2); // both files added as rows
expect(r.namedTn).toBe('ACME-MECH-0001'); // the ZDDC-named file auto-filled
expect(r.namedRev).toBe('A');
expect(r.plainTn).toBe(''); // the plain file is a blank row to fill in
});
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('Folder Tree renders folders and files in natural, case-insensitive order', async ({ page }) => {
await page.click('#modeClassifyBtn');
const order = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
children: [
{ name: 'Beta', path: 'Root/Beta', scanState: 'done', children: [], files: [] },
{ name: 'alpha', path: 'Root/alpha', scanState: 'done', children: [], files: [] },
{ name: 'Rev 10', path: 'Root/Rev 10', scanState: 'done', children: [], files: [] },
{ name: 'Rev 2', path: 'Root/Rev 2', scanState: 'done', children: [], files: [] },
],
files: [
{ originalFilename: 'zeta', extension: 'pdf', folderPath: 'Root' },
{ originalFilename: 'Apple', extension: 'pdf', folderPath: 'Root' },
{ originalFilename: 'banana', extension: 'pdf', folderPath: 'Root' },
],
}];
window.app.modules.tree.render();
return {
folders: Array.from(document.querySelectorAll('#folderTree .folder-children .folder-name')).map(e => e.textContent.trim()),
files: Array.from(document.querySelectorAll('#folderTree .folder-files .file-name')).map(e => e.textContent.trim()),
};
});
expect(order.folders).toEqual(['alpha', 'Beta', 'Rev 2', 'Rev 10']); // case-insensitive + natural (2 < 10)
expect(order.files).toEqual(['Apple.pdf', 'banana.pdf', 'zeta.pdf']); // case-insensitive
});
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 grid cells re-files the file onto the new tracking path', 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 file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
const key = c.srcKeyForFile(file);
const leaf = c.addTrackingPath(null, c.parseFolderLevels('OLD-0001_A (IFR)'));
c.place([key], leaf, 'tracking');
window.app.folderTree = [{ name: 'Sub', path: 'Root/Sub', expanded: true, scanState: 'done', children: [], files: [file] }];
function editCell(cls, val) {
tt.render(); // re-render so we edit the live input each time
const inp = document.querySelector('#trackingTree .' + cls + ' .tg-input');
inp.value = val; inp.dispatchEvent(new Event('change', { bubbles: true }));
}
editCell('tg-tn', 'CPO-0002');
editCell('tg-rev', '0 (IFU)');
editCell('tg-title', 'New Title');
const d = c.deriveTarget(file);
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title };
});
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 hides non-matches in place; never changes expand state', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: true, scanState: 'done', files: [], children: [
// EXPANDED: its match shows in place, the non-match is hidden.
{ name: 'Electrical', path: 'Project/Electrical', expanded: true, scanState: 'done', children: [], files: [
{ originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' },
{ originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
] },
// COLLAPSED but ALSO holds a match — it must stay collapsed (shown
// as a row, file NOT revealed): the filter never auto-expands.
{ name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'master deliverables draft', extension: 'pdf', folderPath: 'Project/Civil' },
] },
],
}];
window.app.modules.tree.render();
window.app.modules.tree.setNameFilter('master deliverables');
const civil = window.app.folderTree[0].children[1];
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).sort(),
civilStillCollapsed: civil.expanded === false,
};
});
expect(r.files).toEqual(['Master Deliverables List.xlsx']); // expanded folder: match in place, Switchgear hidden
expect(r.folders).toEqual(['Project', 'Project/Civil', 'Project/Electrical']); // Civil shown (has a match) but collapsed
expect(r.civilStillCollapsed).toBe(true); // the filter did NOT expand it
});
test('the By-tracking grid filter narrows rows by name/tracking', 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 a = { originalFilename: 'pump', extension: 'pdf', folderPath: 'R' };
const b = { originalFilename: 'valve', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [a, b], children: [] }];
c.setFileIdentity(c.srcKeyForFile(a), { tracking: 'CPO-0001', rev: 'A (IFR)', title: 'Pump' });
c.setFileIdentity(c.srcKeyForFile(b), { tracking: 'XYZ-0009', rev: 'A (IFR)', title: 'Valve' });
tt.render();
tt.setNameFilter('CPO');
return Array.from(document.querySelectorAll('#trackingTree .tg-tn .tg-input')).map((e) => e.value);
});
expect(r).toContain('CPO-0001');
expect(r).not.toContain('XYZ-0009');
});
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);
// …and neither does the name filter — it hides/shows in place, never expands.
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, parentCollapsed: window.app.folderTree[0].expanded === false };
});
expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed
expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed
expect(r.afterSearch).toEqual(['Project']); // name filter leaves it collapsed (no force-expand)
expect(r.parentCollapsed).toBe(true); // expand state untouched
});
test('filter does not open collapsed branches; non-matching siblings hide', 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 (collapsed) 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),
};
});
// Project contains a match so it's shown — but stays COLLAPSED, so Electrical
// isn't rendered and the hit isn't revealed (the user expands to reach it).
// Civil has no match and is hidden.
expect(r.folders).toEqual(['Project']);
expect(r.files).toEqual([]);
});
test('folder count badge shows post-filter totals', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
subdirCount: 2, runDirs: 2, fileCount: 0, runFiles: 3, files: [], children: [
{ name: 'A', path: 'Root/A', expanded: true, scanState: 'done', subdirCount: 0, runDirs: 0, fileCount: 2, runFiles: 2, children: [], files: [
{ originalFilename: 'alpha report', extension: 'pdf', folderPath: 'Root/A' },
{ originalFilename: 'beta memo', extension: 'pdf', folderPath: 'Root/A' },
] },
{ name: 'B', path: 'Root/B', expanded: true, scanState: 'done', subdirCount: 0, runDirs: 0, fileCount: 1, runFiles: 1, children: [], files: [
{ originalFilename: 'gamma note', extension: 'pdf', folderPath: 'Root/B' },
] },
],
}];
const tree = window.app.modules.tree;
const rootCount = () => { const e = document.querySelector('#folderTree .folder-item .folder-count'); return e ? e.textContent : null; };
tree.render();
const before = rootCount(); // no filter → raw scan totals
tree.setNameFilter('alpha'); // matches one file, in folder A only
const after = rootCount();
return { before, after };
});
expect(r.before).toContain('2 folders'); // raw: 2 subfolders…
expect(r.before).toContain('0+3 files'); // …3 files in the subtree
expect(r.after).toContain('1 folder'); // filtered: only A is visible
expect(r.after).toContain('0+1 file'); // …holding the single matching file
});
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('grid: hiding a column drops its cells; a status badge reflects completeness', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
const f = { originalFilename: 'x', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'X' });
tt.render();
const titleBefore = !!document.querySelector('#trackingTree .tg-title');
const badge = document.querySelector('#trackingTree .tg-status .tfile__badge');
// Hide the Title column via the persisted prefs, then re-render.
localStorage.setItem('zddc.classifier.trackingCols', JSON.stringify({ hidden: { title: true } }));
tt.render();
const titleAfter = !!document.querySelector('#trackingTree .tg-title');
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
return { titleBefore, titleAfter, badge: badge && badge.textContent };
});
expect(r.titleBefore).toBe(true);
expect(r.titleAfter).toBe(false); // Title column hidden + persists across re-render
expect(r.badge).toBe('✓'); // complete (tracking + rev + status all set)
});
test('grid: the original-name cell is a preview link', 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: [] }];
let previewed = null;
window.app.modules.preview.previewFile = (file) => { previewed = file.originalFilename; };
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'Foundation' });
tt.render();
const link = document.querySelector('#trackingTree .tg-orig .tg-orig__link');
if (link) link.click();
return { text: link && link.textContent, previewed };
});
expect(r.text).toBe('foundation.pdf');
expect(r.previewed).toBe('foundation'); // clicking the name previews the file
});
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);
});
test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({ page }) => {
const r = await page.evaluate(() => {
const rows = [
{ id: 'a', tn: 'ACM-PRJ-EL-SPC-0001', title: 'Alpha' },
{ id: 'b', tn: 'ACM-PRJ-EL-SPC-0002', title: 'Beta' },
{ id: 'c', tn: 'ACM-PRJ-ME-DWG-0003', title: 'Gamma' },
{ id: 'd', tn: 'ACM-PRJ-ME-DWG-0004', title: 'Delta' },
{ id: 'e', tn: 'XYZ-PRJ-CV-RPT-0009', title: 'Epsilon' },
];
const host = document.createElement('div'); document.body.appendChild(host);
const st = window.app.modules.seltable.create({
container: host, rows: () => rows, rowId: (x) => x.id,
columns: [{ key: 'tn', title: 'Tracking' }, { key: 'title', title: 'Title' }],
});
st.render();
st.clickRow('a', {}); // anchor a
st.clickRow('c', { shiftKey: true }); // range a..c (replaces)
const range1 = st.getSelection().sort().join(',');
st.clickRow('e', { ctrlKey: true }); // toggle-add e
const withE = st.getSelection().sort().join(',');
st.setFilter('DWG'); // narrow to c,d
const shown = st.getFilteredRows().map((x) => x.id).sort().join(',');
st.selectAllFiltered(); // union c,d into the set
const afterAll = st.getSelection().sort().join(',');
st.clear();
st.clickRow('c', {}); // anchor c (within the DWG filter)
st.clickRow('d', { shiftKey: true, ctrlKey: true }); // ctrl-shift range add over filtered
const ctrlShiftRange = st.getSelection().sort().join(',');
host.remove();
return { range1, withE, shown, afterAll, ctrlShiftRange };
});
expect(r.range1).toBe('a,b,c'); // shift range from the anchor
expect(r.withE).toBe('a,b,c,e'); // ctrl-click adds one
expect(r.shown).toBe('c,d'); // autofilter narrows
expect(r.afterAll).toBe('a,b,c,d,e'); // select-filtered unions the visible block
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
});
test('From a list: a drop materializes a real tracking placement; row revision + transmittal complete it', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const f = { originalFilename: 'messy scan 47', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
c.assignFromRow([key], c.getWorklistRow('m1')); // blank revision → partial
const placedTracking = !!(c.getAssignment(key) || {}).trackingNodeId; // a REAL tracking placement
const beforeRev = c.deriveTarget(f);
c.setRevisionCell('m1', 'A (IFR)'); // re-stamps onto the leaf
const named = c.deriveTarget(f);
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([key], bin, 'transmittal');
const full = c.deriveTarget(f);
c.setTitleOverride(key, ''); // use the file's own title instead
const fileTitle = c.deriveTarget(f);
return {
placedTracking, beforeRevErr: beforeRev.errors.length > 0,
beforeTracking: beforeRev.tracking,
named: named.filename, namedComplete: named.complete,
fullName: full.filename, fullComplete: full.complete,
fileTitleName: fileTitle.filename,
};
});
expect(r.placedTracking).toBe(true); // not a separate axis — a tracking placement
expect(r.beforeTracking).toBe('ACM-PRJ-EL-SPC-0001'); // full tracking number preserved while rev pending
expect(r.beforeRevErr).toBe(true); // no revision yet → incomplete
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf');
expect(r.namedComplete).toBe(false); // still needs a transmittal
expect(r.fullComplete).toBe(true);
expect(r.fullName).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf');
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
});
test('From a list: clearing the list keeps classifications; the row drives the seltable', 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: 'scan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([
{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'], revisionCell: 'C (IFC)' },
{ id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] },
]);
tt.showTab('worklist');
const row = document.querySelector('#worklistTable .seltable__row[data-id="m1"]');
const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown
window.app.modules.dnd.setDrag([key]);
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set)
const named = c.deriveTarget(f).filename;
c.clearWorklist(); // list emptied — assignment must survive
return {
hasRow: !!row, latestShown,
placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId,
named,
listLen: c.getWorklist().length,
stillPlaced: !!(c.getAssignment(key) || {}).trackingNodeId,
stillNamed: c.deriveTarget(f).filename,
};
});
expect(r.hasRow).toBe(true);
expect(r.latestShown).toBe(true);
expect(r.placedAfterDrop).toBe(true);
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf'); // tracking + row revision + title
expect(r.listLen).toBe(0); // list cleared
expect(r.stillPlaced).toBe(true); // classification survives the clear
expect(r.stillNamed).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf');
});
test('From a list: editing the tracking number (bump sequence) re-stamps placed files', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const f = { originalFilename: 'plan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-DWG-0007', title: 'Plan', revisionCell: 'A (IFR)' }]);
c.assignFromRow([key], c.getWorklistRow('m1'));
const before = c.deriveTarget(f).filename;
c.setRowTracking('m1', 'ACM-PRJ-EL-DWG-0008'); // it's the next drawing
const after = c.deriveTarget(f).filename;
// the old leaf chain should be pruned (no stray 0007 folder)
const roots = c.getTrackingTree();
const hasStale0007 = JSON.stringify(roots).indexOf('0007') !== -1;
return { before, after, hasStale0007 };
});
expect(r.before).toBe('ACM-PRJ-EL-DWG-0007_A (IFR) - Plan.pdf');
expect(r.after).toBe('ACM-PRJ-EL-DWG-0008_A (IFR) - Plan.pdf'); // file moved with the bump
expect(r.hasStale0007).toBe(false); // old leaf pruned
});
test('From a list: load() migrates a legacy mdlNodeId placement into a tracking placement', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const f = { originalFilename: 'old', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
// A pre-"From a list" serialized workspace: the file points at an mdl row.
c.load({
assignments: { [key]: { mdlNodeId: 'old1', titleFromDeliverable: true, transmittalNodeId: null, excluded: false, titleOverride: null } },
worklist: [{ id: 'old1', trackingNumber: 'ACM-PRJ-EL-SPC-0009', title: 'Legacy', revisionCell: 'B (IFC)' }],
});
const a = c.getAssignment(key) || {};
return {
noMdlNodeId: !('mdlNodeId' in a),
hasTracking: !!a.trackingNodeId,
named: c.deriveTarget(f).filename,
};
});
expect(r.noMdlNodeId).toBe(true); // dead field dropped
expect(r.hasTracking).toBe(true); // materialized into the tracking tree
expect(r.named).toBe('ACM-PRJ-EL-SPC-0009_B (IFC) - Legacy.pdf'); // classification preserved
});
test('parsePastedRows: fixed columns tracking · rev · title · current name', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const text = [
'Tracking Number\tRev\tTitle\tCurrent name', // header → skipped
'ACM-PRJ-EL-SPC-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf', // full 4 columns
'ACM-PRJ-EL-SPC-0002\tB (IFC)\tSection', // 3 cols → current name blank
'\tjust a rev\t', // no tracking → skipped
].join('\n');
return c.parsePastedRows(text);
});
expect(r.rows.map(x => x.trackingNumber)).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-EL-SPC-0002']);
expect(r.rows[0]).toMatchObject({ revisionCell: 'A (IFR)', title: 'Floor plan', currentName: 'IMG_4471.pdf' });
expect(r.rows[1].currentName).toBe(''); // omitted trailing column
expect(r.skipped.length).toBe(1); // the no-tracking row
});
test('proposeMatches: the current-name column drives exact (auto) + token matches', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'IMG_4471', extension: 'pdf', folderPath: 'R' }, // exact (case+ext+sep differ)
{ originalFilename: 'site-survey-final-v2', extension: 'docx', folderPath: 'R' }, // token coverage
{ originalFilename: 'totally unrelated', extension: 'pdf', folderPath: 'R' }, // no match
];
const rows = [
{ id: 'm1', trackingNumber: 'ACM-AR-DWG-0001', currentName: 'img_4471.PDF' },
{ id: 'm2', trackingNumber: 'ACM-AR-DWG-0002', currentName: 'Site Survey final' },
];
const m = c.proposeMatches(files, rows, {});
return Object.fromEntries(m.map(p => [p.file.originalFilename, { tn: p.row.trackingNumber, conf: p.confidence, via: p.via, auto: p.auto }]));
});
expect(r['IMG_4471']).toMatchObject({ tn: 'ACM-AR-DWG-0001', conf: 1, via: 'name', auto: true }); // exact 1:1 → auto
expect(r['site-survey-final-v2'].tn).toBe('ACM-AR-DWG-0002');
expect(r['site-survey-final-v2'].via).toBe('name');
expect(r['site-survey-final-v2'].auto).toBe(false); // < exact → needs review
expect(r['totally unrelated']).toBeUndefined(); // no match dropped
});
test('proposeMatches: ambiguous duplicate current-name is not auto-assigned', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'scan001', extension: 'pdf', folderPath: 'Root/A' },
{ originalFilename: 'scan001', extension: 'pdf', folderPath: 'Root/B' }, // same name, different folder
];
const rows = [{ id: 'm1', trackingNumber: 'ACM-AR-DWG-0009', currentName: 'scan001.pdf' }];
return c.proposeMatches(files, rows, {}).map(p => ({ conf: p.confidence, auto: p.auto }));
});
expect(r.length).toBe(2); // both files match the one row
expect(r.every(p => p.conf === 1)).toBe(true);
expect(r.every(p => p.auto === false)).toBe(true); // a row claimed by 2 files → neither auto-assigns
});
test('proposeMatches finds a row whose tracking number is in the filename', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'ACM-PRJ-EL-SPC-0001 rev A', extension: 'pdf' }, // exact substring
{ originalFilename: 'random scan', extension: 'pdf' }, // no match
{ originalFilename: 'doc ACMPRJELSPC0002 final', extension: 'pdf' }, // normalized
];
const rows = [
{ trackingNumber: 'ACM-PRJ-EL-SPC-0001' },
{ trackingNumber: 'ACM-PRJ-EL-SPC-0002' },
];
const m = c.proposeMatches(files, rows, {});
return m.map(p => ({ file: p.file.originalFilename, tn: p.row.trackingNumber, conf: p.confidence }));
});
expect(r.length).toBe(2); // the no-match file is dropped
expect(r[0]).toEqual({ file: 'ACM-PRJ-EL-SPC-0001 rev A', tn: 'ACM-PRJ-EL-SPC-0001', conf: 1 });
expect(r[1].tn).toBe('ACM-PRJ-EL-SPC-0002'); // matched via normalization
expect(r[1].conf).toBeCloseTo(0.8);
});
test('From a list: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(async () => {
const tt = window.app.modules.targetTree;
function fdir(name, children) {
return {
name, kind: 'directory',
async *values() { for (const ch of children) yield ch; },
async getDirectoryHandle(n) { const c = children.find(x => x.name === n && x.kind === 'directory'); if (!c) throw new Error('nf'); return c; },
};
}
function ffile(name, text) { return { name, kind: 'file', async getFile() { return { text: async () => text }; } }; }
// archive/PartyA/{mdl/<tn>.yaml, issued/T1/<A,B revs>}, archive/PartyB/issued/T2/<~A draft>
const root = fdir('archive', [
fdir('PartyA', [
fdir('mdl', [ffile('ACM-PRJ-EL-SPC-0001.yaml', 'title: Switchgear Spec\n')]),
fdir('issued', [fdir('T1', [
ffile('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf', ''),
ffile('ACM-PRJ-EL-SPC-0001_B (IFC) - Spec.pdf', ''),
ffile('notes.txt', ''), // non-ZDDC → ignored
])]),
]),
fdir('PartyB', [fdir('issued', [fdir('T2', [
ffile('ACM-PRJ-EL-SPC-0001_~A (IFA) - Draft.pdf', ''), // older draft, other party
])])]),
fdir('_system', [fdir('issued', [ffile('ACM-PRJ-EL-SPC-9999_A (IFA) - hidden.pdf', '')])]), // skipped
]);
const byTn = Object.create(null);
const ensure = (tn) => byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) });
await tt._walkDirInto(root, ensure);
const keys = Object.keys(byTn);
const x = byTn['ACM-PRJ-EL-SPC-0001'];
return {
trackingNumbers: keys,
inMdl: !!(x && x.inMdl),
title: x && x.title,
revs: x && Object.keys(x.revs).sort(),
latest: tt._latestRevOf(x && Object.keys(x.revs)),
latestDraftLoses: tt._latestRevOf(['~A (IFR)', 'A (IFC)']),
latestModifierWins: tt._latestRevOf(['A (IFR)', 'A+B1 (IFC)']),
};
});
expect(r.trackingNumbers).toEqual(['ACM-PRJ-EL-SPC-0001']); // one row; _system skipped, .txt ignored
expect(r.inMdl).toBe(true); // the mdl/*.yaml registered it
expect(r.title).toBe('Switchgear Spec'); // title from the deliverable yaml
expect(r.revs).toEqual(['A (IFR)', 'B (IFC)', '~A (IFA)']); // revisions unioned across parties
expect(r.latest).toBe('B (IFC)'); // B > A > ~A
expect(r.latestDraftLoses).toBe('A (IFC)'); // ~A < A
expect(r.latestModifierWins).toBe('A+B1 (IFC)'); // A < A+B1
});
test('From a list: _detectScope routes by URL/protocol', async ({ page }) => {
const r = await page.evaluate(() => {
const tt = window.app.modules.targetTree;
return {
apps: tt._detectScope('/_apps/classifier.html', true, 'https:'),
project: tt._detectScope('/Project-1/archive/PartyA/incoming/', true, 'https:'),
offline: tt._detectScope('/anything', false, 'file:'),
offlineHttp: tt._detectScope('/x', true, 'file:'),
};
});
expect(r.apps).toBe('all');
expect(r.project).toEqual({ one: 'Project-1' });
expect(r.offline).toBe('local');
expect(r.offlineHttp).toBe('local');
});
test('From a list: dir-picker resolves the topmost ticked directories only', async ({ page }) => {
const r = await page.evaluate(() => {
const dp = window.app.modules.dirPicker;
const childOfA = { handle: 'A/x', checked: true, children: [] };
const A = { handle: 'A', checked: true, children: [childOfA] };
const grand = { handle: 'B/y/z', checked: false, children: [] };
const childOfB = { handle: 'B/y', checked: true, children: [grand] };
const B = { handle: 'B', checked: false, children: [childOfB] };
const unchecked = { handle: 'C', checked: false, children: [] };
return dp._collect([A, B, unchecked]);
});
// A is ticked (its ticked child A/x is dropped — covered by A); B itself isn't
// ticked but its child B/y is, so B/y is included; C contributes nothing.
expect(r).toEqual(['A', 'B/y']);
});