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