diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 0d44531..da979e6 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -628,17 +628,6 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o .scratch-match__tn { font-family: var(--mono, monospace); } .scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; } -/* ── MDL-from-archive overlay ───────────────────────────────────────────── */ -.mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; } -.mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; } -.mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); } -.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; } -.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; } -.mdl-overlay__close:hover { color: var(--text); } -.mdl-overlay__status { padding: 0.4rem 1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); } -.mdl-overlay__table { flex: 1; min-height: 0; } -.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); } - /* ── Shared selectable + autofilter table (seltable) ────────────────────── */ .seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; } .seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; } diff --git a/classifier/js/classify.js b/classifier/js/classify.js index feb0add..de7acb9 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -378,15 +378,6 @@ 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) { @@ -858,7 +849,7 @@ getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getTransmittalTree: function () { return state.transmittalTree; }, // derive + reverse - deriveTarget: deriveTarget, filesInNode: filesInNode, + deriveTarget: deriveTarget, fileState: fileState, stats: stats, // persistence serialize: serialize, load: load, reset: reset, diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 2a7db34..4195f1c 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -601,11 +601,11 @@ }); } - // Load the catalog: "Load…" opens a multi-select directory tree (scoped to - // the served context); every ticked directory is walked recursively into the - // union of existing files + MDL deliverables, deduped by tracking number to - // one row at the latest revision. Writes/alters nothing — the revision cell - // is classifier-local and starts blank. + // "From a list" loader: "Load…" opens a multi-select directory tree (scoped + // to the served context); every ticked directory is walked recursively into + // the union of existing files + MDL deliverables, deduped by tracking number + // to one row at the latest revision. Writes/alters nothing — the revision + // cell is classifier-local and starts blank. function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } // The newest combined " ()" string in a set, by revision token. diff --git a/tests/browse.spec.js b/tests/browse.spec.js index fd80da3..2ad91f4 100644 --- a/tests/browse.spec.js +++ b/tests/browse.spec.js @@ -271,11 +271,10 @@ test.describe('Browse menu — context & tiers', () => { expect(res.rwd).toContain('Delete…'); }); - test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => { + // New folder / New file are not toolbar buttons — they live in the + // row/pane context menu (see the "keyboard menu key and kebab" test). + test('toolbar Sort and Show-hidden drive state', async ({ page }) => { await openWithTree(page); - await expect(page.locator('#newFolderBtn')).toBeVisible(); - await expect(page.locator('#newFileBtn')).toBeVisible(); - await page.locator('#sortSelect').selectOption('date:-1'); expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 }); diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 3149322..d282090 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -1408,7 +1408,7 @@ test('proposeMatches finds a row whose tracking number is in the filename', asyn expect(r[1].conf).toBeCloseTo(0.8); }); -test('By existing: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => { +test('From a list: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(async () => { const tt = window.app.modules.targetTree; @@ -1459,7 +1459,7 @@ test('By existing: walkDirInto unions files + mdl deliverables, deduped to the l expect(r.latestModifierWins).toBe('A+B1 (IFC)'); // A < A+B1 }); -test('By existing: _detectScope routes by URL/protocol', async ({ page }) => { +test('From a list: _detectScope routes by URL/protocol', async ({ page }) => { const r = await page.evaluate(() => { const tt = window.app.modules.targetTree; return { @@ -1475,7 +1475,7 @@ test('By existing: _detectScope routes by URL/protocol', async ({ page }) => { expect(r.offlineHttp).toBe('local'); }); -test('By existing: dir-picker resolves the topmost ticked directories only', async ({ page }) => { +test('From a list: dir-picker resolves the topmost ticked directories only', async ({ page }) => { const r = await page.evaluate(() => { const dp = window.app.modules.dirPicker; const childOfA = { handle: 'A/x', checked: true, children: [] }; diff --git a/tests/toast.spec.js b/tests/toast.spec.js index 752be7a..7b6b01e 100644 --- a/tests/toast.spec.js +++ b/tests/toast.spec.js @@ -22,12 +22,13 @@ test.describe('shared/toast.js', () => { expect(exposed).toBe(true); }); - test('renders a single toast with the level class and ARIA role', async ({ page }) => { + test('renders a toast with the level class and ARIA role', async ({ page }) => { const after = await page.evaluate(() => { window.zddc.toast('Saved.', 'success'); const el = document.querySelector('.zddc-toast'); return el && { - text: el.textContent, + // The message lives in its own span (the toast also holds a × button). + text: el.querySelector('.zddc-toast__msg').textContent, level: [...el.classList].find(c => c.startsWith('zddc-toast--')), role: el.getAttribute('role'), live: el.getAttribute('aria-live'), @@ -50,18 +51,24 @@ test.describe('shared/toast.js', () => { expect(probe).toEqual({ role: 'alert', live: 'assertive' }); }); - test('a second toast replaces the first (single-toast policy)', async ({ page }) => { - const count = await page.evaluate(() => { - window.zddc.toast('one', 'info'); - window.zddc.toast('two', 'info'); - return document.querySelectorAll('.zddc-toast').length; + test('toasts stack, and a "Clear all" control appears at 2+', async ({ page }) => { + const r = await page.evaluate(() => { + window.zddc.toast('one', 'error'); // sticky so it stays for the count + window.zddc.toast('two', 'error'); + return { + count: document.querySelectorAll('.zddc-toast').length, + clearAll: !!document.querySelector('.zddc-toasts__clear'), + }; }); - expect(count).toBe(1); + expect(r.count).toBe(2); // stack, not replace + expect(r.clearAll).toBe(true); // "Clear all" surfaces when 2+ are stacked }); - test('clicking dismisses immediately', async ({ page }) => { - await page.evaluate(() => window.zddc.toast('click me', 'info')); - await page.locator('.zddc-toast').click(); + test('the × button dismisses a toast; clicking the body does not', async ({ page }) => { + await page.evaluate(() => window.zddc.toast('keep me', 'error')); // sticky + await page.locator('.zddc-toast .zddc-toast__msg').click(); // selecting text ≠ dismiss + await expect(page.locator('.zddc-toast')).toHaveCount(1); + await page.locator('.zddc-toast .zddc-toast__close').click(); // × dismisses await expect(page.locator('.zddc-toast')).toHaveCount(0); }); }); diff --git a/tests/tokens.spec.js b/tests/tokens.spec.js index d6e19f5..cedfc00 100644 --- a/tests/tokens.spec.js +++ b/tests/tokens.spec.js @@ -162,19 +162,23 @@ test.describe('/.tokens self-service token UI', () => { }); test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => { + page.on('dialog', d => d.accept()); const xssDesc = ``; await page.goto(`${server.baseURL}/.tokens`); - await page.fill('#desc', xssDesc); - await page.click('button[type="submit"]'); - // Wait for the row to appear in the table. - await expect(page.locator('#tokens tbody')).toContainText(' tag should NOT have been parsed as HTML — - // window.__xss must remain undefined. - const xssFired = await page.evaluate(() => window.__xss === 1); - expect(xssFired).toBe(false); - // And the on-disk text content of the cell should contain the - // literal angle brackets, proving they were escaped. - const rowText = await page.locator('#tokens tbody tr', { hasText: 'img src' }).textContent(); - expect(rowText).toContain(' must NOT have been parsed (its onerror never fires)… + expect(await page.evaluate(() => window.__xss === 1)).toBe(false); + // …and the literal angle brackets survive in the cell text. + expect(await row.textContent()).toContain('