Classify & Copy polish — in either target tab the goal is to assign or exclude
every left-pane file until nothing remains:
- Hide Assigned checkbox (classify mode, in the folder-tree pane header):
collapses the source tree to only what's left on the ACTIVE axis — hides
files already assigned in the current tab (or excluded) and any folder whose
scanned subtree is thereby empty. Re-renders on tab switch; target-tree
exposes activeAxis().
- Node add/edit/delete controls moved to the LEFT of the level name and made
always-visible (was right-aligned + hover-only), so building/pruning the
tracking and transmittal trees is one click.
- Brace expansion in the add-folder box: "BMB-187023-{PM,EL,EM}-MOM-
{0001-0002,0005}_A (IFR)" creates all 9 folders — {a,b} alternation +
{N-M} zero-padded numeric ranges, cartesian product across groups; a
multi-create is confirmed first. New classify.expandFolderPattern().
Tests: expandFolderPattern unit cases + a Hide-Assigned DOM test
(classify.spec.js → 29 passed; classifier.spec.js → 4 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
585 lines
30 KiB
JavaScript
585 lines
30 KiB
JavaScript
/**
|
|
* Tests for the classifier "Classify & Copy" state model
|
|
* (classifier/js/classify.js) — the pure derive/assignment logic.
|
|
*
|
|
* Runs against the compiled classifier/dist/classifier.html, driving
|
|
* window.app.modules.classify via page.evaluate. No File System Access API is
|
|
* needed: synthetic file objects ({folderPath, originalFilename, extension})
|
|
* carry everything deriveTarget consults. Drag-and-drop and the actual copy
|
|
* stay manual (Playwright can't drive the directory picker).
|
|
*
|
|
* Build first: sh classifier/build.sh
|
|
*/
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
import * as path from 'path';
|
|
|
|
const PAGE = 'file://' + path.resolve('classifier/dist/classifier.html');
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto(PAGE, { waitUntil: 'load' });
|
|
const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify));
|
|
expect(ok).toBe(true);
|
|
await page.evaluate(() => {
|
|
window.app.modules.classify.reset();
|
|
// No directory is opened in these tests; dismiss the welcome overlay so
|
|
// it doesn't intercept clicks on the in-pane controls.
|
|
const w = document.getElementById('welcomeScreen');
|
|
if (w) w.classList.add('hidden');
|
|
});
|
|
});
|
|
|
|
// Build a tracking chain of folders and place one file in the deepest;
|
|
// return the derived target.
|
|
async function deriveInTracking(page, segments, file) {
|
|
return page.evaluate(({ segments, file }) => {
|
|
const c = window.app.modules.classify;
|
|
let parent = null;
|
|
for (const name of segments) parent = c.addTrackingNode(parent, name);
|
|
const key = c.srcKeyForFile(file);
|
|
c.place([key], parent, 'tracking');
|
|
return c.deriveTarget(file);
|
|
}, { segments, file });
|
|
}
|
|
|
|
const FILE = { folderPath: 'Root/Sub', originalFilename: 'Foundation Plan', extension: 'pdf' };
|
|
|
|
test('tracking: ancestors join with "-", parent leaf = REV (STATUS), title from name', async ({ page }) => {
|
|
const d = await deriveInTracking(page, ['ACME-PROJ', 'MECH', '0001', 'A (IFR)'], FILE);
|
|
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
|
|
expect(d.revision).toBe('A');
|
|
expect(d.status).toBe('IFR');
|
|
expect(d.title).toBe('Foundation Plan');
|
|
expect(d.filename).toBe('ACME-PROJ-MECH-0001_A (IFR) - Foundation Plan.pdf');
|
|
expect(d.trackingLeaf).toBe(true);
|
|
});
|
|
|
|
test('tracking: a single full-number folder also works', async ({ page }) => {
|
|
const d = await deriveInTracking(page, ['ACME-PROJ-MECH-0001', 'B (IFC)'], FILE);
|
|
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
|
|
expect(d.revision).toBe('B');
|
|
expect(d.status).toBe('IFC');
|
|
expect(d.filename).toBe('ACME-PROJ-MECH-0001_B (IFC) - Foundation Plan.pdf');
|
|
});
|
|
|
|
test('tracking: parked in an intermediate (non-leaf) folder is flagged incomplete', async ({ page }) => {
|
|
const d = await page.evaluate((file) => {
|
|
const c = window.app.modules.classify;
|
|
const proj = c.addTrackingNode(null, 'ACME-PROJ');
|
|
const mech = c.addTrackingNode(proj, 'MECH');
|
|
c.addTrackingNode(mech, '0001'); // child exists → MECH is not a leaf
|
|
const key = c.srcKeyForFile(file);
|
|
c.place([key], mech, 'tracking'); // file parked at MECH
|
|
return c.deriveTarget(file);
|
|
}, FILE);
|
|
expect(d.trackingLeaf).toBe(false);
|
|
expect(d.errors.join(' ')).toContain('leaf');
|
|
expect(d.complete).toBe(false);
|
|
});
|
|
|
|
test('tracking: unknown status code is reported', async ({ page }) => {
|
|
const d = await deriveInTracking(page, ['ACME', 'Z (BOGUS)'], FILE);
|
|
expect(d.status).toBe('BOGUS');
|
|
expect(d.errors.join(' ')).toContain('unknown status');
|
|
expect(d.complete).toBe(false);
|
|
});
|
|
|
|
test('tracking: leaf with no "(STATUS)" parens is flagged', async ({ page }) => {
|
|
const d = await deriveInTracking(page, ['ACME', '0001'], FILE);
|
|
expect(d.status).toBe('');
|
|
expect(d.filename).toBe(''); // formatFilename needs a status
|
|
expect(d.errors.join(' ')).toContain('STATUS');
|
|
});
|
|
|
|
test('title: original ZDDC name reuses its title; titleOverride wins', async ({ page }) => {
|
|
const zddcFile = { folderPath: 'Root', originalFilename: 'X-1_A (IFR) - Real Title', extension: 'pdf' };
|
|
const def = await page.evaluate((f) => window.app.modules.classify.defaultTitle(f), zddcFile);
|
|
expect(def).toBe('Real Title');
|
|
|
|
const overridden = await page.evaluate((f) => {
|
|
const c = window.app.modules.classify;
|
|
const key = c.srcKeyForFile(f);
|
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
|
|
c.place([key], leaf, 'tracking');
|
|
c.setTitleOverride(key, 'Custom Title');
|
|
return c.deriveTarget(f);
|
|
}, FILE);
|
|
expect(overridden.title).toBe('Custom Title');
|
|
expect(overridden.filename).toContain(' - Custom Title.pdf');
|
|
});
|
|
|
|
test('transmittal: party/slot/bin → output path; full target composes', async ({ page }) => {
|
|
const d = await page.evaluate((file) => {
|
|
const c = window.app.modules.classify;
|
|
const key = c.srcKeyForFile(file);
|
|
// axis 1
|
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
|
c.place([key], leaf, 'tracking');
|
|
// axis 2
|
|
const party = c.addParty('ClientCorp');
|
|
const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
|
c.place([key], bin, 'transmittal');
|
|
return { d: c.deriveTarget(file), binName: c.getNode(bin).name };
|
|
}, FILE);
|
|
expect(d.binName).toBe('2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
|
|
expect(d.d.party).toBe('ClientCorp');
|
|
expect(d.d.slot).toBe('received');
|
|
expect(d.d.outPath).toBe('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
|
|
expect(d.d.complete).toBe(true);
|
|
});
|
|
|
|
test('exclude clears placements and reports excluded state', async ({ page }) => {
|
|
const r = await page.evaluate((file) => {
|
|
const c = window.app.modules.classify;
|
|
const key = c.srcKeyForFile(file);
|
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
|
|
c.place([key], leaf, 'tracking');
|
|
c.setExcluded([key], true);
|
|
return { state: c.fileState(file), d: c.deriveTarget(file) };
|
|
}, FILE);
|
|
expect(r.state).toBe('excluded');
|
|
expect(r.d.excluded).toBe(true);
|
|
});
|
|
|
|
// ── Phase 2: mode toggle + target-tree rendering (UI) ──────────────────────
|
|
|
|
test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => {
|
|
await page.click('#modeClassifyBtn');
|
|
expect(await page.locator('#targetPane').isHidden()).toBe(false);
|
|
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true);
|
|
await page.click('#modeRenameBtn');
|
|
expect(await page.locator('#targetPane').isHidden()).toBe(true);
|
|
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
|
|
});
|
|
|
|
test('target tree renders structure and tabs switch', async ({ page }) => {
|
|
await page.click('#modeClassifyBtn');
|
|
await page.evaluate(() => {
|
|
const c = window.app.modules.classify;
|
|
const acme = c.addTrackingNode(null, 'ACME-PROJ');
|
|
c.addTrackingNode(acme, 'A (IFR)');
|
|
const party = c.addParty('ClientCorp');
|
|
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
|
});
|
|
// Tracking panel visible by default with the nodes rendered.
|
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
|
await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible();
|
|
// Switch to transmittal tab.
|
|
await page.click('#transmittalTab');
|
|
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
|
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
|
|
});
|
|
|
|
test('"+ Root folder" button (prompt) adds a tracking node', async ({ page }) => {
|
|
await page.click('#modeClassifyBtn');
|
|
page.once('dialog', (d) => d.accept('ACME-PROJ'));
|
|
await page.click('#addTrackingRootBtn');
|
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
|
});
|
|
|
|
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
|
|
|
|
test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
|
|
await page.click('#modeClassifyBtn');
|
|
const r = await page.evaluate(() => {
|
|
const c = window.app.modules.classify;
|
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
|
window.app.modules.targetTree.render();
|
|
const row = document.querySelector('#trackingTree .tnode--leaf .tnode__row');
|
|
const key = 'Sub/foundation.pdf';
|
|
window.app.modules.dnd.setDrag([key]);
|
|
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
|
return { assigned: c.assignmentFor(key).trackingNodeId, leaf };
|
|
});
|
|
expect(r.assigned).toBe(r.leaf);
|
|
});
|
|
|
|
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
|
|
await page.click('#modeClassifyBtn');
|
|
await page.click('#transmittalTab');
|
|
const r = await page.evaluate(() => {
|
|
const c = window.app.modules.classify;
|
|
const party = c.addParty('ClientCorp');
|
|
const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
|
window.app.modules.targetTree.render();
|
|
const key = 'Sub/foundation.pdf';
|
|
|
|
// Drop on the bin → assigned.
|
|
const binRow = document.querySelector('#transmittalTree .tnode--bin .tnode__row');
|
|
window.app.modules.dnd.setDrag([key]);
|
|
binRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
|
const afterBin = c.assignmentFor(key).transmittalNodeId;
|
|
|
|
// Reset, then drop on the party row → ignored (only bins are targets).
|
|
c.place([key], null, 'transmittal');
|
|
const partyRow = document.querySelector('#transmittalTree .tnode--party > .tnode__row');
|
|
window.app.modules.dnd.setDrag([key]);
|
|
partyRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
|
const afterParty = c.assignmentFor(key).transmittalNodeId;
|
|
|
|
return { afterBin, bin, afterParty };
|
|
});
|
|
expect(r.afterBin).toBe(r.bin);
|
|
expect(r.afterParty).toBe(null);
|
|
});
|
|
|
|
// ── Phase 4: left-tree markers, exclude, cross-tree find ───────────────────
|
|
|
|
// Inject a synthetic scanned tree (no FS Access needed) and render it.
|
|
async function withSourceTree(page) {
|
|
await page.click('#modeClassifyBtn');
|
|
await page.evaluate(() => {
|
|
window.app.folderTree = [{
|
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
|
files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }],
|
|
children: [], fileCount: 1, subdirCount: 0, runFiles: 1, runDirs: 0,
|
|
}];
|
|
window.app.modules.tree.render();
|
|
});
|
|
}
|
|
|
|
test('source file rows render with a state dot in classify mode', async ({ page }) => {
|
|
await withSourceTree(page);
|
|
await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'Foundation Plan.pdf' })).toBeVisible();
|
|
await expect(page.locator('#folderTree .file-item .cl-dot--none')).toBeVisible();
|
|
});
|
|
|
|
test('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, skips an identical re-copy, flags a differing target', async ({ page }) => {
|
|
await page.click('#modeClassifyBtn');
|
|
const res = await page.evaluate(async () => {
|
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
|
const store = {};
|
|
const fileHandleFor = (full) => ({
|
|
getFile: async () => new File([store[full] != null ? store[full] : ''], full.split('/').pop()),
|
|
createWritable: async () => ({ write: async (d) => { store[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
|
|
});
|
|
const mockDir = (prefix) => ({
|
|
name: prefix || 'out',
|
|
getDirectoryHandle: async (name) => mockDir((prefix ? prefix + '/' : '') + name),
|
|
getFileHandle: async (name, opts) => {
|
|
const full = (prefix ? prefix + '/' : '') + name;
|
|
if (!opts || !opts.create) { if (!(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
|
|
return fileHandleFor(full);
|
|
},
|
|
});
|
|
const srcFile = (name, content) => {
|
|
const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.');
|
|
return { originalFilename: stem, extension: ext, folderPath: 'Root', handle: { getFile: async () => new File([content], name) } };
|
|
};
|
|
const f = srcFile('foundation.pdf', 'AAA');
|
|
window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [f], children: [], runFiles: 1 }];
|
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
|
const key = c.srcKeyForFile(f);
|
|
c.place([key], leaf, 'tracking'); c.place([key], bin, 'transmittal');
|
|
|
|
const out = mockDir('');
|
|
const first = await copy.copyTo(out, copy.plan());
|
|
const second = await copy.copyTo(out, copy.plan()); // identical → skipped
|
|
const tkey = Object.keys(store)[0];
|
|
store[tkey] = 'DIFFERENT'; // tamper target
|
|
const third = await copy.copyTo(out, copy.plan()); // differs → left alone
|
|
return { firstCopied: first.copied, secondSkipped: second.skipped, thirdDiffer: third.differ, keys: Object.keys(store) };
|
|
});
|
|
expect(res.firstCopied).toBe(1);
|
|
expect(res.secondSkipped).toBe(1);
|
|
expect(res.thirdDiffer).toBe(1);
|
|
expect(res.keys.some((k) => k.endsWith('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal/ACME-MECH-0001_A (IFR) - foundation.pdf'))).toBe(true);
|
|
});
|
|
|
|
test('copy: two sources mapping to the same output path are a conflict', async ({ page }) => {
|
|
await page.click('#modeClassifyBtn');
|
|
const conflicts = await page.evaluate(() => {
|
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
|
const srcFile = (name, folder) => {
|
|
const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.');
|
|
return { originalFilename: stem, extension: ext, folderPath: folder, handle: { getFile: async () => new File(['x'], name) } };
|
|
};
|
|
const f1 = srcFile('plan.pdf', 'Root/a');
|
|
const f2 = srcFile('plan.pdf', 'Root/b'); // same name, different folder → same derived output
|
|
window.app.folderTree = [{
|
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [],
|
|
children: [
|
|
{ name: 'a', path: 'Root/a', files: [f1], children: [] },
|
|
{ name: 'b', path: 'Root/b', files: [f2], children: [] },
|
|
],
|
|
}];
|
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
|
c.place([c.srcKeyForFile(f1)], leaf, 'tracking'); c.place([c.srcKeyForFile(f1)], bin, 'transmittal');
|
|
c.place([c.srcKeyForFile(f2)], leaf, 'tracking'); c.place([c.srcKeyForFile(f2)], bin, 'transmittal');
|
|
return copy.conflictsIn(copy.plan()).conflicts.length;
|
|
});
|
|
expect(conflicts).toBe(1);
|
|
});
|
|
|
|
// ── 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.setHideAssigned(true);
|
|
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']);
|
|
});
|