From 413991903625d9b68e2db225fe930b504ab1e1cc Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 24 Jun 2026 22:50:44 -0700 Subject: [PATCH] =?UTF-8?q?test(ssr):=20Phase=205=20parity=20gate=20?= =?UTF-8?q?=E2=80=94=20characterization=20spec=20for=20Invoice=20Bulk=20Ed?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-parity safety net before migrating the Invoice Bulk Edit modal off the wizard. The modal had no e2e coverage; the existing seeded invoice is bulk-editable as-is, so no seed change was needed (avoids interfering with the transaction-link spec). e2e/invoice-bulk-edit.spec.ts (4 tests): open the modal (expense-account grid with Account/Location/%/TOTAL/BALANCE + a default row + New account), add an account row, save a 100% coding (modalclose), and the percentage-validation rejection. Models the bulk-code-transactions spec. Full Playwright suite 50/50 (46 prior + 4 new). Co-Authored-By: Claude Opus 4.8 --- e2e/invoice-bulk-edit.spec.ts | 128 ++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 e2e/invoice-bulk-edit.spec.ts diff --git a/e2e/invoice-bulk-edit.spec.ts b/e2e/invoice-bulk-edit.spec.ts new file mode 100644 index 00000000..444cc2f3 --- /dev/null +++ b/e2e/invoice-bulk-edit.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; + +// Characterization spec for the Invoice Bulk Edit modal. Captures CURRENT +// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven +// behavior-preserving. Reset the shared dataset before each test for isolation. +test.beforeEach(async ({ request }) => { + await request.post('/test-reset'); +}); + +async function getTestInfo(page: any) { + return (await page.request.get('/test-info')).json(); +} + +async function navigateToInvoices(page: any) { + await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' }); + await page.goto('/invoice'); + await page.waitForSelector('#entity-table tbody tr'); +} + +async function selectFirstInvoice(page: any) { + await page.locator('#entity-table tbody input[type="checkbox"]').first().click(); + await page.waitForTimeout(200); +} + +async function openBulkEditModal(page: any) { + await page.locator('button:has-text("Bulk Edit")').first().click(); + await page.waitForSelector('#wizardmodal'); +} + +async function addNewAccount(page: any) { + await page.locator('a:has-text("New account")').first().click(); + await page.waitForTimeout(500); +} + +// Set an account on a row by replacing its Alpine-managed hidden input with a plain +// one (Solr-backed typeahead is unavailable in tests), then dispatching the location +// reload -- the same approach the bulk-code spec uses. +async function setRowAccount(page: any, rowIndex: number, accountId: string) { + const rows = page.locator('#wizard-form tbody tr'); + const row = rows.nth(rowIndex); + const hidden = row.locator('input[type="hidden"][name*="[account]"]').first(); + await hidden.evaluate((el: HTMLInputElement, value: string) => { + const n = document.createElement('input'); + n.type = 'hidden'; n.name = el.name; n.value = value; + el.parentNode!.replaceChild(n, el); + }, accountId); + await page.waitForTimeout(200); + const loc = row.locator('[x-dispatch\\:changed]').first(); + if (await loc.count() > 0) { + await loc.evaluate((el: HTMLElement) => el.dispatchEvent(new CustomEvent('changed', { bubbles: true }))); + await page.waitForTimeout(400); + } +} + +async function setRowPercentage(page: any, rowIndex: number, pct: string) { + const row = page.locator('#wizard-form tbody tr').nth(rowIndex); + const input = row.locator('input.amount-field, input[name*="percentage"]').first(); + await input.fill(pct); + await input.dispatchEvent('change'); + await page.waitForTimeout(200); +} + +async function submitForm(page: any) { + await page.locator('#wizard-form').evaluate((f: HTMLFormElement) => + f.dispatchEvent(new Event('submit', { bubbles: true }))); +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Invoice Bulk Edit (characterization)', () => { + test('opens the modal with the expense-account grid', async ({ page }) => { + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + const modal = page.locator('#wizardmodal'); + await expect(modal).toContainText('Bulk editing 1 invoices'); + await expect(modal).toContainText('Account'); + await expect(modal).toContainText('Location'); + await expect(modal).toContainText('TOTAL'); + await expect(modal).toContainText('BALANCE'); + // a default expense-account row is present, plus the New account button + expect(await modal.locator('input[name*="[expense-accounts]"][name*="[account]"]').count()).toBeGreaterThanOrEqual(1); + await expect(modal.locator('a:has-text("New account")')).toBeVisible(); + }); + + test('New account adds an expense-account row', async ({ page }) => { + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + const accountRows = () => page.locator('#wizard-form input[name*="[expense-accounts]"][name*="[account]"]'); + const before = await accountRows().count(); + await addNewAccount(page); + expect(await accountRows().count()).toBe(before + 1); + }); + + test('saving a 100% account coding closes the modal', async ({ page }) => { + const info = await getTestInfo(page); + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + // the default row is at 100% already; set its account and save + await setRowAccount(page, 0, info.accounts['test-account'].toString()); + await setRowPercentage(page, 0, '100'); + await submitForm(page); + + // a successful save fires modalclose -> the modal closes + await expect(page.locator('#wizardmodal')).toBeHidden({ timeout: 8000 }); + }); + + test('rejects when account percentages do not total 100%', async ({ page }) => { + const info = await getTestInfo(page); + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + await setRowAccount(page, 0, info.accounts['test-account'].toString()); + await setRowPercentage(page, 0, '50'); + await submitForm(page); + await page.waitForTimeout(1000); + + // modal stays open on validation failure + await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); + await expect(page.locator('#wizardmodal')).toContainText('does not equal 100%'); + }); +});