test(ssr): Phase 4 parity gate — seed + characterization spec for Sales Summary edit

Establishes the behavior-parity safety net required before migrating the POS
Sales Summary edit modal off the wizard (the modal had zero test coverage and the
test server seeded no POS data).

- test_server.clj: seed a balanced sales summary ($500 credit = $500 debit) with
  two auto items referencing the existing test client + accounts; surface its id
  via /test-info (`salesSummaryId`).
- e2e/sales-summary-edit.spec.ts: characterization spec (6 tests) capturing current
  behavior — open modal (debit/credit columns, categories, resolved account names,
  amounts), balanced state, inline account editor (pencil -> typeahead editor ->
  cancel restores / save re-renders the cell), and Save (PUT round-trip closes the
  modal + keeps the grid row). Exercises the edit-wizard, edit/save/cancel-item-account,
  and edit-wizard-submit routes.

Notable finding: the "New Summary Item" button is currently BROKEN (its Alpine
handler throws "newRowIndex is not defined" and hx-target="closest .new-row"
matches no ancestor, so the new-summary-item route never fires). The spec documents
this as inert rather than asserting it works; the migration will decide fix-vs-preserve.

Full Playwright suite 45/45 (39 prior + 6 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 21:15:28 -07:00
parent 03620e9d42
commit a289ff2557
2 changed files with 149 additions and 1 deletions

View File

@@ -0,0 +1,126 @@
import { test, expect } from '@playwright/test';
// Characterization spec for the POS Sales Summary 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 openEditModal(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto('/pos/summaries');
await page.waitForSelector('#entity-table tbody tr');
// The row's edit button is an hx-get to /pos/summaries/<id> (the edit-wizard route).
await page.locator('#entity-table tbody tr').first()
.locator('a[hx-get], button[hx-get]').first().click();
await page.waitForSelector('#wizardmodal');
}
test.describe.configure({ mode: 'serial' });
test.describe('Sales Summary Edit (characterization)', () => {
test('opens the edit modal with debit/credit columns, categories, accounts and amounts', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
await expect(modal).toContainText('Edit Summary');
await expect(modal).toContainText('Debits');
await expect(modal).toContainText('Credits');
// seeded items
await expect(modal).toContainText('Cash Deposit'); // debit item category
await expect(modal).toContainText('Food Sales'); // credit item category
// resolved account names (account-display-cell pulls the account name)
await expect(modal).toContainText('Second Account'); // debit item account
await expect(modal).toContainText('Test Account'); // credit item account
// amounts render
await expect(modal).toContainText('$500.00');
// two account cells, each with an inline-edit pencil
expect(await modal.locator('.account-cell').count()).toBe(2);
expect(await modal.locator('[hx-get*="edit/item-account"]').count()).toBe(2);
});
test('seeded summary is balanced (no out-of-balance indicator)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
// balanced: $500 debit == $500 credit, so no unbalanced warning text
await expect(modal).not.toContainText('Out of balance');
await expect(modal).not.toContainText('Unbalanced');
});
test('inline account edit: pencil opens the typeahead editor; cancel restores the display', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
// The debit row shows "Second Account" with a pencil. Click it -> account-edit-cell.
const debitCell = modal.locator('.account-cell', { hasText: 'Second Account' }).first();
await debitCell.locator('[hx-get*="edit/item-account"]').click();
// edit cell: a typeahead plus check (save) + cancel buttons
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
await expect(editCell.locator('[hx-get*="cancel-item-account"]')).toBeVisible();
// Cancel -> back to display mode showing the original account
await editCell.locator('[hx-get*="cancel-item-account"]').click();
await page.waitForTimeout(300);
await expect(modal.locator('.account-cell', { hasText: 'Second Account' }).first()).toBeVisible();
// back in display mode: the pencil (edit) is shown again
await expect(modal.locator('.account-cell', { hasText: 'Second Account' })
.first().locator('[hx-get*="edit/item-account"]')).toBeVisible();
});
test('inline account edit: save (check) re-renders the account display cell', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
const creditCell = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
await creditCell.locator('[hx-get*="edit/item-account"]').click();
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
// Save without changing -> display cell re-renders, account preserved, pencil back.
await editCell.locator('[hx-put*="save-item-account"]').click();
await page.waitForTimeout(300);
const display = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
await expect(display).toBeVisible();
await expect(display.locator('[hx-get*="edit/item-account"]')).toBeVisible();
});
// NOTE: the "New Summary Item" button is currently BROKEN in this modal and is
// therefore intentionally not characterized as working. Its Alpine handler
// (`@click="$dispatch('newRow', {index: (newRowIndex++)})"`) throws
// "newRowIndex is not defined" (the modal's x-data is empty), and even if it
// fired, `hx-target="closest .new-row"` matches no ancestor in this div layout,
// so the `new-summary-item` route never fires. We assert the *current* behavior:
// the button is present but adds nothing. The migration may fix or preserve this.
test('New Summary Item button is present but currently inert (documents the break)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
const before = await modal.locator('input').count();
await modal.locator('[hx-get*="sales-summary-item"]').first().click();
await page.waitForTimeout(500);
// no manual row was added (no category input appeared, input count unchanged)
expect(await modal.locator('input[placeholder="Category/Explanation"]').count()).toBe(0);
expect(await modal.locator('input').count()).toBe(before);
});
test('Save closes the modal and the summary stays in the grid', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
// submit the form via the Save button; the PUT swaps the grid row + fires modalclose
const putResp = page.waitForResponse(r =>
r.url().includes('/pos/summaries') && r.request().method() === 'PUT');
await modal.locator('button[type="submit"]').click();
expect((await putResp).status()).toBe(200);
// modalclose hides the modal (it is hidden, not removed from the DOM)
await expect(modal).toBeHidden({ timeout: 5000 });
// the grid still shows the summary row
await expect(page.locator('#entity-table tbody tr')).toHaveCount(1);
});
});

