feat(classifier): live filter box above each file tree (reveals matches + path)

Adds an autofilter input above the source tree and above each target tree.
Typing substring-matches (ANDing space-separated terms) against the full file
path/name (and folder/node names) and reveals every match with the folder
hierarchy leading to it — non-matching branches collapse out, matching branches
auto-expand. So you can type "master deliverables list" and jump straight to it.

- Source tree (tree.js): one-pass visible-set over path+name; composes with the
  Show Unassigned/Assigned/Excluded toggles; auto-expands to reveal hits.
- Target trees (target-tree.js): tracking + transmittal nodes are filter-aware
  (match node names + each placed file's original/derived name); one shared
  query mirrored across both tab inputs.

Tests: source-tree path reveal + tracking-tree node filter (classify.spec.js -> 36).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 12:37:36 -05:00
parent 9851cc4463
commit c61cac7c8f
6 changed files with 203 additions and 51 deletions

View file

@ -150,6 +150,15 @@
.classify-filters { display: inline-flex; flex-wrap: wrap; gap: 0.3rem 0.75rem; justify-content: flex-end; } .classify-filters { display: inline-flex; flex-wrap: wrap; gap: 0.3rem 0.75rem; justify-content: flex-end; }
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; } .classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
/* Live filter box above a file tree. */
.tree-filter {
width: 100%; box-sizing: border-box; margin: 0.25rem 0;
padding: 0.25rem 0.5rem; font: inherit; font-size: 0.85rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text);
}
.tree-filter:focus { outline: none; border-color: var(--primary); }
.folder-stats, .folder-stats,
.file-stats { .file-stats {
display: flex; display: flex;

View file

@ -150,6 +150,9 @@
exportDatasetBtn: document.getElementById('exportDatasetBtn'), exportDatasetBtn: document.getElementById('exportDatasetBtn'),
importDatasetBtn: document.getElementById('importDatasetBtn'), importDatasetBtn: document.getElementById('importDatasetBtn'),
importDatasetInput: document.getElementById('importDatasetInput'), importDatasetInput: document.getElementById('importDatasetInput'),
treeFilterInput: document.getElementById('treeFilterInput'),
trackingFilterInput: document.getElementById('trackingFilterInput'),
transmittalFilterInput: document.getElementById('transmittalFilterInput'),
// Folder tree // Folder tree
folderTree: document.getElementById('folderTree'), folderTree: document.getElementById('folderTree'),
@ -347,6 +350,20 @@
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); }); if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); }); if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
// Live source-tree filter (matches file path + name; reveals the hierarchy).
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
if (app.modules.tree && app.modules.tree.setNameFilter) app.modules.tree.setNameFilter(this.value);
});
// Target-tree filter — both tabs share one query (mirrored across inputs).
function targetFilter(val) {
if (app.dom.trackingFilterInput) app.dom.trackingFilterInput.value = val;
if (app.dom.transmittalFilterInput) app.dom.transmittalFilterInput.value = val;
if (app.modules.targetTree && app.modules.targetTree.setNameFilter) app.modules.targetTree.setNameFilter(val);
}
[app.dom.trackingFilterInput, app.dom.transmittalFilterInput].forEach(function (inp) {
if (inp) inp.addEventListener('input', function () { targetFilter(this.value); });
});
// Dataset export / import (round-trip the classification through a JSON file). // Dataset export / import (round-trip the classification through a JSON file).
if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset); if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset);
if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); }); if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); });

View file

