ZDDC/tests/archive-cascade.spec.js
2026-06-11 13:32:31 -05:00

514 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// End-to-end cascade behaviour for the archive browser.
//
// Pins the contract for how the three filter layers compose:
// 1. Folder-type bar (enabledFolderTypes ⊂ {issued, received, mdl, incoming})
// 2. Selected parties (selectedGroupingFolders, name-keyed)
// 3. Selected projects (visibleProjects, in multi-project mode)
//
// Specifically targets the symptom "I select various third-party folders and
// the Issued/Received/MDL/Incoming badges, and sometimes files that should be
// there aren't shown". Each test asserts both the file table and the
// transmittal folder list, since the user reported both can drop entries.
import { test, expect } from '@playwright/test';
import { MOCK_FS_INIT_SCRIPT } from './fixtures/mock-fs-api.js';
import * as path from 'path';
const HTML_PATH = path.resolve('archive/dist/archive.html');
// All four folder types enabled — used at the start of each test so default
// (issued+received only) isn't quietly hiding things.
async function enableAllFolderTypes(page) {
await page.evaluate(() => {
window.app.enabledFolderTypes = new Set(['issued', 'received', 'mdl', 'incoming']);
});
}
async function selectAllParties(page) {
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(200);
}
// Returns the names of files currently visible in the table (parsed from the
// row labels). Folder-type changes flow through applyFilters → filteredFiles
// → updateFileTable, so reading filteredFiles directly is the closest signal.
async function visibleFileNames(page) {
return page.evaluate(() => window.app.filteredFiles.map(f => f.name));
}
// Returns the visible-transmittal-folder paths (the ones that pass the
// grouping/folder-type cascade — what the left panel actually renders).
async function visibleTransmittalPaths(page) {
return page.evaluate(() => {
const items = document.querySelectorAll('#transmittalFoldersList .folder-item');
return Array.from(items).map(el => el.getAttribute('data-path'));
});
}
test.describe('Archive cascade: folder-type × parties × outstanding', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
});
test('all four folder-type badges hide their subtrees and only their subtrees', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// Start with all four enabled so the scan picks up MDL/Incoming.
await enableAllFolderTypes(page);
await page.evaluate(() => {
window.__setMockDirectoryTree('Archive', {
'BM': {
'Issued': {
'2025-01-01_T-ISSUED (IFC) - I': {
'100-EL-SPC-0001_A (IFC) - SpecIssued.pdf': '%PDF',
},
},
'Received': {
'2025-01-02_T-RECEIVED (IFC) - R': {
'100-EL-SPC-0002_A (IFC) - SpecReceived.pdf': '%PDF',
},
},
'MDL': {
'2025-01-03_T-MDL (IFC) - M': {
'100-EL-SPC-0003_A (IFC) - SpecMDL.pdf': '%PDF',
},
},
'Incoming': {
'2025-01-04_T-INCOMING (IFC) - In': {
'100-EL-SPC-0004_A (IFC) - SpecIncoming.pdf': '%PDF',
},
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
// All four folder types should have surfaced files at scan time.
const allFour = await visibleFileNames(page);
for (const expected of ['SpecIssued', 'SpecReceived', 'SpecMDL', 'SpecIncoming']) {
expect(allFour.some(n => n.includes(expected)), `expected ${expected} present with all four enabled; got ${allFour.join(', ')}`).toBe(true);
}
// Toggle each folder type off and verify its subtree's files disappear,
// while the other three stay. This is the load-bearing claim.
const types = [
{ type: 'issued', expectGone: 'SpecIssued' },
{ type: 'received', expectGone: 'SpecReceived' },
{ type: 'mdl', expectGone: 'SpecMDL' },
{ type: 'incoming', expectGone: 'SpecIncoming' },
];
for (const { type, expectGone } of types) {
// Disable the type via the canonical app entry point.
await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type);
await page.waitForTimeout(300);
const visible = await visibleFileNames(page);
expect(
visible.some(n => n.includes(expectGone)),
`disabling ${type} should hide ${expectGone}; saw: ${visible.join(', ')}`
).toBe(false);
// The other three subtrees keep their files.
for (const other of types) {
if (other.type === type) continue;
expect(
visible.some(n => n.includes(other.expectGone)),
`disabling ${type} must not hide ${other.expectGone}; saw: ${visible.join(', ')}`
).toBe(true);
}
// Re-enable for the next iteration. toggleFolderType triggers a
// refreshDirectories() because the scan dropped that subtree —
// we need to wait for the rescan to finish before continuing.
await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type);
await page.waitForTimeout(2000);
}
});
test('toggling a folder type off then on does not duplicate or lose files', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await enableAllFolderTypes(page);
await page.evaluate(() => {
window.__setMockDirectoryTree('Archive', {
'BM': {
'Issued': {
'2025-01-01_T-ISSUED (IFC) - I': {
'100-EL-SPC-0001_A (IFC) - I1.pdf': '%PDF',
'100-EL-SPC-0001_B (IFC) - I2.pdf': '%PDF',
},
},
'Received': {
'2025-01-02_T-RECEIVED (IFC) - R': {
'100-EL-SPC-0002_A (IFC) - R1.pdf': '%PDF',
},
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
const baseline = await page.evaluate(() => ({
files: window.app.files.length,
txn: window.app.transmittalFolders.length,
grouping: window.app.groupingFolders.length,
}));
expect(baseline.files).toBe(3);
// Toggle Issued off → on.
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
await page.waitForTimeout(300);
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
await page.waitForTimeout(2000);
const after = await page.evaluate(() => ({
files: window.app.files.length,
txn: window.app.transmittalFolders.length,
grouping: window.app.groupingFolders.length,
// Distinct-file-id counts to catch duplication via re-scan.
distinctIds: new Set(window.app.files.map(f => f.id)).size,
distinctPaths: new Set(window.app.files.map(f => f.path)).size,
}));
expect(after.files, 'no file duplication after toggle off→on').toBe(baseline.files);
expect(after.distinctIds, 'each file should still have a unique id').toBe(after.files);
expect(after.distinctPaths, 'no duplicate file paths').toBe(after.files);
expect(after.grouping, 'grouping folders not duplicated').toBe(baseline.grouping);
expect(after.txn, 'transmittal folders not duplicated').toBe(baseline.txn);
});
test('outstanding files: visible under <party>/, <party>/Issued/, <party>/MDL/ matches the folder-type cascade', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await enableAllFolderTypes(page);
await page.evaluate(() => {
window.__setMockDirectoryTree('Archive', {
'BM': {
// Loose file directly under party — not under any folder-type marker.
'100-EL-SPC-LOOSE_A (IFC) - LooseAtParty.pdf': '%PDF',
'Issued': {
// Loose file under Issued (no transmittal folder wrapper)
'100-EL-SPC-LOOSE_A (IFC) - LooseAtIssued.pdf': '%PDF',
},
'MDL': {
'100-EL-SPC-LOOSE_A (IFC) - LooseAtMDL.pdf': '%PDF',
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
await selectAllParties(page);
// With all folder types enabled, all three loose files should be visible
// under the Outstanding virtual transmittal.
const allEnabled = await visibleFileNames(page);
expect(allEnabled.some(n => n.includes('LooseAtParty'))).toBe(true);
expect(allEnabled.some(n => n.includes('LooseAtIssued'))).toBe(true);
expect(allEnabled.some(n => n.includes('LooseAtMDL'))).toBe(true);
// Disable MDL — only LooseAtMDL should drop.
await page.evaluate(() => window.app.modules.app.toggleFolderType('mdl'));
await page.waitForTimeout(2000);
const noMDL = await visibleFileNames(page);
expect(noMDL.some(n => n.includes('LooseAtParty'))).toBe(true);
expect(noMDL.some(n => n.includes('LooseAtIssued'))).toBe(true);
expect(noMDL.some(n => n.includes('LooseAtMDL'))).toBe(false);
// Disable Issued too — LooseAtIssued drops, LooseAtParty stays
// (LooseAtParty's path has no folder-type segment at all).
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
await page.waitForTimeout(2000);
const noIssuedMDL = await visibleFileNames(page);
expect(noIssuedMDL.some(n => n.includes('LooseAtParty'))).toBe(true);
expect(noIssuedMDL.some(n => n.includes('LooseAtIssued'))).toBe(false);
expect(noIssuedMDL.some(n => n.includes('LooseAtMDL'))).toBe(false);
});
test('same-name party across two projects + folder-type cascade hides both projects symmetrically', async ({ page }) => {
// BM in ProjectA AND ProjectB, each with Issued and Received subtrees.
// Disabling Received must hide BOTH projects' Received files;
// selecting BM (one row in the panel) must surface both projects' Issued.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await enableAllFolderTypes(page);
await page.evaluate(() => {
window.__setMockDirectoryTree('combined-root', {
'ProjectA': {
'Archive': {
'BM': {
'Issued': {
'2025-01-01_T-A-I (IFC) - X': {
'100-EL-SPC-0001_A (IFC) - A_Issued.pdf': '%PDF',
},
},
'Received': {
'2025-01-02_T-A-R (IFC) - X': {
'100-EL-SPC-0002_A (IFC) - A_Received.pdf': '%PDF',
},
},
},
},
},
'ProjectB': {
'Archive': {
'BM': {
'Issued': {
'2025-02-01_T-B-I (IFC) - X': {
'200-EL-SPC-0001_A (IFC) - B_Issued.pdf': '%PDF',
},
},
'Received': {
'2025-02-02_T-B-R (IFC) - X': {
'200-EL-SPC-0002_A (IFC) - B_Received.pdf': '%PDF',
},
},
},
},
},
});
window.app.projectFilter = new Set(['ProjectA', 'ProjectB']);
window.app.visibleProjects = new Set(['ProjectA', 'ProjectB']);
window.app.isMultiProject = true;
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
// BM should appear once in the parties panel.
const partyRows = await page.locator('#groupingFoldersList .folder-item-name').allTextContents();
const bmCount = partyRows.filter(n => n.trim() === 'BM').length;
expect(bmCount, 'BM merged to one party row across projects').toBe(1);
// All four files visible with all four folder types enabled.
const all = await visibleFileNames(page);
expect(all.some(n => n.includes('A_Issued'))).toBe(true);
expect(all.some(n => n.includes('B_Issued'))).toBe(true);
expect(all.some(n => n.includes('A_Received'))).toBe(true);
expect(all.some(n => n.includes('B_Received'))).toBe(true);
// Disable Received — BOTH projects' Received files hide.
await page.evaluate(() => window.app.modules.app.toggleFolderType('received'));
await page.waitForTimeout(2000);
const noReceived = await visibleFileNames(page);
expect(noReceived.some(n => n.includes('A_Issued')), 'ProjectA Issued stays').toBe(true);
expect(noReceived.some(n => n.includes('B_Issued')), 'ProjectB Issued stays').toBe(true);
expect(noReceived.some(n => n.includes('A_Received')), 'ProjectA Received hidden').toBe(false);
expect(noReceived.some(n => n.includes('B_Received')), 'ProjectB Received hidden').toBe(false);
// Transmittal folder list also drops both projects' Received transmittals.
const txnPaths = await visibleTransmittalPaths(page);
expect(txnPaths.every(p => !p.includes('/Received/')), 'no Received transmittals: ' + txnPaths.join(', ')).toBe(true);
});
test('toggling a party off then on with selectAllTransmittals re-syncs the transmittal selection', async ({ page }) => {
// Default selectAllTransmittals=true. After deselecting BM, BM's
// transmittal folders are gone from the list. Reselecting BM should
// make them visible AND auto-selected (because select-all is still on).
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await enableAllFolderTypes(page);
await page.evaluate(() => {
window.__setMockDirectoryTree('Archive', {
'BM': {
'Issued': {
'2025-01-01_T-BM (IFC) - X': {
'100-EL-SPC-0001_A (IFC) - BM1.pdf': '%PDF',
},
},
},
'OTHER': {
'Issued': {
'2025-01-02_T-OTHER (IFC) - X': {
'200-EL-SPC-0002_A (IFC) - OTHER1.pdf': '%PDF',
},
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
// Both parties auto-selected, both transmittals auto-selected.
const initial = await page.evaluate(() => ({
selectAllTxn: window.app.selectAllTransmittals,
selectedGrouping: Array.from(window.app.selectedGroupingFolders).sort(),
selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(),
}));
expect(initial.selectAllTxn).toBe(true);
expect(initial.selectedGrouping).toEqual(['BM', 'OTHER']);
expect(initial.selectedTxn.some(p => p.includes('T-BM'))).toBe(true);
expect(initial.selectedTxn.some(p => p.includes('T-OTHER'))).toBe(true);
// Deselect BM via the canonical event handler (mimics user click)
await page.evaluate(() => {
window.app.selectAllGroupingFolders = false;
window.app.selectedGroupingFolders.delete('BM');
window.app.modules.app.renderGroupingFolders();
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
});
await page.waitForTimeout(200);
// BM's transmittal is no longer visible AND no longer in the selection set.
const afterDeselect = await page.evaluate(() => ({
selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(),
visiblePaths: Array.from(document.querySelectorAll('#transmittalFoldersList .folder-item'))
.map(el => el.getAttribute('data-path')),
}));
expect(afterDeselect.selectedTxn.some(p => p.includes('T-BM'))).toBe(false);
expect(afterDeselect.visiblePaths.some(p => p && p.includes('T-BM'))).toBe(false);
// Re-add BM. Because selectAllTransmittals stays true, BM's
// transmittal should be auto-selected on the re-render.
await page.evaluate(() => {
window.app.selectedGroupingFolders.add('BM');
window.app.modules.app.renderGroupingFolders();
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
});
await page.waitForTimeout(200);
const afterReselect = await page.evaluate(() => ({
selectAllTxn: window.app.selectAllTransmittals,
selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(),
visibleFiles: window.app.filteredFiles.map(f => f.name),
}));
expect(afterReselect.selectAllTxn).toBe(true);
expect(
afterReselect.selectedTxn.some(p => p.includes('T-BM')),
'BM transmittal should be re-selected via selectAll cascade'
).toBe(true);
expect(afterReselect.visibleFiles.some(n => n.includes('BM1'))).toBe(true);
});
});
test.describe('Archive cascade: nested-party path-segment contract', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
});
// Pin the contract for nested-party paths. With folder-type segment NOT
// immediately after the party, the current rule (transmittalIsUnderVisibleParty
// checks only the segment right after the first matched party) means the
// folder-type cascade can leak. Either the test should fail and we fix
// the cascade to walk all party matches; or the contract is "the folder
// type only applies one level under the party". Recording the call here
// forces us to face the question explicitly when a future change touches
// the helper.
test('nested party + deep folder-type marker: the deep folder-type filter applies to the file', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await enableAllFolderTypes(page);
await page.evaluate(() => {
window.__setMockDirectoryTree('Archive', {
'BM': {
'sub': {
'Issued': {
'2025-01-01_T-NESTED (IFC) - X': {
'100-EL-SPC-NESTED_A (IFC) - SpecDeepIssued.pdf': '%PDF',
},
},
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
await selectAllParties(page);
// With Issued enabled, the file is visible.
const visibleAll = await visibleFileNames(page);
expect(visibleAll.some(n => n.includes('SpecDeepIssued'))).toBe(true);
// Turn Issued OFF — the file lives under .../Issued/... so its scan-time
// listing should be skipped. After the toggle-driven rescan, the file
// must NOT be visible regardless of how transmittalIsUnderVisibleParty
// walks segments.
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
await page.waitForTimeout(2000);
const visibleNoIssued = await visibleFileNames(page);
expect(
visibleNoIssued.some(n => n.includes('SpecDeepIssued')),
'deep Issued subtree must hide when Issued is toggled off'
).toBe(false);
});
});
test.describe('Archive cascade: URL state round-trip', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
});
test('non-default folder-type set round-trips through serialize → restore', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
const qs = await page.evaluate(() => {
window.app.enabledFolderTypes = new Set(['issued', 'mdl']); // received off, incoming off, mdl on
return window.app.modules.urlState.serialize();
});
// serialize() returns "?types=..." (leading "?" included) or "" when no
// diffs from defaults exist. Strip the leading "?" before re-emitting.
const cleanQs = qs.startsWith('?') ? qs.slice(1) : qs;
expect(cleanQs).toContain('types=');
const round = await page.evaluate((querystring) => {
// Reset to defaults so restore has work to do.
window.app.enabledFolderTypes = new Set(['issued', 'received']);
history.replaceState(null, '', '?' + querystring);
window.app.modules.urlState.restore();
return Array.from(window.app.enabledFolderTypes).sort();
}, cleanQs);
expect(round).toEqual(['issued', 'mdl']);
});
// Selected parties are deliberately NOT in URL state today — the natural
// flow is: pick a directory locally, then narrow with party checkboxes.
// Pin this in a test so accidentally adding party serialization later
// doesn't break sharing semantics; remove the test (and the assertion)
// when/if sharing-with-parties becomes a feature.
test('selected parties are NOT serialized to URL state (current contract)', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
const qs = await page.evaluate(() => {
window.app.selectAllGroupingFolders = false;
window.app.selectedGroupingFolders = new Set(['BM', 'OTHER']);
return window.app.modules.urlState.serialize();
});
expect(qs).not.toContain('parties=');
expect(qs).not.toContain('groups=');
expect(qs).not.toContain('selected=');
});
});