View File

@@ -29,6 +29,7 @@
(def test-transaction-id (atom nil)) (def test-transaction-id (atom nil))
(def test-account-ids (atom {})) (def test-account-ids (atom {}))
(def test-client-ids (atom {})) (def test-client-ids (atom {}))
(def test-sales-summary-id (atom nil))
(defn admin-identity [] (defn admin-identity []
(case @test-identity-mode (case @test-identity-mode
@@ -160,7 +161,26 @@
:invoice/invoice-number "UNPAID-001" :invoice/invoice-number "UNPAID-001"
:invoice/expense-accounts [{:invoice-expense-account/account "account-id" :invoice/expense-accounts [{:invoice-expense-account/account "account-id"
:invoice-expense-account/amount 150.0 :invoice-expense-account/amount 150.0
:invoice-expense-account/location "DT"}])]) :invoice-expense-account/location "DT"}])
;; Sales summary for the POS sales-summary edit modal e2e
;; (balanced: $500 credit = $500 debit).
{:db/id "sales-summary-id"
:sales-summary/client "client-id"
:sales-summary/date #inst "2026-06-20T00:00:00Z"
:sales-summary/items [{:db/id "ss-item-credit"
:sales-summary-item/category "Food Sales"
:sales-summary-item/sort-order 0
:sales-summary-item/manual? false
:ledger-mapped/ledger-side :ledger-side/credit
:ledger-mapped/amount 500.0
:ledger-mapped/account "account-id"}
{:db/id "ss-item-debit"
:sales-summary-item/category "Cash Deposit"
:sales-summary-item/sort-order 1
:sales-summary-item/manual? false
:ledger-mapped/ledger-side :ledger-side/debit
:ledger-mapped/amount 500.0
:ledger-mapped/account "account-id-2"}]}])
tempids (:tempids tx-result) tempids (:tempids tx-result)
tx-entity-id (get tempids "transaction-id")] tx-entity-id (get tempids "transaction-id")]
(println "Test transaction entity ID:" tx-entity-id) (println "Test transaction entity ID:" tx-entity-id)
@@ -174,6 +194,7 @@
(reset! test-client-ids (reset! test-client-ids
{:test (get tempids "client-id") {:test (get tempids "client-id")
:test2 (get tempids "client-id-2")}) :test2 (get tempids "client-id-2")})
(reset! test-sales-summary-id (get tempids "sales-summary-id"))
tx-entity-id)) tx-entity-id))
(defn test-info-handler [request] (defn test-info-handler [request]
@@ -183,6 +204,7 @@
{:transactionId @test-transaction-id {:transactionId @test-transaction-id
:accounts @test-account-ids :accounts @test-account-ids
:clientMode @test-identity-mode :clientMode @test-identity-mode
:salesSummaryId @test-sales-summary-id
:clients (mapv :client/code (:clients request))})}) :clients (mapv :client/code (:clients request))})})
(defn test-set-client-mode-handler [request] (defn test-set-client-mode-handler [request]