ZDDC/tests/tables.spec.js
ZDDC 8e703dc61a feat(tables): editable cells phase 4 — copy/paste from Excel/Sheets
Bidirectional clipboard interop with Excel, Google Sheets, and any
other spreadsheet that uses RFC-4180-ish TSV on the text/plain
clipboard mime. Pasted cells write straight into the draft buffer
the same way per-key edits do; row-level save (Phase 3) picks them
up on the next row-blur with the same If-Match optimistic-
concurrency flow.

TSV parser (clipboard.js parseTSV):

- Tabs separate columns, \\n / \\r\\n separate rows.
- Quoted fields ("...") may contain tabs and newlines verbatim.
- Doubled \\"\\" inside a quoted field escapes a literal \\".
- Trailing empty row from a final \\n is dropped (Excel sends
  this; matching the convention avoids a phantom blank row at
  the end of every paste).

Apply-paste (clipboard.js applyPaste):

- Anchor = currently selected cell.
- 1×1 clipboard into selection → writes that one cell.
- N×M clipboard → SPILLS from the anchor down/right to
  (anchor.row + N - 1, anchor.col + M - 1). Cells past the end
  of either axis are silently dropped with a toast count.
- Each pasted value goes through coerceCell, which checks the
  column's row-schema property type:
    * number / integer → Number()
    * boolean          → "true"|"yes"|"1" → true; "false"|
                         "no"|"0"|""      → false
    * everything else  → raw string
  Drafts hold the right JS type so the row-PUT body matches the
  JSON Schema the server validates against.

Copy (clipboard.js onCopy):

- Single-cell selection: Ctrl/Cmd+C writes the cell's
  effectiveCellValue (draft if dirty, else stored) as text/plain
  via formatCell (RFC-4180 quoting on tab/newline/quote).
- Range copy is Phase 5 (depends on range-selection landing).

Event wiring:

- document.addEventListener('paste'/'copy') so events bubble
  from any cell with focus. Phase 1's roving tabindex moves
  focus around; per-cell binding would have to be re-applied
  after every paint.
- onPaste bails when an editor input is mounted (the input
  owns its own paste — typing into a cell editor that was just
  populated with a chunk of TSV would be a footgun).

Toast for partial pastes:

When applyPaste skipped any cells, a small message in
#table-status: "Pasted N cells; M dropped (out of bounds)".
Auto-clears after 4s. Coexists with Phase 3's stale-row prompt
(toast doesn't fire if a prompt is already up; prompt outranks
toast).

Tests (6 new Phase 4 specs, total 37 in tests/tables.spec.js):

- parseTSV handles tabs, newlines, and quoted fields — covers
  the parser edge cases including embedded \\n inside "..." and
  doubled "" escapes.
- paste single value into selected cell — the 1×1 path; verifies
  the draft buffer entry.
- paste 2×2 grid spills from anchor — the N×M spill semantic.
- paste coerces numeric/boolean values via row schema —
  verifies the draft holds typeof===number for an integer column
  and === true for a boolean column.
- paste out-of-bounds drops cells silently with toast — drives
  via dispatched ClipboardEvent('paste') (the only way to
  exercise onPaste end-to-end including the toast).
- copy single cell writes value to clipboard — synthesizes a
  ClipboardEvent('copy') with a writable DataTransfer payload
  and asserts the cell value lands in text/plain.

Bundle size: 134 KB → 138 KB.

Files:

- tables/js/clipboard.js (new) — parseTSV, formatTSV,
  applyPaste, onPaste/onCopy, toast helper.
- tables/build.sh — clipboard.js in concat list.
- zddc/internal/handler/tables.html — regenerated bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:30:05 -05:00

934 lines
39 KiB
JavaScript
Raw 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.

