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('#bulk-edit-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('#bulk-edit-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('#bulk-edit-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('#bulk-edit-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 }); }); // The TOTAL/BALANCE percentage rows were dead code in the wizard (commented out, with a // duplicate id="total"); the migration implements them as a sibling- Rule-4 swap. test('TOTAL/BALANCE percentages render and recompute on edit (implemented)', async ({ page }) => { await navigateToInvoices(page); await selectFirstInvoice(page); await openBulkEditModal(page); // default row is 100% -> TOTAL 100.0% await expect(page.locator('#expense-totals')).toContainText('100.0%'); // edit to 50% -> the totals tbody refreshes via the targeted swap const pct = page.locator('#bulk-edit-form input.amount-field').first(); await pct.click(); await pct.fill(''); await pct.pressSequentially('50'); // keyup -> Rule-4 swap of #expense-totals await expect(page.locator('#expense-totals')).toContainText('50.0%', { timeout: 5000 }); }); 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%'); }); });