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>
This commit is contained in:
ZDDC 2026-06-11 15:48:22 -05:00
parent d4d48cad4a
commit 95c9e42270
7 changed files with 437 additions and 0 deletions

View file

@ -95,6 +95,10 @@ export default defineConfig({
name: 'tables',
testMatch: 'tables.spec.js',
},
{
name: 'tables-mdl',
testMatch: 'tables-mdl.spec.js',
},
{
name: 'zddc-filter',
testMatch: 'zddc-filter.spec.js',

46
shared/seltable.css Normal file
View file

@ -0,0 +1,46 @@
/* Shared selectable + autofilter table (seltable) + its hosting overlay
Used by the tables tool's "Add from archive". The classifier carries an
equivalent copy inline in its layout.css for the catalog. */
.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; }
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
.seltable__colfilter {
width: 100%; min-width: 5rem; padding: 0.15rem 0.35rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
.mdlarch-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
}
.mdlarch-overlay__box {
display: flex; flex-direction: column; min-height: 0;
width: min(960px, 95vw); height: min(80vh, 760px);
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);
}
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
.mdlarch-overlay__close:hover { color: var(--text); }
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }

View file

@ -25,6 +25,7 @@ concat_files \
"../shared/profile-menu.css" \
"../shared/logo.css" \
"../shared/context-menu.css" \
"../shared/seltable.css" \
"css/table.css" \
"../form/css/form.css" \
> "$css_temp"
@ -46,6 +47,7 @@ concat_files \
"../shared/profile-menu.js" \
"../shared/cap.js" \
"../shared/context-menu.js" \
"../shared/seltable.js" \
"js/mode.js" \
"js/app.js" \
"js/context.js" \
@ -61,6 +63,7 @@ concat_files \
"js/export.js" \
"js/render.js" \
"js/api-actions.js" \
"js/mdl-from-archive.js" \
"js/main.js" \
"../form/js/app.js" \
"../form/js/context.js" \

View file

@ -167,6 +167,11 @@
}
}
// "Add from archive" — shown only on the project MDL rollup (own gating).
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
app.modules.mdlFromArchive.setup(ctx);
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];

View file

