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>
194 lines
9.3 KiB
JavaScript
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);
|
|
});
|
|
});
|