/**
 * 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) parses a name into nested levels', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
    await page.click('#addTrackingRootBtn');
    // "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels).
    await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible();
    await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible();
    await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible();
});

// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────

test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        const c = window.app.modules.classify;
        const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
        window.app.modules.targetTree.render();
        const row = document.querySelector('#trackingTree .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.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'),
        };
    });
    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']);
});

test('add-folder builds a nested chain sharing common ancestors', async ({ page }) => {
    const r = await page.evaluate(() => {
        const c = window.app.modules.classify;
        c.reset();
        // Brace-expand then nest: two leaves under shared CPO/000x ancestors.
        c.expandFolderPattern('CPO-{0001,0002}_0 (IFU)').forEach((nm) =>
            c.addTrackingPath(null, c.parseFolderLevels(nm)));
        return JSON.parse(JSON.stringify(c.getTrackingTree(), (k, v) => (k === 'id' ? undefined : v)));
    });
    expect(r.length).toBe(1);                                   // one shared CPO root
    expect(r[0].name).toBe('CPO');
    expect(r[0].children.map((n) => n.name)).toEqual(['0001', '0002']);
    expect(r[0].children[0].children[0].name).toBe('0 (IFU)');  // leaf rev under each number
});

test('trackingNodeComplete: true only for a leaf with a valid status', async ({ page }) => {
    const r = await page.evaluate(() => {
        const c = window.app.modules.classify;
        c.reset();
        const parent = c.addTrackingNode(null, 'CPO');
        const num = c.addTrackingNode(parent, '0001');
        const leaf = c.addTrackingNode(num, '0 (IFU)');
        const bare = c.addTrackingNode(c.addTrackingNode(null, 'X'), '0001'); // leaf, no status
        return {
            root: c.trackingNodeComplete(parent), // has children
            num: c.trackingNodeComplete(num),      // has a child leaf
            leaf: c.trackingNodeComplete(leaf),    // leaf + valid status
            bare: c.trackingNodeComplete(bare),    // leaf, no "(STATUS)"
        };
    });
    expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
});

test('editing a placed file’s filename re-files it onto the parsed tracking path', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        const c = window.app.modules.classify;
        c.reset();
        const leaf = c.addTrackingNode(c.addTrackingNode(null, 'OLD'), 'A (IFR)');
        const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
        const key = c.srcKeyForFile(file);
        c.place([key], leaf, 'tracking');
        window.app.folderTree = [{
            name: 'Sub', path: 'Sub', expanded: true, scanState: 'done', children: [], files: [file],
        }];
        window.app.modules.targetTree.render();
        const input = document.querySelector('#trackingTree .tfile__name');
        input.value = 'CPO-0002_0 (IFU) - New Title.pdf';
        input.dispatchEvent(new Event('change', { bubbles: true }));
        const d = c.deriveTarget(file);
        return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete };
    });
    expect(r.tracking).toBe('CPO-0002');
    expect(r.revision).toBe('0');
    expect(r.status).toBe('IFU');
    expect(r.title).toBe('New Title');
});

test('dataset (filename-based): import reconstruction rebuilds tracking + shared transmittals', async ({ page }) => {
    const r = await page.evaluate(() => {
        const c = window.app.modules.classify;
        const z = window.zddc;
        c.reset();
        // Mirrors app.importDataset's per-record reconstruction: two docs sharing
        // one transmittal package, plus an excluded junk file.
        const recs = [
            { source: 'a.pdf', filename: 'CPO-0001_0 (IFU) - Doc A.pdf', excluded: false,
              transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } },
            { source: 'b.pdf', filename: 'CPO-0002_0 (IFU) - Doc B.pdf', excluded: false,
              transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } },
            { source: 'junk.tmp', filename: '', excluded: true },
        ];
        recs.forEach((rec) => {
            if (rec.excluded) { c.setExcluded([rec.source], true); return; }
            const p = z.parseFilename(rec.filename);
            c.place([rec.source], c.addTrackingPath(null, c.parseFolderLevels(p.trackingNumber + '_' + p.revision + ' (' + p.status + ')')), 'tracking');
            c.setTitleOverride(rec.source, p.title);
            const t = rec.transmittal;
            const bid = c.findOrAddTransmittalBin(c.findOrAddParty(t.party), t.slot, t);
            c.place([rec.source], bid, 'transmittal');
        });
        const da = c.deriveTarget({ folderPath: '', originalFilename: 'a', extension: 'pdf' }); // key 'a.pdf'
        const tree = c.getTransmittalTree();
        return {
            tracking: da.tracking, rev: da.revision, status: da.status, title: da.title,
            parties: tree.length,
            bins: tree[0] ? tree[0].children.filter((s) => s.slot === 'received')[0].children.length : -1,
            excluded: c.getAssignment('junk.tmp').excluded,
        };
    });
    expect(r.tracking).toBe('CPO-0001');
    expect(r.rev).toBe('0');
    expect(r.status).toBe('IFU');
    expect(r.title).toBe('Doc A');
    expect(r.parties).toBe(1);   // one Acme party
    expect(r.bins).toBe(1);      // shared transmittal → single bin (dedup)
    expect(r.excluded).toBe(true);
});

test('source-tree filter reveals matches with their folder hierarchy', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        window.app.folderTree = [{
            name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
                { name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [
                    { originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' },
                    { originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
                ] },
                { name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
                    { originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' },
                ] },
            ],
        }];
        window.app.modules.tree.render();
        window.app.modules.tree.setNameFilter('master deliverables');
        return {
            files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
            folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
        };
    });
    expect(r.files).toEqual(['Master Deliverables List.xlsx']);     // only the match shown
    expect(r.folders).toEqual(['Project', 'Project/Electrical']);   // path revealed; Civil hidden
});

test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const names = await page.evaluate(() => {
        const c = window.app.modules.classify;
        c.reset();
        c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
        c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
        window.app.modules.targetTree.render();
        window.app.modules.targetTree.setNameFilter('CPO');
        return Array.from(document.querySelectorAll('#trackingTree .tnode__name')).map((e) => e.textContent);
    });
    expect(names).toContain('CPO');
    expect(names).toContain('0001');
    expect(names).not.toContain('XYZ');
});

test('Show Empty off hides folders that contain no files', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        window.app.folderTree = [
            { name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [],
              files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }] },
            { name: 'EmptyDir', path: 'EmptyDir', expanded: true, scanState: 'done', children: [], files: [] },
        ];
        window.app.modules.tree.render();
        const before = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort();
        window.app.modules.tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
        const after = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort();
        return { before, after };
    });
    expect(r.before).toEqual(['Docs', 'EmptyDir']);   // both shown by default
    expect(r.after).toEqual(['Docs']);                // empty folder hidden when Show Empty off
});

test('toggling a Show filter preserves collapse state (no force-expand)', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        window.app.folderTree = [{
            name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
                { name: 'Sub', path: 'Project/Sub', expanded: false, scanState: 'done', children: [],
                  files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Project/Sub' }] },
            ],
        }];
        const tree = window.app.modules.tree;
        tree.render();
        const collapsed = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
        // A Show toggle must not expand the collapsed parent…
        tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
        const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
        // …whereas a name search still reveals the match by auto-expanding.
        tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true });
        tree.setNameFilter('a.pdf');
        const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
        return { collapsed, afterToggle, afterSearch };
    });
    expect(r.collapsed).toEqual(['Project']);                 // child hidden — parent collapsed
    expect(r.afterToggle).toEqual(['Project']);               // Show toggle leaves it collapsed
    expect(r.afterSearch).toEqual(['Project', 'Project/Sub']); // name search auto-expands to the match
});

test('search opens only the branch with a hit, leaving siblings collapsed', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        window.app.folderTree = [{
            name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
                { name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [
                    { originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
                ] },
                { name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
                    { originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' },
                ] },
            ],
        }];
        const tree = window.app.modules.tree;
        tree.render();
        tree.setNameFilter('switchgear');   // a file deep in the Electrical branch
        return {
            folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
            files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
        };
    });
    // Path to the hit opens; the unrelated Civil sibling is not force-opened (stays out).
    expect(r.folders).toEqual(['Project', 'Project/Electrical']);
    expect(r.files).toEqual(['Switchgear Spec.pdf']);
});

test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => {
    const r = await page.evaluate(() => {
        const sc = window.app.modules.scanner;
        window.app.folderTree = [{
            name: 'Root', path: 'Root', scanState: 'done', files: [], children: [{
                name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, zipPath: 'Root/docs.zip',
                scanState: 'done', children: [], files: [{
                    originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip',
                    isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf',
                }],
            }],
        }];
        const json = JSON.stringify(sc.snapshotTree());
        window.app.folderTree = [];
        sc.loadSnapshot(JSON.parse(json));
        const zip = window.app.folderTree[0].children[0];
        const m = zip.files[0];
        return {
            isZipRoot: zip.isZipRoot, zipPath: zip.zipPath, done: zip.scanState === 'done',
            virtual: m.isVirtual, mZip: m.zipPath, entry: m.zipEntryPath, handleNull: m.handle === null,
        };
    });
    expect(r.isZipRoot).toBe(true);                // archive preserved as an expandable folder
    expect(r.zipPath).toBe('Root/docs.zip');
    expect(r.done).toBe(true);
    expect(r.virtual).toBe(true);                  // member flagged virtual…
    expect(r.mZip).toBe('Root/docs.zip');          // …with enough to re-extract
    expect(r.entry).toBe('spec.pdf');
    expect(r.handleNull).toBe(true);
});

test('copy: a zip member is extracted from its archive and written out', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const res = await page.evaluate(async () => {
        const c = window.app.modules.classify, copy = window.app.modules.copy;
        const f = {
            originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip',
            isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf', handle: null,
        };
        window.app.folderTree = [{ name: 'Root', path: 'Root', files: [], children: [
            { name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, files: [f], children: [] },
        ] }];
        // Stub archive extraction — return the member's bytes as a Blob.
        window.app.rootHandle = {};
        window.app.modules.scanner.extractZipMember = async () => new File(['ZIPBYTES'], 'spec.pdf');

        const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
        const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
        c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal');

        const outStore = {};
        const mkOut = (prefix) => ({
            name: prefix || 'out',
            getDirectoryHandle: async (n) => mkOut((prefix ? prefix + '/' : '') + n),
            getFileHandle: async (n, opts) => {
                const full = (prefix ? prefix + '/' : '') + n;
                if (!opts || !opts.create) { if (!(full in outStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
                return {
                    getFile: async () => new File([outStore[full] != null ? outStore[full] : ''], n),
                    createWritable: async () => ({ write: async (d) => { outStore[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
                };
            },
        });
        const s = await copy.copyTo(mkOut(''), copy.plan());
        return { copied: s.copied, content: Object.values(outStore)[0], wrote: Object.keys(outStore).some((k) => k.endsWith('spec.pdf')) };
    });
    expect(res.copied).toBe(1);
    expect(res.wrote).toBe(true);
    expect(res.content).toBe('ZIPBYTES');
});

test('workspace: import recreates a transferable record (snapshot + map, no handle)', async ({ page }) => {
    const r = await page.evaluate(async () => {
        const ws = window.app.modules.workspace, P = window.app.modules.persist;
        const json = JSON.stringify({
            zddcWorkspace: 1,
            meta: { name: 'Transferred', rootName: 'BigProject', summary: { files: 3, done: 1, excluded: 0 } },
            tree: [{ n: 'BigProject', p: 'BigProject', f: [{ o: 'a', e: 'pdf', p: 'BigProject' }] }],
            classify: {
                assignments: { 'a.pdf': { trackingNodeId: null, transmittalNodeId: null, excluded: true, titleOverride: null } },
                trackingTree: [], transmittalTree: [],
            },
        });
        const file = new File([json], 'x.zddc-workspace.json', { type: 'application/json' });
        const id = await ws.importWorkspace(file);
        const rec = id && await P.getWorkspace(id);
        const rows = await P.listWorkspaces();
        return {
            listed: rows.some((x) => x.id === id && x.name === 'Transferred' && x.rootName === 'BigProject'),
            hasTree: !!(rec && rec.tree && rec.tree.length),
            excluded: !!(rec && rec.classify && rec.classify.assignments['a.pdf'] && rec.classify.assignments['a.pdf'].excluded),
            noHandle: rec ? (rec.rootHandle == null) : false,
        };
    });
    expect(r.listed).toBe(true);     // appears in the welcome list
    expect(r.hasTree).toBe(true);    // the expensive scan came across
    expect(r.excluded).toBe(true);   // classifications came across
    expect(r.noHandle).toBe(true);   // source handle intentionally absent (re-attach on this browser)
});

test('zip mode: collapse turns an expanded archive back into one .zip file', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        const c = window.app.modules.classify, sc = window.app.modules.scanner;
        const member = { originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf' };
        const zipNode = { name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, zipPath: 'Root/docs.zip', files: [member], children: [] };
        const root = { name: 'Root', path: 'Root', files: [], children: [zipNode] };
        zipNode.parent = root;
        window.app.folderTree = [root];
        c.setExcluded([c.srcKeyForFile(member)], true);
        const hadAssign = !!(c.getAssignment('docs.zip/spec.pdf') || {}).excluded;
        sc.collapseZipToFile(zipNode);
        const file = root.files[0];
        return {
            hadAssign,
            noChild: root.children.length === 0,
            fileName: file && (file.originalFilename + '.' + file.extension),
            droppedMemberAssign: !c.getAssignment('docs.zip/spec.pdf'),
        };
    });
    expect(r.hadAssign).toBe(true);          // member had an assignment
    expect(r.noChild).toBe(true);            // archive folder removed
    expect(r.fileName).toBe('docs.zip');     // single .zip file restored
    expect(r.droppedMemberAssign).toBe(true); // member assignment cleared
});

test('a fully-excluded folder is struck through like its files', async ({ page }) => {
    await page.click('#modeClassifyBtn');
    const r = await page.evaluate(() => {
        const c = window.app.modules.classify, tree = window.app.modules.tree;
        const f1 = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' };
        const f2 = { originalFilename: 'b', extension: 'pdf', folderPath: 'Docs' };
        window.app.folderTree = [{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], files: [f1, f2] }];
        tree.render();
        const sel = '#folderTree .folder-item[data-path="Docs"]';
        const before = document.querySelector(sel).classList.contains('excluded');
        c.setExcluded([c.srcKeyForFile(f1), c.srcKeyForFile(f2)], true);
        tree.render();
        const after = document.querySelector(sel).classList.contains('excluded');
        return { before, after };
    });
    expect(r.before).toBe(false);   // not struck through while active
    expect(r.after).toBe(true);     // struck through once the whole subtree is excluded
});