@ -0,0 +1,184 @@
// mdl-from-archive.js — "Add from archive" for the project MDL rollup.
//
// The MDL owns the workflow of registering deliverables; this is the catch-up
// path. On the project rollup (<project>/mdl/), walk the project archive into a
// shared seltable (autofilter + ctrl-shift selection), dedupe the selection to
// one deliverable per tracking number, and PUT a deliverable .yaml into each
// originator's archive/<originator>/mdl/. The body's identity fields are split
// from the tracking number positionally per the project's own table columns
// (originator is folder-pinned, so omitted); the server composes/validates the
// filename. Server-only.
(function (app) {
'use strict';
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function ctxObj() { return (app && app.context) || {}; }
// The tracking-number identity fields, in order, from the table columns:
// everything between `originator` and `title` (e.g. phase, project, area,
// discipline, type, sequence, suffix). originator is folder-pinned.
function identityFields() {
var cols = (ctxObj().columns || []).map(function (c) { return c && c.field; }).filter(Boolean);
var oi = cols.indexOf('originator'), ti = cols.indexOf('title');
return cols.slice(oi >= 0 ? oi + 1 : 0, ti >= 0 ? ti : cols.length);
}
// tracking → { tracking, originator, body{identity fields + title} }, or null
// if it can't supply the originator + at least one identity segment.
function deliverableFromFile(f, idFields) {
var segs = String(f.tracking || '').split('-');
if (segs.length < 2) return null;
var rest = segs.slice(1), body = {};
idFields.forEach(function (name, i) { if (rest[i] != null && rest[i] !== '') body[name] = rest[i]; });
if (!Object.keys(body).length) return null;
body.title = f.title || '';
return { tracking: f.tracking, originator: segs[0], body: body };
}
function dedupe(files, idFields) {
var seen = Object.create(null), out = [];
(files || []).forEach(function (f) {
if (seen[f.tracking]) return;
var d = deliverableFromFile(f, idFields);
if (d) { seen[f.tracking] = true; out.push(d); }
});
return out;
}
async function walkArchive(rootHandle) {
var out = [];
async function walk(dirH, parts) {
for await (var entry of dirH.values()) {
var nm = String(entry.name || '').replace(/\/$/, '');
if (entry.kind === 'directory') {
var c = nm.charAt(0);
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
await walk(await dirH.getDirectoryHandle(nm), parts.concat(nm));
} else {
var p = window.zddc.parseFilename(nm);
if (p && p.valid && p.trackingNumber) {
out.push({
id: parts.concat(nm).join('/'),
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
});
}
}
}
}
await walk(rootHandle, []);
return out;
}
async function instantiateOne(archiveRoot, d) {
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
dir = await dir.getDirectoryHandle('mdl', { create: true });
var fname = d.tracking + '.yaml';
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
var fh = await dir.getFileHandle(fname, { create: true });
var w = await fh.createWritable();
await w.write(new Blob([window.jsyaml.dump(d.body)], { type: 'application/yaml' }));
await w.close();
return 'created';
}
// ── UI ───────────────────────────────────────────────────────────────────
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
function archiveBaseUrl() {
var proj = (location.pathname || '/').replace(/\/mdl\/.*$/, '/'); // <project>/
return location.origin + proj + 'archive/';
}
async function open() {
var src = window.zddc && window.zddc.source;
if (!src || (location.protocol !== 'http:' && location.protocol !== 'https:')) {
T('Adding from the archive needs the tables page served by a zddc-server.', 'error'); return;
}
buildOverlay();
try {
archiveRoot = new src.HttpDirectoryHandle(archiveBaseUrl(), 'archive');
setStatus('Scanning archive…');
files = await walkArchive(archiveRoot);
table.renderBody();
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
} catch (e) { setStatus('Archive scan failed — ' + (e.message || e)); T('Archive scan failed — ' + (e.message || e), 'error'); }
}
function buildOverlay() {
close();
overlay = el('div', 'mdlarch-overlay');
var box = el('div', 'mdlarch-overlay__box');
var head = el('div', 'mdlarch-overlay__head');
head.appendChild(el('h2', null, 'Add deliverables from archive'));
var x = el('button', 'mdlarch-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
head.appendChild(x); box.appendChild(head);
statusEl = el('div', 'mdlarch-overlay__status', 'Scanning archive…'); box.appendChild(statusEl);
var host = el('div', 'mdlarch-overlay__table'); box.appendChild(host);
var foot = el('div', 'mdlarch-overlay__foot');
var create = el('button', 'btn btn-primary', 'Create deliverables');
create.addEventListener('click', function () { runCreate(create); });
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
foot.appendChild(create); foot.appendChild(cancel); box.appendChild(foot);
overlay.appendChild(box); document.body.appendChild(overlay);
table = window.app.modules.seltable.create({
container: host,
extraTitle: '',
rows: function () { return files; },
rowId: function (r) { return r.id; },
columns: [
{ key: 'party', title: 'Party' },
{ key: 'slot', title: 'Slot' },
{ key: 'transmittal', title: 'Transmittal' },
{ key: 'tracking', title: 'Tracking number' },
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
{ key: 'title', title: 'Title' },
],
});
table.render();
}
async function runCreate(btn) {
if (!table) return;
var sel = table.getSelection();
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
var picked = {}; sel.forEach(function (i) { picked[i] = true; });
var deliverables = dedupe(files.filter(function (f) { return picked[f.id]; }), identityFields());
if (!deliverables.length) { T('None of the selected files split into deliverable fields.', 'warning'); return; }
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\nOne .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
btn.disabled = true;
var s = { created: 0, skipped: 0, errors: 0 };
for (var i = 0; i < deliverables.length; i++) {
setStatus('Creating ' + (i + 1) + '/' + deliverables.length + ' — ' + deliverables[i].tracking);
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
}
btn.disabled = false;
setStatus(s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '.');
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '. Reload to see them.', s.errors ? 'warning' : 'success');
}
// Show the toolbar button only on the project MDL rollup (addable:false +
// an mdl path), over http, gated on create permission. Called from main.js
// init once the context is known.
function setup(ctx) {
var btn = document.getElementById('table-add-from-archive');
if (!btn) return;
var onHttp = location.protocol === 'http:' || location.protocol === 'https:';
var isMdlRollup = ctx && ctx.addable === false && /\/mdl\/(table\.html)?$/.test(location.pathname || '');
if (!(onHttp && isMdlRollup)) return;
btn.hidden = false;
btn.addEventListener('click', open);
if (window.zddc && window.zddc.cap) {
window.zddc.cap.at(archiveBaseUrl().replace(location.origin, '')).then(function (view) {
var verbs = (view && view.path_verbs) || '';
if (verbs.indexOf('c') === -1) { btn.classList.add('is-disabled'); btn.title = "You don't have create access in this project's archive."; }
});
}
}
app.modules.mdlFromArchive = {
setup: setup, open: open,
// test seams
identityFields: identityFields, deliverableFromFile: deliverableFromFile,
dedupe: dedupe, walkArchive: walkArchive, instantiateOne: instantiateOne,
};
})(window.tablesApp);

View file

@ -43,6 +43,7 @@
<div class="table-toolbar__right">
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<button type="button" id="table-add-from-archive" class="btn btn-secondary btn-sm" hidden title="Register deliverables from existing archive files (project MDL rollup)"> From archive</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div>

194
tests/tables-mdl.spec.js Normal file
View file

@ -0,0 +1,194 @@
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);
});
});