import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const HTML_PATH = path.resolve('tables/dist/tables.html');
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
const MDL_COLUMNS = [
{ field: 'id', title: 'ID', width: '6em' },
{ field: 'title', title: 'Deliverable' },
{ field: 'party', title: 'Party', enum: ['Acme', 'Beta', 'Gamma'] },
{ field: 'dueDate', title: 'Due', format: 'date' },
{ field: 'status', title: 'Status', enum: ['pending', 'submitted', 'accepted'] },
];
function makeRow(id, title, party, dueDate, status, editable = true) {
return {
url: `/Working/MDL/${id}.yaml.html`,
data: { id, title, party, dueDate, status },
editable,
};
}
const ROWS = [
makeRow('D-001', 'Site survey report', 'Acme', '2026-05-12', 'pending'),
makeRow('D-002', 'Foundation drawings A', 'Beta', '2026-05-20', 'submitted'),
makeRow('D-003', 'Procurement schedule', 'Acme', '2026-05-08', 'accepted'),
makeRow('D-004', 'Safety plan', 'Gamma', '2026-05-15', 'pending'),
makeRow('D-005', 'Geotechnical report', 'Beta', '2026-05-30', 'submitted'),
];
// Inject a complete table context into the page. Same pattern as
// form-safety.spec.js: write a patched copy of tables.html to a temp
// file and navigate via file://.
async function loadTableWithContext(page, context) {
const ctxJson = JSON.stringify(context).replace(/<\//g, '<\\/');
const replacement = `<script id="table-context" type="application/json">${ctxJson}</script>`;
const patched = HTML_RAW.replace(
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
replacement,
);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-spec-'));
const tmpPath = path.join(tmpDir, 'tables.html');
fs.writeFileSync(tmpPath, patched);
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
}
test.describe('tables/ — directory-of-YAML table view', () => {
test('renders header with column titles and rows from context', async ({ page }) => {
page.on('pageerror', e => console.log('[pageerror]', e.message));
await loadTableWithContext(page, {
title: 'Master Deliverables List',
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForFunction(
() => document.querySelector('#table-root tbody').children.length > 0,
null,
{ timeout: 5000 },
);
// Header cells.
const headers = page.locator('.zddc-table__title-row .zddc-table__th');
await expect(headers).toHaveCount(MDL_COLUMNS.length);
await expect(headers.nth(0)).toContainText('ID');
await expect(headers.nth(1)).toContainText('Deliverable');
// Title in the page header.
await expect(page.locator('#table-title')).toContainText('Master Deliverables List');
// Row count.
await expect(page.locator('#table-root tbody tr')).toHaveCount(ROWS.length);
await expect(page.locator('#table-rowcount')).toContainText(`${ROWS.length} rows`);
});
test('default sort puts dueDate ascending when configured', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
defaults: { sort: [{ field: 'dueDate', dir: 'asc' }] },
});
await page.waitForSelector('#table-root tbody tr');
const ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
// Sorted ascending by dueDate: D-003 (5/8), D-001 (5/12), D-004 (5/15), D-002 (5/20), D-005 (5/30).
expect(ids).toEqual(['D-003', 'D-001', 'D-004', 'D-002', 'D-005']);
});
test('clicking a column header sorts by that column and toggles direction', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Click the ID header → sort ascending.
await page.locator('.zddc-table__th[data-field="id"]').click();
let ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
expect(ids).toEqual(['D-001', 'D-002', 'D-003', 'D-004', 'D-005']);
// Click again → descending.
await page.locator('.zddc-table__th[data-field="id"]').click();
ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
expect(ids).toEqual(['D-005', 'D-004', 'D-003', 'D-002', 'D-001']);
});
test('free-text filter narrows visible rows', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Type "report" in the title column's filter — should match the
// two rows whose title contains "report" (Site survey report,
// Geotechnical report).
const titleFilter = page.locator('.zddc-table__th[data-field="title"]')
.locator('..')
.locator('xpath=following-sibling::tr[1]')
.locator('input[type="text"]')
.nth(0);
// Simpler selector: nth filter input under the filter row.
const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
await filterInputs.nth(1).fill('report'); // index 1 = title column
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
});
test('text filter on an enum column does substring match', async ({ page }) => {
// Filter row is uniformly text-contains across all columns —
// even for columns declared with `enum:` in the spec. Enum
// metadata still informs validation/sort but not filter UI.
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
// Spec columns: 0=id, 1=title, 2=party, 3=dueDate, 4=status.
const statusInput = filterInputs.nth(4);
await statusInput.fill('pending');
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
});
test('clicking a cell selects it (Phase 1 — replaces row-click navigation)', async ({ page }) => {
// Single click → cell selection. Row navigation moves to a
// dedicated affordance in Phase 2 (open-in-form button) so the
// primary click action can be the spreadsheet-native one.
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Stub navigate seam — verifies single-click does NOT navigate.
await page.evaluate(() => {
window.__navTarget = null;
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
});
// Click a specific cell.
const firstCell = page.locator('#table-root tbody tr').first().locator('[role="gridcell"]').first();
await firstCell.click();
await expect(firstCell).toHaveClass(/zddc-table__cell--selected/);
await expect(firstCell).toHaveAttribute('tabindex', '0');
await expect(page.evaluate(() => window.__navTarget)).resolves.toBeNull();
});
test('arrow keys move cell selection (ARIA grid)', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Click to seed selection at (0,0), then arrow around.
const r0c0 = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(0);
await r0c0.click();
await expect(r0c0).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowDown');
const r1c0 = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(0);
await expect(r1c0).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowRight');
const r1c1 = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(1);
await expect(r1c1).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowUp');
const r0c1 = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await expect(r0c1).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowLeft');
await expect(r0c0).toHaveClass(/zddc-table__cell--selected/);
});
test('Tab and Shift-Tab traverse cells with row-wrap', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const numCols = MDL_COLUMNS.length;
// Start at last column of row 0.
const r0Last = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(numCols - 1);
await r0Last.click();
await expect(r0Last).toHaveClass(/zddc-table__cell--selected/);
// Tab → first cell of row 1 (wrap).
await page.keyboard.press('Tab');
const r1First = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(0);
await expect(r1First).toHaveClass(/zddc-table__cell--selected/);
// Shift+Tab → back to last cell of row 0.
await page.keyboard.press('Shift+Tab');
await expect(r0Last).toHaveClass(/zddc-table__cell--selected/);
});
test('Enter enters edit mode; Enter commits and moves down', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Edit the title cell (column index 1) of row 0.
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await titleCell.click();
await page.keyboard.press('Enter');
// Editor input mounted inside the cell.
const input = titleCell.locator('input.zddc-table__cell-input');
await expect(input).toBeVisible();
await expect(input).toBeFocused();
// Type new value, press Enter to commit + move down.
await page.keyboard.press('Control+a');
await page.keyboard.type('New title via cell editor');
await page.keyboard.press('Enter');
// Cell shows new value, input gone.
await expect(titleCell).toContainText('New title via cell editor');
await expect(titleCell.locator('input')).toHaveCount(0);
// Selection moved down one row, same column.
const r1Title = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(1);
await expect(r1Title).toHaveClass(/zddc-table__cell--selected/);
});
test('Escape cancels edit, restoring prior value', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
const originalText = await titleCell.textContent();
await titleCell.click();
await page.keyboard.press('Enter');
await page.keyboard.press('Control+a');
await page.keyboard.type('Should not stick');
await page.keyboard.press('Escape');
// Value restored to original; no draft entry.
await expect(titleCell).toHaveText(originalText.trim());
const draftCount = await page.evaluate(() =>
Object.keys(window.tablesApp.state.drafts).length);
expect(draftCount).toBe(0);
});
test('typing a printable char enters edit and replaces value', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await titleCell.click();
// Press a printable character — should enter edit mode with
// that char as the new value.
await page.keyboard.press('X');
const input = titleCell.locator('input.zddc-table__cell-input');
await expect(input).toBeVisible();
await expect(input).toHaveValue('X');
});
test('double-click also enters edit mode', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await titleCell.dblclick();
await expect(titleCell.locator('input.zddc-table__cell-input')).toBeVisible();
});
test('non-editable rows still get the readonly class', async ({ page }) => {
// Cosmetic guard for an existing convention: rows where the
// server says editable=false get a visual treatment. Cell
// selection still works in Phase 1; Phase 3 will gate writes
// on the editable flag at save time.
const readOnlyRows = ROWS.map(r => ({ ...r, editable: false }));
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: readOnlyRows,
});
await page.waitForSelector('#table-root tbody tr');
await expect(page.locator('#table-root tbody tr.zddc-table__row--editable')).toHaveCount(0);
await expect(page.locator('#table-root tbody tr.zddc-table__row--readonly')).toHaveCount(ROWS.length);
});
test('default filters seed the visible row count from defaults.filter', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
defaults: { filter: { status: ['pending'] } },
});
await page.waitForSelector('#table-root tbody tr');
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
});
test('empty rows list shows the empty-state notice', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: [],
});
// Wait briefly for init.
await page.waitForTimeout(50);
await expect(page.locator('#table-root tbody tr')).toHaveCount(0);
await expect(page.locator('#table-empty')).toBeHidden();
});
// --- Phase 2: schema-driven cell editor widgets -----------------------
// A small JSON Schema covering the inline-editable types the
// factory recognises: string, integer with min/max, boolean,
// string-enum, format:date, array<enum>+uniqueItems.
const ROW_SCHEMA = {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
party: { type: 'string', enum: ['Acme', 'Beta', 'Gamma'] },
dueDate: { type: 'string', format: 'date' },
status: { type: 'string', enum: ['pending', 'submitted', 'accepted'] },
priority: { type: 'integer', minimum: 1, maximum: 5 },
done: { type: 'boolean' },
tags: {
type: 'array',
uniqueItems: true,
items: { type: 'string', enum: ['blue', 'green', 'red'] },
},
// A nested-object cell — should punt to navigation rather
// than mount an inline editor.
owner: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
},
},
};
const SCHEMA_COLUMNS = [
{ field: 'id', title: 'ID', width: '6em' },
{ field: 'title', title: 'Title' },
{ field: 'party', title: 'Party' },
{ field: 'dueDate', title: 'Due', format: 'date' },
{ field: 'status', title: 'Status' },
{ field: 'priority', title: 'Priority' },
{ field: 'done', title: 'Done' },
{ field: 'tags', title: 'Tags' },
{ field: 'owner', title: 'Owner' },
];
function makeSchemaRow(over) {
return {
url: `/Working/MDL/${over.id || 'D-001'}.yaml.html`,
data: Object.assign({
id: 'D-001', title: 'Sample', party: 'Acme', dueDate: '2026-05-12',
status: 'pending', priority: 3, done: false, tags: ['blue'],
owner: { name: 'Casey', email: 'c@example.com' },
}, over.data || {}),
editable: true,
};
}
const SCHEMA_ROWS = [makeSchemaRow({ id: 'D-001' }), makeSchemaRow({ id: 'D-002' })];
function colIdx(field) {
return SCHEMA_COLUMNS.findIndex(c => c.field === field);
}
test('Phase 2: enum column edits via select dropdown', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS,
rows: SCHEMA_ROWS,
rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const partyCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('party'));
await partyCell.dblclick();
const select = partyCell.locator('select.zddc-table__cell-input');
await expect(select).toBeVisible();
// Empty placeholder + 3 enum options.
await expect(select.locator('option')).toHaveCount(4);
await select.selectOption('Beta');
await page.keyboard.press('Enter');
await expect(partyCell).toContainText('Beta');
});
test('Phase 2: integer column gives a number input with min/max', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS,
rows: SCHEMA_ROWS,
rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const cell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('priority'));
await cell.dblclick();
const input = cell.locator('input.zddc-table__cell-input');
await expect(input).toHaveAttribute('type', 'number');
await expect(input).toHaveAttribute('min', '1');
await expect(input).toHaveAttribute('max', '5');
await expect(input).toHaveAttribute('step', '1');
await input.fill('4');
await page.keyboard.press('Enter');
await expect(cell).toContainText('4');
// Draft holds a Number, not a string.
const draftType = await page.evaluate(() => {
const drafts = window.tablesApp.state.drafts;
const rowId = Object.keys(drafts)[0];
return typeof drafts[rowId].priority;
});
expect(draftType).toBe('number');
});
test('Phase 2: boolean column gives a checkbox', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS,
rows: SCHEMA_ROWS,
rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const cell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('done'));
await cell.dblclick();
const cb = cell.locator('input.zddc-table__cell-input');
await expect(cb).toHaveAttribute('type', 'checkbox');
// Toggle via Space (the keyboard contract a screen-reader user
// would use). Avoids the click+blur race that Playwright's
// .check() helper hits on a focused-checkbox-inside-grid-cell.
await page.keyboard.press('Space');
await page.keyboard.press('Enter');
const draftValue = await page.evaluate(() => {
const drafts = window.tablesApp.state.drafts;
const rowId = Object.keys(drafts)[0];
return drafts[rowId].done;
});
expect(draftValue).toBe(true);
});
test('Phase 2: format:date column gives a date input', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS,
rows: SCHEMA_ROWS,
rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const cell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('dueDate'));
await cell.dblclick();
const input = cell.locator('input.zddc-table__cell-input');
await expect(input).toHaveAttribute('type', 'date');
await expect(input).toHaveValue('2026-05-12');
});
test('Phase 2: multi-select enum-array column gives a multi-select', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS,
rows: SCHEMA_ROWS,
rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const cell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('tags'));
await cell.dblclick();
const select = cell.locator('select.zddc-table__cell-input');
await expect(select).toBeVisible();
await expect(select).toHaveAttribute('multiple', '');
});
test('Phase 2: complex (object) column navigates to the row form on edit', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS,
rows: SCHEMA_ROWS,
rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
// Stub navigation seam — see how the editor punts to the
// form for inline-uneditable types.
await page.evaluate(() => {
window.__navTarget = null;
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
});
const cell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('owner'));
await cell.dblclick();
// No inline editor mounted.
await expect(cell.locator('.zddc-table__cell-input')).toHaveCount(0);
const target = await page.evaluate(() => window.__navTarget);
expect(target).toContain('.yaml.html');
});
test('Phase 2: no rowSchema → falls back to plain text editor', async ({ page }) => {
await loadTableWithContext(page, {
// No rowSchema in the context — same as a directory with
// table.yaml but no form.yaml.
columns: SCHEMA_COLUMNS,
rows: SCHEMA_ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const cell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('party'));
await cell.dblclick();
// Default text input — even for a column that COULD have been
// an enum dropdown if the schema had been provided.
const input = cell.locator('input.zddc-table__cell-input');
await expect(input).toHaveAttribute('type', 'text');
});
// --- Phase 3: row-level save + ETag conflict UX -----------------------
// Loading the page via file:// means PUTs go to file:// URLs — no
// network. We intercept via page.route on the parent http://* URL
// by hosting the test fixture rows with absolute http URLs in their
// yamlUrl field.
function makeNetRow(over) {
const id = over.id || 'D-001';
return {
url: `http://test.local/Working/MDL/${id}.yaml.html`,
yamlUrl: `http://test.local/Working/MDL/${id}.yaml`,
data: Object.assign({
id, title: 'Sample', party: 'Acme', dueDate: '2026-05-12',
status: 'pending', priority: 3, done: false, tags: ['blue'],
owner: { name: 'Casey', email: 'c@example.com' },
}, over.data || {}),
etag: over.etag || 'v1',
editable: true,
};
}
async function setupSaveCapture(page) {
// Intercept PUTs on test.local. Tests configure responses by
// pushing into window.__saveResponses; tests inspect requests
// via window.__savePuts.
await page.route('http://test.local/**', async (route) => {
const req = route.request();
const method = req.method();
if (method !== 'PUT' && method !== 'GET') {
await route.continue();
return;
}
await page.evaluate(({ url, method, body, headers }) => {
window.__capturedRequests = window.__capturedRequests || [];
window.__capturedRequests.push({ url, method, body, headers });
}, {
url: req.url(),
method,
body: req.postData(),
headers: req.headers(),
});
const queued = await page.evaluate(() => window.__nextResponse || null);
if (queued) {
await page.evaluate(() => { window.__nextResponse = null; });
await route.fulfill({
status: queued.status,
headers: queued.headers || {},
body: queued.body || '',
});
return;
}
// Default success.
await route.fulfill({
status: 200,
headers: { 'ETag': '"v2"' },
body: '',
});
});
}
test('Phase 3: row-blur fires PUT with merged drafts + If-Match', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => { window.__capturedRequests = []; });
// Edit a cell in row 0, then click row 1 to move selection
// (row-blur trigger).
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('Edited title');
await page.keyboard.press('Enter');
// Enter committed + moved selection down to row 1 — that IS
// the row-blur. Wait briefly for the async fetch to fire.
await page.waitForFunction(() =>
(window.__capturedRequests || []).some(r => r.method === 'PUT'),
null, { timeout: 3000 });
const puts = await page.evaluate(() =>
(window.__capturedRequests || []).filter(r => r.method === 'PUT'));
expect(puts).toHaveLength(1);
expect(puts[0].url).toBe('http://test.local/Working/MDL/D-001.yaml');
expect(puts[0].body).toContain('Edited title');
expect(puts[0].body).toContain('id: D-001');
// If-Match present.
expect(puts[0].headers['if-match']).toBe('"v1"');
});
test('Phase 3: 412 conflict marks row stale + shows status prompt', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => { window.__nextResponse = { status: 412 }; });
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('My change');
await page.keyboard.press('Enter');
// Wait for the row to gain the stale class.
const row0 = page.locator('#table-root tbody tr').nth(0);
await expect(row0).toHaveClass(/zddc-table__row--stale/);
// Status prompt visible with both buttons.
const status = page.locator('#table-status');
await expect(status).toBeVisible();
await expect(status).toContainText('changed by someone else');
await expect(status.locator('button', { hasText: 'Use mine' })).toBeVisible();
await expect(status.locator('button', { hasText: 'Reload' })).toBeVisible();
// Drafts must STILL exist — never silently discard.
const draftCount = await page.evaluate(() =>
Object.keys(window.tablesApp.state.drafts).length);
expect(draftCount).toBe(1);
});
test('Phase 3: 422 validation errors mark cells invalid', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => {
window.__nextResponse = {
status: 422,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
errors: [
{ path: '/priority', message: 'must be ≤ 5' },
{ path: '/title', message: 'required' },
],
}),
};
});
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('x');
await page.keyboard.press('Enter');
// Enter committed + moved selection to row 1 → row-blur on row 0
// → PUT fires → 422 → cells marked.
await page.waitForFunction(() =>
document.querySelector('.zddc-table__cell--invalid'),
null, { timeout: 3000 });
const invalidCells = await page.locator('.zddc-table__cell--invalid').count();
expect(invalidCells).toBe(2);
});
// --- Phase 4: copy/paste from Excel/Sheets (TSV) ---------------------
test('Phase 4: parseTSV handles tabs, newlines, and quoted fields', async ({ page }) => {
// Unit-style test of the parser via the exposed clipboard module.
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const result = await page.evaluate(() => {
const p = window.tablesApp.modules.clipboard.parseTSV;
return [
p('a\tb\tc'),
p('a\tb\nc\td'),
p('a\t"line1\nline2"\tc'),
p('"with ""quotes"""\tplain'),
p('a\r\nb\r\nc'),
p(''),
];
});
expect(result[0]).toEqual([['a', 'b', 'c']]);
expect(result[1]).toEqual([['a', 'b'], ['c', 'd']]);
expect(result[2]).toEqual([['a', 'line1\nline2', 'c']]);
expect(result[3]).toEqual([['with "quotes"', 'plain']]);
expect(result[4]).toEqual([['a'], ['b'], ['c']]);
expect(result[5]).toEqual([]);
});
test('Phase 4: paste single value into selected cell', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.click();
// Drive applyPaste directly (Playwright's clipboard event
// simulation is browser-flag-gated; calling the module is the
// honest unit-test path).
await page.evaluate(() => {
const sel = window.tablesApp.state.selected;
window.tablesApp.modules.clipboard.applyPaste(
sel.row, sel.col,
[['Pasted single value']],
);
window.tablesApp.repaint();
});
await expect(titleCell).toContainText('Pasted single value');
// Draft buffer holds it under the right field.
const draftValue = await page.evaluate(() => {
const drafts = window.tablesApp.state.drafts;
const rowId = Object.keys(drafts)[0];
return drafts[rowId].title;
});
expect(draftValue).toBe('Pasted single value');
});
test('Phase 4: paste 2x2 grid spills from anchor', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
// Anchor at row 0, title column.
const anchor = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await anchor.click();
await page.evaluate(() => {
const sel = window.tablesApp.state.selected;
window.tablesApp.modules.clipboard.applyPaste(
sel.row, sel.col,
[
['t-r0', 'Acme'],
['t-r1', 'Beta'],
],
);
window.tablesApp.repaint();
});
// Row 0: title=t-r0, party=Acme.
await expect(
page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(colIdx('title'))
).toContainText('t-r0');
await expect(
page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(colIdx('party'))
).toContainText('Acme');
// Row 1: title=t-r1, party=Beta.
await expect(
page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(colIdx('title'))
).toContainText('t-r1');
await expect(
page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(colIdx('party'))
).toContainText('Beta');
});
test('Phase 4: paste coerces numeric/boolean values via row schema', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const priorityCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('priority'));
await priorityCell.click();
await page.evaluate(() => {
const sel = window.tablesApp.state.selected;
window.tablesApp.modules.clipboard.applyPaste(
sel.row, sel.col,
[['4', 'true']], // priority + done
);
window.tablesApp.repaint();
});
const drafts = await page.evaluate(() => {
const d = window.tablesApp.state.drafts;
const rowId = Object.keys(d)[0];
return d[rowId];
});
expect(drafts.priority).toBe(4);
expect(typeof drafts.priority).toBe('number');
expect(drafts.done).toBe(true);
});
test('Phase 4: paste out-of-bounds drops cells silently with toast', async ({ page }) => {
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const lastRowLastCol = page.locator('#table-root tbody tr').nth(1)
.locator('[role="gridcell"]').last();
await lastRowLastCol.click();
// Dispatch a real paste event so onPaste runs (which writes
// the toast). 3-row × 2-col TSV anchored at the last cell —
// every cell spills past the end of either rows or columns.
await page.evaluate(() => {
const dt = new DataTransfer();
dt.setData('text/plain', 'x1\ty1\nx2\ty2\nx3\ty3');
const ev = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(ev);
});
await expect(page.locator('#table-status')).toContainText('dropped');
});
test('Phase 4: copy single cell writes value to clipboard', async ({ page, context }) => {
// Granting clipboard-read makes Chromium permit synthetic copy.
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.click();
// Synthesize a copy event with a writable DataTransfer-like
// payload; assert the handler wrote the cell value into it.
const written = await page.evaluate(() => {
const ev = new ClipboardEvent('copy', {
clipboardData: new DataTransfer(),
bubbles: true,
cancelable: true,
});
document.dispatchEvent(ev);
return ev.clipboardData.getData('text/plain');
});
expect(written).toBe('Sample');
});
test('Phase 3: Reload button drops drafts and refreshes', async ({ page }) => {
await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
await loadTableWithContext(page, {
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => { window.__nextResponse = { status: 412 }; });
const titleCell = page.locator('#table-root tbody tr').nth(0)
.locator('[role="gridcell"]').nth(colIdx('title'));
await titleCell.dblclick();
await page.keyboard.press('Control+a');
await page.keyboard.type('My change');
await page.keyboard.press('Enter');
const status = page.locator('#table-status');
await expect(status).toBeVisible();
// Queue a fresh GET response with new ETag.
await page.evaluate(() => {
window.__nextResponse = {
status: 200,
headers: { 'ETag': '"v3"', 'Content-Type': 'application/yaml' },
body: 'id: D-001\ntitle: Server-side new title\nparty: Acme\ndueDate: 2026-05-12\nstatus: pending\npriority: 3\ndone: false\ntags:\n - blue\nowner:\n name: Casey\n email: c@example.com\n',
};
});
await status.locator('button', { hasText: 'Reload' }).click();
await expect(status).toBeHidden();
// Drafts cleared.
const draftCount = await page.evaluate(() =>
Object.keys(window.tablesApp.state.drafts).length);
expect(draftCount).toBe(0);
});
});