ZDDC/tests/tables-mdl.spec.js
ZDDC 95c9e42270 feat(tables): "Add from archive" on the project MDL rollup
The MDL owns the workflow of registering deliverables; this is the
catch-up path for files that already exist in the archive but were never
listed. On the project MDL rollup (<project>/mdl/, addable:false), a new
"+ From archive" toolbar button opens an overlay that walks the project
archive into the shared seltable (per-column autofilter + ctrl-shift
selection), dedupes the selection to one deliverable per tracking number,
and PUTs a deliverable .yaml into each originator's archive/<originator>/
mdl/. Identity fields are split positionally from the tracking number per
the project's own table columns (originator is folder-pinned, so omitted
from the body); the server composes/validates the filename. Existing
deliverables are skipped; created/skipped/failed are reported.

- tables/js/mdl-from-archive.js: walkArchive / dedupe / deliverableFromFile
  / instantiateOne + the overlay UI; setup() shows the button only on an
  /mdl/ rollup over http, gated on archive create permission.
- shared/seltable.css: promoted seltable base styles + per-column filter
  row + the overlay chrome (bundled into tables; classifier keeps its
  inline copy).
- main.js wires setup(ctx); template.html adds the (hidden) button;
  build.sh bundles ../shared/seltable.{js,css} + the new module.
- tests/tables-mdl.spec.js (new project): split/dedupe/walk/instantiate
  against in-page mock FS handles; 7 green. tables suite still 47 green.

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

194 lines
9.3 KiB
JavaScript

