diff --git a/classifier/build.sh b/classifier/build.sh index 27bf0aa..0d1120b 100755 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -51,6 +51,8 @@ concat_files \ "js/utils.js" \ "../shared/zddc-filter.js" \ "js/store.js" \ + "js/persist.js" \ + "js/classify.js" \ "js/validator.js" \ "js/scanner.js" \ "js/tree.js" \ diff --git a/classifier/js/classify.js b/classifier/js/classify.js new file mode 100644 index 0000000..fca8ee6 --- /dev/null +++ b/classifier/js/classify.js @@ -0,0 +1,431 @@ +/** + * ZDDC Classifier — "Classify & Copy" state model. + * + * The non-destructive workflow: the source directory is read-only; the user + * maps each source file onto two orthogonal target trees, and a later copy + * step writes renamed copies into a separate output directory. + * + * - Tracking tab (→ filename), POSITIONAL: + * tracking number = the file's ancestor folder names joined with '-' + * revision (+status) = its immediate parent folder, named "REV (STATUS)" + * title = derived from the original filename + * → TRACKING_REV (STATUS) - TITLE.ext + * - Transmittal tab (→ output path): + * /{issued,received}// + * + * This module is the single source of truth: placements live in `assignments` + * keyed by source-relative path (so they survive a re-pick); the trees define + * structure only. All target values are DERIVED, never stored. + */ +(function () { + 'use strict'; + + // ── unique ids ─────────────────────────────────────────────────────────── + var _idSeq = 0; + function uid() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ } + } + return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36); + } + + // ── state ──────────────────────────────────────────────────────────────── + var state = { + enabled: false, // classify mode on/off + assignments: {}, // srcKey -> { trackingNodeId, transmittalNodeId, excluded, titleOverride } + trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children) + transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ] + outputName: null, // remembered output directory display name + }; + + // id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent } + var nodeIndex = {}; + + // ── pub/sub ────────────────────────────────────────────────────────────── + var listeners = []; + function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; } + var notifyScheduled = false; + function notify() { + // Coalesce bursts (a group-drop touches many keys) into one render. + if (notifyScheduled) return; + notifyScheduled = true; + Promise.resolve().then(function () { + notifyScheduled = false; + for (var i = 0; i < listeners.length; i++) { + try { listeners[i](); } catch (e) { console.error('classify listener', e); } + } + scheduleSave(); + }); + } + + // ── persistence hook (debounced) ───────────────────────────────────────── + var saveTimer = null; + function scheduleSave() { + if (!window.app.modules.persist) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function () { + saveTimer = null; + try { window.app.modules.persist.saveState(serialize()); } catch (e) { console.warn('persist', e); } + }, 400); + } + + // ── source keys + title derivation ─────────────────────────────────────── + function stripRoot(p) { + var i = (p || '').indexOf('/'); + return i < 0 ? '' : p.slice(i + 1); + } + // Stable key for a file: its path relative to the picked root (root segment + // dropped), so re-picking the same directory re-attaches the same map. + function srcKeyForFile(file) { + var rel = stripRoot(file.folderPath || ''); + var fn = zddc.joinExtension(file.originalFilename, file.extension); + return rel ? rel + '/' + fn : fn; + } + // Default title: if the original name already parses as ZDDC, reuse its + // title; otherwise the cleaned stem (originalFilename is the stem already). + function defaultTitle(file) { + var full = zddc.joinExtension(file.originalFilename, file.extension); + var parsed = zddc.parseFilename(full); + if (parsed && parsed.valid && parsed.title) return parsed.title; + return (file.originalFilename || '').trim(); + } + + // Parse a leaf folder label "A (IFR)" → { revision, status }. No parens → + // the whole label is the revision and status is blank. + var LEAF_RE = /^(.*?)\s*\(([^)]+)\)\s*$/; + function parseLeafLabel(name) { + var m = (name || '').match(LEAF_RE); + if (m) return { revision: m[1].trim(), status: m[2].trim() }; + return { revision: (name || '').trim(), status: '' }; + } + + // ── assignments ────────────────────────────────────────────────────────── + function assignmentFor(key) { + var a = state.assignments[key]; + if (!a) { + a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null }; + state.assignments[key] = a; + } + return a; + } + function cleanAssignment(key) { + var a = state.assignments[key]; + if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) { + delete state.assignments[key]; + } + } + + // Place keys onto a node along one axis ('tracking' | 'transmittal'). + // nodeId null clears that axis. + function place(keys, nodeId, axis) { + var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; + keys.forEach(function (k) { + var a = assignmentFor(k); + a[field] = nodeId || null; + a.excluded = false; // placing un-excludes + cleanAssignment(k); + }); + notify(); + } + function setExcluded(keys, excluded) { + keys.forEach(function (k) { + var a = assignmentFor(k); + a.excluded = !!excluded; + if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; } + cleanAssignment(k); + }); + notify(); + } + function setTitleOverride(key, title) { + var a = assignmentFor(key); + a.titleOverride = title && title.trim() ? title.trim() : null; + cleanAssignment(key); + notify(); + } + + // ── node index ─────────────────────────────────────────────────────────── + function rebuildIndex() { + nodeIndex = {}; + (function walkTracking(nodes, parent) { + (nodes || []).forEach(function (n) { + nodeIndex[n.id] = { node: n, kind: 'tracking', parent: parent }; + walkTracking(n.children, n); + }); + })(state.trackingTree, null); + (state.transmittalTree || []).forEach(function (party) { + nodeIndex[party.id] = { node: party, kind: 'party', parent: null }; + (party.children || []).forEach(function (slot) { + nodeIndex[slot.id] = { node: slot, kind: 'slot', parent: party }; + (slot.children || []).forEach(function (bin) { + nodeIndex[bin.id] = { node: bin, kind: 'transmittal', parent: slot }; + }); + }); + }); + } + function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; } + function infoFor(id) { return nodeIndex[id] || null; } + + // Ancestor name chain for a tracking node (root → node inclusive). + function trackingChain(info) { + var names = []; + var cur = info; + while (cur && cur.kind === 'tracking') { + names.unshift(cur.node.name); + cur = cur.parent ? infoFor(cur.parent.id) : null; + } + return names; + } + + // ── tracking tree ops ──────────────────────────────────────────────────── + function addTrackingNode(parentId, name) { + var node = { id: uid(), name: (name || 'new').trim() || 'new', children: [] }; + if (parentId) { + var info = infoFor(parentId); + if (!info || info.kind !== 'tracking') return null; + info.node.children.push(node); + } else { + state.trackingTree.push(node); + } + rebuildIndex(); + notify(); + return node.id; + } + + // ── transmittal tree ops ───────────────────────────────────────────────── + function addParty(name) { + var party = { id: uid(), kind: 'party', name: (name || 'Party').trim() || 'Party', children: [] }; + state.transmittalTree.push(party); + rebuildIndex(); + notify(); + return party.id; + } + function ensureSlot(party, slot) { + var existing = (party.children || []).filter(function (s) { return s.slot === slot; })[0]; + if (existing) return existing; + var node = { id: uid(), kind: 'slot', slot: slot, name: slot, children: [] }; + party.children.push(node); + return node; + } + // Create a transmittal bin. meta = { date, type:'TRN'|'SUB', seq, status?, title? }. + // The folder name follows the folder grammar; party node name doubles as the + // transmittal-number prefix (so its tracking is "--"). + function addTransmittalBin(partyId, slot, meta) { + var info = infoFor(partyId); + if (!info || info.kind !== 'party') return null; + var slotNode = ensureSlot(info.node, slot); + var bin = { id: uid(), kind: 'transmittal', name: transmittalFolderName(info.node.name, meta), meta: meta }; + slotNode.children.push(bin); + rebuildIndex(); + notify(); + return bin.id; + } + function transmittalFolderName(partyName, meta) { + var tn = [partyName, meta.type, meta.seq].filter(Boolean).join('-'); + var status = meta.status && zddc.isValidStatus(meta.status) ? meta.status : '---'; + var title = (meta.title && meta.title.trim()) || (meta.type === 'SUB' ? 'Submittal' : 'Transmittal'); + return zddc.formatFolder({ date: meta.date, trackingNumber: tn, status: status, title: title }); + } + + // ── shared node ops ────────────────────────────────────────────────────── + function renameNode(id, name) { + var info = infoFor(id); + if (!info) return; + if (info.kind === 'slot') return; // slots are fixed + info.node.name = (name || '').trim() || info.node.name; + if (info.kind === 'party') { + // Party rename re-derives child transmittal folder names (prefix). + (info.node.children || []).forEach(function (slot) { + (slot.children || []).forEach(function (bin) { + bin.name = transmittalFolderName(info.node.name, bin.meta); + }); + }); + } + rebuildIndex(); + notify(); + } + // Delete a node (and descendants). Any placement referencing a removed node + // is cleared so no file points at a ghost. + function deleteNode(id) { + var info = infoFor(id); + if (!info) return; + var removed = {}; + (function collect(n) { + removed[n.id] = true; + (n.children || []).forEach(collect); + })(info.node); + + if (info.kind === 'tracking') { + removeFrom(info.parent ? info.parent.children : state.trackingTree, id); + } else if (info.kind === 'party') { + removeFrom(state.transmittalTree, id); + } else if (info.kind === 'transmittal') { + removeFrom(info.parent.children, id); // info.parent is the slot node + } + // Clear dangling placements. + Object.keys(state.assignments).forEach(function (k) { + var a = state.assignments[k]; + if (a.trackingNodeId && removed[a.trackingNodeId]) a.trackingNodeId = null; + if (a.transmittalNodeId && removed[a.transmittalNodeId]) a.transmittalNodeId = null; + cleanAssignment(k); + }); + rebuildIndex(); + notify(); + } + function removeFrom(arr, id) { + for (var i = 0; i < arr.length; i++) { if (arr[i].id === id) { arr.splice(i, 1); return; } } + } + + // ── derive target ──────────────────────────────────────────────────────── + // Compute the full target for a file from its placements. Pure; returns + // { tracking, revision, status, title, extension, filename, outPath, + // party, slot, transmittalFolder, complete, excluded, errors:[] }. + function deriveTarget(file) { + var key = srcKeyForFile(file); + var a = state.assignments[key] || {}; + var out = { + key: key, + tracking: '', revision: '', status: '', + title: (a.titleOverride && a.titleOverride.trim()) || defaultTitle(file), + extension: file.extension || '', + filename: '', outPath: '', + party: '', slot: '', transmittalFolder: '', + trackingLeaf: false, excluded: !!a.excluded, errors: [], + }; + if (out.excluded) return out; + + // Axis 1 — tracking. + if (a.trackingNodeId) { + var ti = infoFor(a.trackingNodeId); + if (ti && ti.kind === 'tracking') { + var chain = trackingChain(ti); // [root … node] + out.tracking = chain.slice(0, -1).join('-'); // ancestors only + var leaf = parseLeafLabel(ti.node.name); + out.revision = leaf.revision; + out.status = leaf.status; + out.trackingLeaf = (ti.node.children || []).length === 0; + if (!out.tracking) out.errors.push('tracking number is empty — the file needs at least one ancestor folder'); + if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"'); + if (!out.status) out.errors.push('parent folder has no "(STATUS)" — name it like "A (IFR)"'); + if (!out.trackingLeaf) out.errors.push('not in a leaf folder yet'); + } + } else { + out.errors.push('no tracking number assigned'); + } + + // Axis 2 — transmittal → output path. + if (a.transmittalNodeId) { + var xi = infoFor(a.transmittalNodeId); + if (xi && xi.kind === 'transmittal') { + // bin → slot → party (nodeIndex stores parent as a NODE) + var slotInfo = xi.parent ? infoFor(xi.parent.id) : null; + out.slot = slotInfo ? slotInfo.node.slot : ''; + out.party = slotInfo && slotInfo.parent ? slotInfo.parent.name : ''; + out.transmittalFolder = xi.node.name; + if (out.party && out.slot && out.transmittalFolder) { + out.outPath = out.party + '/' + out.slot + '/' + out.transmittalFolder; + } + } + } else { + out.errors.push('not placed in a transmittal'); + } + + out.filename = zddc.formatFilename({ + trackingNumber: out.tracking, revision: out.revision, + status: out.status, title: out.title, extension: out.extension, + }); + if (!out.filename && out.errors.length === 0) out.errors.push('incomplete name'); + out.complete = !!(out.filename && out.outPath && out.errors.length === 0); + return out; + } + + // Files currently placed in a node (reverse lookup over all source files). + function filesInNode(nodeId, axis, allFiles) { + var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; + return (allFiles || []).filter(function (f) { + var a = state.assignments[srcKeyForFile(f)]; + return a && a[field] === nodeId; + }); + } + + // Per-file classification state for the left-tree markers. + // 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none' + function fileState(file) { + var a = state.assignments[srcKeyForFile(file)]; + if (!a) return 'none'; + if (a.excluded) return 'excluded'; + var t = !!a.trackingNodeId, x = !!a.transmittalNodeId; + if (t && x) { + var d = deriveTarget(file); + return d.complete ? 'done' : 'partial'; + } + if (t) return 'tracking'; + if (x) return 'transmittal'; + return 'none'; + } + + function stats(allFiles) { + var s = { total: 0, excluded: 0, none: 0, partial: 0, done: 0 }; + (allFiles || []).forEach(function (f) { + s.total++; + var st = fileState(f); + if (st === 'excluded') s.excluded++; + else if (st === 'done') s.done++; + else if (st === 'none') s.none++; + else s.partial++; // tracking | transmittal | partial + }); + return s; + } + + // ── serialize / load ───────────────────────────────────────────────────── + function serialize() { + return { + assignments: state.assignments, + trackingTree: state.trackingTree, + transmittalTree: state.transmittalTree, + outputName: state.outputName, + }; + } + function load(obj) { + if (!obj) return; + state.assignments = obj.assignments || {}; + state.trackingTree = obj.trackingTree || []; + state.transmittalTree = obj.transmittalTree || []; + state.outputName = obj.outputName || null; + rebuildIndex(); + notify(); + } + function reset() { + state.assignments = {}; state.trackingTree = []; state.transmittalTree = []; + state.outputName = null; + rebuildIndex(); + notify(); + } + + // ── mode ───────────────────────────────────────────────────────────────── + function setEnabled(on) { state.enabled = !!on; notify(); } + function isEnabled() { return state.enabled; } + + window.app.modules.classify = { + // mode + setEnabled: setEnabled, isEnabled: isEnabled, + // pub/sub + on: on, + // keys/title + srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle, + // assignments + assignmentFor: assignmentFor, place: place, setExcluded: setExcluded, + setTitleOverride: setTitleOverride, + // trees + addTrackingNode: addTrackingNode, addParty: addParty, + addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode, + getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, + getTransmittalTree: function () { return state.transmittalTree; }, + // derive + reverse + deriveTarget: deriveTarget, filesInNode: filesInNode, + fileState: fileState, stats: stats, + // persistence + serialize: serialize, load: load, reset: reset, + getOutputName: function () { return state.outputName; }, + setOutputName: function (n) { state.outputName = n || null; scheduleSave(); }, + }; +})(); diff --git a/classifier/js/persist.js b/classifier/js/persist.js new file mode 100644 index 0000000..34dfe19 --- /dev/null +++ b/classifier/js/persist.js @@ -0,0 +1,98 @@ +/** + * ZDDC Classifier — persistence for the Classify & Copy map. + * + * The assignment map and target trees, plus the picked source directory + * HANDLE, are stored in IndexedDB (localStorage can't hold a + * FileSystemDirectoryHandle; the handle is structured-cloneable, so IndexedDB + * can). On reload we re-request permission on the stored handle — a single + * click re-grants access, no re-navigation. If that fails (permission denied, + * or a different machine), the caller falls back to a fresh pick and the map + * re-attaches by relative path. + * + * NOTE: a stored handle is only valid in the same browser profile on the same + * machine. The map keys on source-relative paths, so re-picking the same tree + * elsewhere still re-attaches — that's the warning shown to the user on save. + */ +(function () { + 'use strict'; + + var DB_NAME = 'zddc-classifier'; + var STORE = 'kv'; + var K_STATE = 'classify-state'; + var K_HANDLE = 'source-handle'; + + var available = typeof indexedDB !== 'undefined'; + + function openDB() { + return new Promise(function (resolve, reject) { + if (!available) { reject(new Error('IndexedDB unavailable')); return; } + var req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = function () { + var db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + } + + function tx(mode, fn) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + var t = db.transaction(STORE, mode); + var store = t.objectStore(STORE); + var out = fn(store); + t.oncomplete = function () { resolve(out && out.result !== undefined ? out.result : out); }; + t.onerror = function () { reject(t.error); }; + t.onabort = function () { reject(t.error); }; + }); + }); + } + + function put(key, value) { return tx('readwrite', function (s) { return s.put(value, key); }); } + + function getValue(key) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + var t = db.transaction(STORE, 'readonly'); + var req = t.objectStore(STORE).get(key); + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + }); + } + + // ── public API ───────────────────────────────────────────────────────── + function saveState(obj) { return put(K_STATE, obj).catch(function (e) { console.warn('persist.saveState', e); }); } + function loadState() { return getValue(K_STATE).catch(function () { return null; }); } + + function saveSourceHandle(handle) { + // Real FileSystemDirectoryHandle only; the HTTP polyfill handle is not + // worth persisting (server mode re-detects the root on load). + if (!handle || handle.isHttp) return Promise.resolve(); + return put(K_HANDLE, handle).catch(function (e) { console.warn('persist.saveHandle', e); }); + } + function loadSourceHandle() { return getValue(K_HANDLE).catch(function () { return null; }); } + + function clearAll() { + return tx('readwrite', function (s) { s.delete(K_STATE); s.delete(K_HANDLE); }) + .catch(function (e) { console.warn('persist.clear', e); }); + } + + // Re-acquire read permission on a stored handle. Returns true if usable. + function verifyPermission(handle) { + if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false); + var opts = { mode: 'read' }; + return handle.queryPermission(opts).then(function (p) { + if (p === 'granted') return true; + return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; }); + }).catch(function () { return false; }); + } + + window.app.modules.persist = { + available: available, + saveState: saveState, loadState: loadState, + saveSourceHandle: saveSourceHandle, loadSourceHandle: loadSourceHandle, + verifyPermission: verifyPermission, clearAll: clearAll, + }; +})(); diff --git a/playwright.config.js b/playwright.config.js index 43510b9..c8f3330 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -51,6 +51,10 @@ export default defineConfig({ name: 'classifier', testMatch: 'classifier.spec.js', }, + { + name: 'classify', + testMatch: 'classify.spec.js', + }, { name: 'browse', testMatch: 'browse.spec.js', diff --git a/tests/classify.spec.js b/tests/classify.spec.js new file mode 100644 index 0000000..9e0e2ef --- /dev/null +++ b/tests/classify.spec.js @@ -0,0 +1,149 @@ +/** + * 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()); +}); + +// 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); +}); + +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'); +});