ZDDC/tests/classify.spec.js
ZDDC a8f116734d feat(classifier): Classify & Copy state model + persistence (phase 1)
Foundation for the non-destructive map+copy workflow: source stays read-only,
files are mapped onto two orthogonal target trees, a later step copies renamed
copies to a separate output dir.

- classify.js: the single source of truth. assignments map keyed by
  source-relative path (survives re-pick); tracking tree (positional: ancestors
  joined '-' = tracking number, immediate parent 'REV (STATUS)' leaf = rev+status,
  title from original name) and transmittal tree (<party>/{received,issued}/<bin>).
  deriveTarget() computes filename + output path + validation purely; pub/sub +
  debounced autosave; node CRUD with dangling-placement cleanup.
- persist.js: IndexedDB store of the serialized map + the source
  FileSystemDirectoryHandle, with queryPermission/requestPermission re-grant on
  reload and a re-pick fallback.
- tests/classify.spec.js: 9 in-page unit tests for the derive/assignment logic
  (no FS Access needed) — tracking join, leaf REV (STATUS) parse incl. invalid
  status, title derivation/override, transmittal path composition, exclude,
  cascade delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:11:04 -05:00

149 lines
6.5 KiB
JavaScript

/**
* 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');
});