import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// "Add from archive" for the tables tool's project MDL rollup. The page is
// loaded offline (file://) with an injected #table-context whose columns drive
// how a tracking number splits into deliverable fields. The walk / dedupe /
// instantiate logic is exercised against in-page mock FS-Access handles — no
// server needed.
const HTML_PATH = path.resolve('tables/dist/tables.html');
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
// originator … identity fields … title (originator is folder-pinned → omitted
// from the body; everything between originator and title is the tracking split).
const MDL_COLUMNS = [
{ field: 'originator', title: 'Orig' },
{ field: 'phase', title: 'Phase' },
{ field: 'project', title: 'Project' },
{ field: 'area', title: 'Area' },
{ field: 'discipline', title: 'Disc' },
{ field: 'type', title: 'Type' },
{ field: 'sequence', title: 'Seq' },
{ field: 'suffix', title: 'Suffix' },
{ field: 'title', title: 'Deliverable' },
];
async function loadRollup(page) {
const ctx = { title: 'MDL', columns: MDL_COLUMNS, rows: [], addable: false };
const ctxJson = JSON.stringify(ctx).replace(/<\//g, '<\\/');
const patched = HTML_RAW.replace(
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
`<script id="table-context" type="application/json">${ctxJson}</script>`,
);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-mdl-'));
const tmpPath = path.join(tmpDir, 'tables.html');
fs.writeFileSync(tmpPath, patched);
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
await page.waitForFunction(
() => window.tablesApp && window.tablesApp.modules && window.tablesApp.modules.mdlFromArchive,
);
}
test.describe('tables/ — Add deliverables from archive', () => {
test('identityFields() = columns between originator and title', async ({ page }) => {
await loadRollup(page);
const fields = await page.evaluate(() => window.tablesApp.modules.mdlFromArchive.identityFields());
expect(fields).toEqual(['phase', 'project', 'area', 'discipline', 'type', 'sequence', 'suffix']);
});
test('deliverableFromFile splits the tracking number, omits originator, keeps title', async ({ page }) => {
await loadRollup(page);
const d = await page.evaluate(() => {
const m = window.tablesApp.modules.mdlFromArchive;
return m.deliverableFromFile(
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001-X', title: 'Foundation Plan' },
m.identityFields(),
);
});
expect(d.originator).toBe('ACME');
expect(d.tracking).toBe('ACME-DD-PRJ-A1-CIV-DWG-001-X');
expect(d.body).toEqual({
phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV',
type: 'DWG', sequence: '001', suffix: 'X', title: 'Foundation Plan',
});
// originator must NOT be in the body (server pins it from the folder).
expect(d.body.originator).toBeUndefined();
});
test('a shorter tracking number leaves trailing identity fields unset', async ({ page }) => {
await loadRollup(page);
const d = await page.evaluate(() => {
const m = window.tablesApp.modules.mdlFromArchive;
// no suffix segment
return m.deliverableFromFile({ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: '' }, m.identityFields());
});
expect(d.body.sequence).toBe('001');
expect('suffix' in d.body).toBe(false);
});
test('dedupe collapses duplicate tracking numbers, dropping unsplittable rows', async ({ page }) => {
await loadRollup(page);
const out = await page.evaluate(() => {
const m = window.tablesApp.modules.mdlFromArchive;
return m.dedupe([
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a' },
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a-dup' },
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', title: 'b' },
{ tracking: 'NOPE', title: 'too short' },
], m.identityFields());
});
expect(out.map(d => d.tracking)).toEqual([
'ACME-DD-PRJ-A1-CIV-DWG-001', 'ACME-DD-PRJ-A1-CIV-DWG-002',
]);
expect(out[0].body.title).toBe('a'); // first wins
});
test('walkArchive collects valid document files, skipping mdl/rsk/dot/underscore dirs', async ({ page }) => {
await loadRollup(page);
const files = await page.evaluate(async () => {
// Mock FS-Access directory handles.
function dir(name, entries) {
return {
name, kind: 'directory', _entries: entries,
async *values() { for (const e of entries) yield e; },
async getDirectoryHandle(n) {
const e = entries.find(x => x.name === n && x.kind === 'directory');
if (!e) throw new DOMException('not found', 'NotFoundError');
return e;
},
};
}
const file = name => ({ name, kind: 'file' });
const root = dir('archive', [
dir('Acme', [
dir('issued', [
dir('2026-05-01_ACME-DD-PRJ-A1-CIV-DWG-001 (IFR) - Plan', [
file('ACME-DD-PRJ-A1-CIV-DWG-001_B (IFR) - Foundation Plan.pdf'),
file('not-a-zddc-file.txt'),
]),
]),
dir('mdl', [ file('ACME-DD-PRJ-A1-CIV-DWG-001.yaml') ]), // skipped
dir('rsk', [ file('whatever_A (IFA) - x.pdf') ]), // skipped
]),
dir('_system', [ file('ACME-DD-PRJ-A1-CIV-DWG-999_A (IFA) - hidden.pdf') ]), // skipped
]);
const out = await window.tablesApp.modules.mdlFromArchive.walkArchive(root);
return out.map(f => ({ tracking: f.tracking, party: f.party, slot: f.slot, rev: f.revision, title: f.title }));
});
expect(files).toEqual([
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', party: 'Acme', slot: 'issued', rev: 'B', title: 'Foundation Plan' },
]);
});
test('instantiateOne writes a yaml on create, skips when it already exists', async ({ page }) => {
await loadRollup(page);
const result = await page.evaluate(async () => {
const writes = [];
function fileHandle(name, exists) {
return {
name,
async createWritable() {
return {
async write(blob) { writes.push({ name, text: await blob.text() }); },
async close() {},
};
},
_exists: exists,
};
}
function mdlDir() {
const present = {}; // tracking.yaml already there
present['ACME-DD-PRJ-A1-CIV-DWG-002.yaml'] = true;
return {
async getFileHandle(n, opts) {
if (opts && opts.create) return fileHandle(n, false);
if (present[n]) return fileHandle(n, true);
throw new DOMException('nf', 'NotFoundError');
},
};
}
function originatorDir() {
return { async getDirectoryHandle() { return mdlDir(); } };
}
const archiveRoot = { async getDirectoryHandle() { return originatorDir(); } };
const m = window.tablesApp.modules.mdlFromArchive;
const created = await m.instantiateOne(archiveRoot, {
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', originator: 'ACME',
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '001', title: 'Plan' },
});
const skipped = await m.instantiateOne(archiveRoot, {
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', originator: 'ACME',
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '002', title: 'Plan2' },
});
return { created, skipped, writes };
});
expect(result.created).toBe('created');
expect(result.skipped).toBe('skipped');
expect(result.writes.length).toBe(1);
expect(result.writes[0].name).toBe('ACME-DD-PRJ-A1-CIV-DWG-001.yaml');
expect(result.writes[0].text).toContain('title: Plan');
expect(result.writes[0].text).toContain('discipline: CIV');
// originator must not be serialized into the body
expect(result.writes[0].text).not.toContain('originator:');
});
test('the "From archive" button stays hidden when not on an /mdl/ rollup path', async ({ page }) => {
await loadRollup(page);
// file:// path is not /mdl/, so setup() must not reveal the button.
const hidden = await page.evaluate(() => document.getElementById('table-add-from-archive').hidden);
expect(hidden).toBe(true);
});
});