@ -180,40 +180,67 @@
return box; return box;
} }
// Tracking tree (recursive) // ── name filter (the autofilter box above the target trees) ────────────
var rfTerms = [];
function setNameFilter(q) {
rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
render();
}
function rfActive() { return rfTerms.length > 0; }
function rfHit(text) {
if (!rfTerms.length) return true;
var t = String(text || '').toLowerCase();
for (var i = 0; i < rfTerms.length; i++) { if (t.indexOf(rfTerms[i]) === -1) return false; }
return true;
}
// A placed-file row matches on its original name or its derived ZDDC name.
function fileRowMatches(f) {
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
}
// Tracking tree (recursive, filter-aware — a match reveals its whole path).
function renderTrackingInto(container, nodes, placedMap) { function renderTrackingInto(container, nodes, placedMap) {
container.textContent = ''; container.textContent = '';
if (!nodes.length) { if (!nodes.length) {
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.')); container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
return; return;
} }
nodes.forEach(function (n) { container.appendChild(trackingNode(n, placedMap)); }); nodes.forEach(function (n) { var e = trackingNode(n, placedMap, false); if (e) container.appendChild(e); });
if (rfActive() && !container.children.length) {
container.appendChild(el('div', 'target-empty', 'No matches in the tracking tree.'));
}
} }
function trackingNode(n, placedMap) { function trackingNode(n, placedMap, ancMatched) {
var matched = ancMatched || rfHit(n.name);
var isLeaf = (n.children || []).length === 0; var isLeaf = (n.children || []).length === 0;
var expanded = !collapsed[n.id] || rfActive(); // auto-expand to reveal matches
var childEls = [];
if (expanded || rfActive()) {
(n.children || []).forEach(function (c) { var ce = trackingNode(c, placedMap, matched); if (ce) childEls.push(ce); });
}
var placed = placedMap[n.id] || [];
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
if (rfActive() && !matched && !childEls.length && !shownFiles.length) return null;
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : '')); var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
wrap.dataset.id = n.id; wrap.dataset.id = n.id;
var row = el('div', 'tnode__row'); var row = el('div', 'tnode__row');
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (expanded ? '▾' : '▸'));
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾'));
if (!isLeaf) toggle.dataset.act = 'toggle'; if (!isLeaf) toggle.dataset.act = 'toggle';
row.appendChild(toggle); row.appendChild(toggle);
row.appendChild(el('span', 'tnode__name', n.name)); row.appendChild(el('span', 'tnode__name', n.name));
var placed = placedMap[n.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([ row.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child folder' }, { act: 'add', label: '', title: 'Add child folder' },
{ act: 'rename', label: '✎', title: 'Rename' }, { act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' }, { act: 'del', label: '🗑', title: 'Delete' },
])); ]));
wrap.appendChild(row); wrap.appendChild(row);
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
if (placed.length) wrap.appendChild(fileList(placed)); if (!isLeaf && expanded && childEls.length) {
if (!isLeaf && !collapsed[n.id]) {
var kids = el('div', 'tnode__children'); var kids = el('div', 'tnode__children');
(n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, placedMap)); }); childEls.forEach(function (ce) { kids.appendChild(ce); });
wrap.appendChild(kids); wrap.appendChild(kids);
} }
return wrap; return wrap;
@ -226,20 +253,14 @@
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.')); container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
return; return;
} }
parties.forEach(function (p) { container.appendChild(partyNode(p, placedMap)); }); parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); });
if (rfActive() && !container.children.length) {
container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.'));
}
} }
function partyNode(party, placedMap) { function partyNode(party, placedMap) {
var wrap = el('div', 'tnode tnode--party'); var partyMatch = rfHit(party.name);
wrap.dataset.id = party.id; var slotEls = [], anyBin = false;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__icon', '🏢'));
row.appendChild(el('span', 'tnode__name', party.name));
row.appendChild(nodeActions([
{ act: 'rename-party', label: '✎', title: 'Rename party' },
{ act: 'del-party', label: '🗑', title: 'Delete party' },
]));
wrap.appendChild(row);
SLOTS.forEach(function (slot) { SLOTS.forEach(function (slot) {
var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0]; var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
var sw = el('div', 'tslot'); var sw = el('div', 'tslot');
@ -256,22 +277,39 @@
sw.appendChild(binForm(party.id, slot)); sw.appendChild(binForm(party.id, slot));
} }
(slotNode ? slotNode.children : []).forEach(function (bin) { (slotNode ? slotNode.children : []).forEach(function (bin) {
sw.appendChild(binNode(bin, placedMap)); var be = binNode(bin, placedMap, partyMatch);
if (be) { sw.appendChild(be); anyBin = true; }
}); });
wrap.appendChild(sw); slotEls.push(sw);
}); });
if (rfActive() && !partyMatch && !anyBin) return null;
var wrap = el('div', 'tnode tnode--party');
wrap.dataset.id = party.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__icon', '🏢'));
row.appendChild(el('span', 'tnode__name', party.name));
row.appendChild(nodeActions([
{ act: 'rename-party', label: '✎', title: 'Rename party' },
{ act: 'del-party', label: '🗑', title: 'Delete party' },
]));
wrap.appendChild(row);
slotEls.forEach(function (sw) { wrap.appendChild(sw); });
return wrap; return wrap;
} }
function binNode(bin, placedMap) { function binNode(bin, placedMap, ancMatched) {
var matched = ancMatched || rfHit(bin.name || '');
var placed = placedMap[bin.id] || [];
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
if (rfActive() && !matched && !shownFiles.length) return null;
var wrap = el('div', 'tnode tnode--bin'); var wrap = el('div', 'tnode tnode--bin');
wrap.dataset.id = bin.id; wrap.dataset.id = bin.id;
var row = el('div', 'tnode__row'); var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
var placed = placedMap[bin.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
wrap.appendChild(row); wrap.appendChild(row);
if (placed.length) wrap.appendChild(fileList(placed)); if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
return wrap; return wrap;
} }
@ -490,6 +528,7 @@
render: render, render: render,
showTab: showTab, showTab: showTab,
activeAxis: activeAxis, activeAxis: activeAxis,
setNameFilter: setNameFilter,
reveal: reveal, reveal: reveal,
}; };
})(); })();

View file

@ -63,18 +63,55 @@
var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId); var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId);
return assigned ? 'assigned' : 'unassigned'; return assigned ? 'assigned' : 'unassigned';
} }
function fileVisible(file) { return !!showFilters[fileCategory(file)]; } function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; }
function subtreeVisibleCount(folder) {
var n = 0; // ── name filter (the autofilter box above the tree) ────────────────────
subtreeFiles(folder).forEach(function (f) { if (fileVisible(f)) n++; }); // Live substring search over each file's full path+name (and folder names),
return n; // ANDing space-separated terms. Matches reveal their whole folder hierarchy.
var nameFilter = '', filterTerms = [];
function setNameFilter(q) {
nameFilter = (q || '').trim();
filterTerms = nameFilter.toLowerCase().split(/\s+/).filter(Boolean);
render();
} }
// Hide a folder only when it's fully scanned (so we never hide one that may function filterActive() { return filterTerms.length > 0; }
// still reveal files) and the active filters leave nothing visible in it. function nameHit(text) {
function folderHidden(folder) { if (!filterTerms.length) return true;
if (!classifyOn() || allFiltersOn()) return false; var t = String(text || '').toLowerCase();
if (folder.scanState && folder.scanState !== 'done') return false; for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; }
return subtreeVisibleCount(folder) === 0; return true;
}
// Anything narrowing the tree (a name search, or a show-filter turned off).
function anyFilter() { return filterActive() || (classifyOn() && !allFiltersOn()); }
// One pass → the set of folder paths + file keys to render. A file shows when
// it passes the show-filters AND (no name search, OR an ancestor folder
// matched, OR its own path/name matches). A folder shows when it (or an
// ancestor) matches, or anything inside it shows — so the path to a hit is
// always revealed.
var visible = null; // { folders, files } while filtering, else null
function computeVisible() {
var c = window.app.modules.classify;
var folders = Object.create(null), files = Object.create(null);
var nf = filterActive();
function walk(folder, ancMatched) {
var matched = ancMatched || (nf && nameHit(folder.path || folder.name));
var show = false;
(folder.children || []).forEach(function (ch) { if (walk(ch, matched)) show = true; });
(folder.files || []).forEach(function (f) {
if (!classifyAllows(f)) return;
if (!nf || matched || nameHit(c.srcKeyForFile(f))) { files[c.srcKeyForFile(f)] = true; show = true; }
});
if (matched) show = true;
if (show) folders[folder.path] = true;
return show;
}
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
return { folders: folders, files: files };
}
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
function fileShown(file) {
if (!classifyAllows(file)) return false;
return !visible || !!visible.files[window.app.modules.classify.srcKeyForFile(file)];
} }
// All scanned files (for the per-bucket counts on the filter checkboxes). // All scanned files (for the per-bucket counts on the filter checkboxes).
function allClassifyFiles() { function allClassifyFiles() {
@ -100,6 +137,7 @@
wireClassifyInteractions(); wireClassifyInteractions();
container.innerHTML = ''; container.innerHTML = '';
updateFilterCounts(); updateFilterCounts();
visible = anyFilter() ? computeVisible() : null;
if (window.app.folderTree.length === 0) { if (window.app.folderTree.length === 0) {
container.innerHTML = '<div class="tree-empty">No folders found</div>'; container.innerHTML = '<div class="tree-empty">No folders found</div>';
@ -107,12 +145,13 @@
} }
window.app.folderTree.forEach(folder => { window.app.folderTree.forEach(folder => {
if (folderHidden(folder)) return; if (!folderShown(folder)) return;
const element = createFolderElement(folder); const element = createFolderElement(folder);
container.appendChild(element); container.appendChild(element);
}); });
if (classifyOn() && !container.children.length) { if (!container.children.length) {
container.innerHTML = '<div class="tree-empty">Nothing matches the current filters.</div>'; container.innerHTML = '<div class="tree-empty">'
+ (filterActive() ? 'No files match “' + nameFilter + '”.' : 'Nothing matches the current filters.') + '</div>';
} }
updateSelectedCount(); updateSelectedCount();
@ -220,7 +259,7 @@
// expandable so its files can be revealed and dragged. // expandable so its files can be revealed and dragged.
|| (classifyOn() && folder.files && folder.files.length > 0); || (classifyOn() && folder.files && folder.files.length > 0);
if (mightHaveChildren) { if (mightHaveChildren) {
toggle.textContent = folder.expanded ? '▼' : '▶'; toggle.textContent = (folder.expanded || visible) ? '▼' : '▶';
toggle.addEventListener('click', (e) => { toggle.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey; const recursive = e.ctrlKey || e.metaKey;
@ -298,12 +337,12 @@
div.appendChild(item); div.appendChild(item);
// Children (if expanded) // Children — when expanded, or auto-expanded to reveal a filter match.
if (folder.expanded && folder.children && folder.children.length > 0) { if ((folder.expanded || visible) && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div'); const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children'; childrenDiv.className = 'folder-children';
folder.children.forEach(child => { folder.children.forEach(child => {
if (folderHidden(child)) return; if (!folderShown(child)) return;
const childElement = createFolderElement(child, level + 1); const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement); childrenDiv.appendChild(childElement);
}); });
@ -311,12 +350,12 @@
} }
// Classify mode: list this folder's own files (draggable leaves) when // Classify mode: list this folder's own files (draggable leaves) when
// expanded, so they can be dropped onto the target trees. // expanded (or auto-expanded by a filter), so they can be dropped.
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) { if (classifyOn() && (folder.expanded || visible) && folder.files && folder.files.length > 0) {
const filesDiv = document.createElement('div'); const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files'; filesDiv.className = 'folder-children folder-files';
folder.files.forEach(function (file) { folder.files.forEach(function (file) {
if (classifyOn() && !fileVisible(file)) return; if (!fileShown(file)) return;
filesDiv.appendChild(createFileElement(file, level + 1)); filesDiv.appendChild(createFileElement(file, level + 1));
}); });
div.appendChild(filesDiv); div.appendChild(filesDiv);
@ -894,6 +933,7 @@
expandAll, expandAll,
selectAll, selectAll,
revealFile, revealFile,
setShowFilters setShowFilters,
setNameFilter
}; };
})(); })();

View file

@ -78,6 +78,8 @@
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span> <span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div> </div>
</div> </div>
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
<div id="folderTree" class="folder-tree"> <div id="folderTree" class="folder-tree">
<!-- Dynamically populated --> <!-- Dynamically populated -->
</div> </div>
@ -168,6 +170,8 @@
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button> <button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span> <span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
</div> </div>
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
<div id="trackingTree" class="target-tree"></div> <div id="trackingTree" class="target-tree"></div>
</section> </section>
<section id="transmittalPanel" class="target-panel" hidden> <section id="transmittalPanel" class="target-panel" hidden>
@ -175,6 +179,8 @@
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button> <button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
<span class="target-hint">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal.</span> <span class="target-hint">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal.</span>
</div> </div>
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
<div id="transmittalTree" class="target-tree"></div> <div id="transmittalTree" class="target-tree"></div>
</section> </section>
</div> </div>

View file

@ -701,3 +701,44 @@ test('dataset (filename-based): import reconstruction rebuilds tracking + shared
expect(r.bins).toBe(1); // shared transmittal → single bin (dedup) expect(r.bins).toBe(1); // shared transmittal → single bin (dedup)
expect(r.excluded).toBe(true); 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');
});