Files
integreat/e2e/transaction-rule.spec.ts
Bryce 107a02f4f1 refactor(ssr): Phase 6b — migrate Transaction Rule wizard onto the session engine; de-cursor
Proves the Phase-6a wizard engine against a real 2-step modal: the Transaction
Rule wizard (edit step + read-only test/preview step) now runs on wizard2 /
wizard-state, fully de-cursored.

What changed
- Wizard machinery removed: deleted the EditModal / TestModal /
  TransactionRuleWizard defrecords (mm/ModalWizardStep + LinearModalWizard),
  MultiStepFormState, the EDN snapshot, and the step-params[...] prefix. Replaced
  with a data-driven `transaction-rule-wizard-config` (two steps + init-fn +
  done-fn) driven by the engine.
- De-cursored the whole edit form (82 fc/ refs -> 0): every field reads explicit
  data + path->name2; errors via a bound *errors* / ferr. The account row's Alpine
  cross-field dispatch wiring (clientId -> accountId -> location) is preserved
  verbatim — only the data plumbing moved off the cursor.
- The test step's :render reads :all-data (the engine's get-all), so the
  formtools "combine at the end" mechanism feeds the preview table.
- Routes 4 -> 2: open-rule-wizard (new + edit), save-step (every transition via the
  engine's `direction` field). The dedicated `navigate` route is deleted.
- decode-rule-form select-keys to the schema's known keys so the engine's nav
  fields (wizard-id/current-step/direction) don't leak into the upserted entity.

Scorecard (admin/transaction_rules.clj): fc/ 82->0, mm/ 20->0, defrecords 3->0,
LOC 1000->964, routes 4->2.

Scope note: the de-cursored edit step keeps com/* Hiccup leaf components (not yet
sc/* Selmer); the value here was removing fc/ + mm/ and proving the engine, not
re-templating the conditional/Alpine-cross-field layout. Hiccup-in-render is a
documented partial; the com/ -> sc/ swap is a mechanical follow-up.

Verification: rule spec 4/4 (new + edit dialogs, advance-to-test preview, save);
full Playwright suite 55/55; cljfmt clean. Skill fed: scorecard row + narrative
(engine's first real modal; generalizes for a one-data-step wizard); gotchas
(strip engine nav fields in decode, new-row temp-id, direction-button nav).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:12:33 -07:00

127 lines
5.7 KiB
TypeScript

import { test, expect } from '@playwright/test';
// Characterization spec for the Transaction Rule wizard (edit step + test/preview step).
// Captures CURRENT (pre-migration) behavior so the migration onto the session-backed
// wizard engine can be proven behavior-preserving. Reset the dataset before each test.
test.beforeEach(async ({ request }) => {
await request.post('/test-reset');
});
async function getTestInfo(page: any) {
return (await page.request.get('/test-info')).json();
}
async function navigateToRules(page: any) {
// The rule fixtures live under client TEST2 (to stay out of the single-client TEST
// transaction grid the other specs use), so view as an admin who sees all clients.
await page.request.get('/test-set-client-mode?mode=multi-client');
await page.setExtraHTTPHeaders({ 'x-clients': '"all"' });
await page.goto('/admin/transaction-rule');
await page.waitForSelector('#entity-table');
}
async function openNewDialog(page: any) {
await page.locator('button:has-text("New Transaction Rule")').first().click();
await page.waitForSelector('#wizard-form');
}
async function openEditDialog(page: any) {
// the edit pencil on the seeded rule's row (hx-get .../<id>/edit)
await page.locator('#entity-table tbody tr').first()
.locator('[hx-get*="/edit"]').first().click();
await page.waitForSelector('#wizard-form');
}
// Add a valid account-coding row (Solr typeahead unavailable in tests, so inject the
// account id into the row's hidden input), location Shared, percentage 100.
async function addAccount(page: any, accountId: string) {
await page.locator('#wizard-form a:has-text("New account")').first().click();
await page.waitForTimeout(400);
const hidden = page.locator('#wizard-form input[type="hidden"][name*="[transaction-rule-account/account]"]').first();
await hidden.evaluate((el: HTMLInputElement, v: string) => {
const n = document.createElement('input'); n.type = 'hidden'; n.name = el.name; n.value = v;
el.parentNode!.replaceChild(n, el);
}, accountId);
await page.waitForTimeout(200);
const loc = page.locator('#wizard-form select[name*="[transaction-rule-account/location]"]').first();
if (await loc.count() > 0) await loc.selectOption('Shared').catch(() => {});
const pct = page.locator('#wizard-form input[name*="[transaction-rule-account/percentage]"]').first();
await pct.fill('100');
await pct.dispatchEvent('change');
await page.waitForTimeout(200);
}
async function fillDescription(page: any, desc: string) {
await page.locator('#wizard-form input[name="transaction-rule/description"]').first().fill(desc);
}
// Approval status is required to advance/save; the radio-card's first option is "Approved".
async function selectApproved(page: any) {
const radio = page.locator('#wizard-form input[type="radio"][name*="transaction-approval-status"]').first();
await radio.check({ force: true }).catch(async () => {
await page.locator('#wizard-form label:has-text("Approved")').first().click();
});
await page.waitForTimeout(100);
}
async function clickTest(page: any) {
// the footer "Test" button navigates edit -> test
await page.locator('#wizard-form button:has-text("Test"), #wizard-form a:has-text("Test")').first().click();
await page.waitForTimeout(800);
}
test.describe.configure({ mode: 'serial' });
test.describe('Transaction Rule wizard (characterization)', () => {
test('New dialog opens the edit step with rule form + account grid', async ({ page }) => {
await navigateToRules(page);
await openNewDialog(page);
const modal = page.locator('#wizard-form');
await expect(modal).toContainText('Description');
await expect(modal).toContainText('Outcomes');
await expect(modal).toContainText('New account');
await expect(modal).toContainText('Approval status');
// the step indicator + the Test (advance) control
await expect(modal.locator('button:has-text("Test"), a:has-text("Test")').first()).toBeVisible();
});
test('Edit dialog pre-populates the seeded rule', async ({ page }) => {
await navigateToRules(page);
await openEditDialog(page);
const desc = page.locator('#wizard-form input[name="transaction-rule/description"]').first();
await expect(desc).toHaveValue('ZZRULEMATCH');
});
test('advancing to the test step renders the matching-transactions preview', async ({ page }) => {
const info = await getTestInfo(page);
await navigateToRules(page);
await openNewDialog(page);
await fillDescription(page, 'ZZRULEMATCH');
await addAccount(page, info.accounts['test-account'].toString());
await selectApproved(page);
await clickTest(page);
// the wizard advances to the test/preview step (the test-table query + render is
// reused unchanged by the migration; the seed has no recent match, so the count is 0)
const modal = page.locator('#wizard-form');
await expect(modal).toContainText('Matching transactions');
});
test('Saving from the test step creates the rule and closes the modal', async ({ page }) => {
const info = await getTestInfo(page);
await navigateToRules(page);
const before = await page.locator('#entity-table tbody tr').count();
await openNewDialog(page);
await fillDescription(page, 'ZZRULEMATCH');
await addAccount(page, info.accounts['test-account'].toString());
await selectApproved(page);
await clickTest(page);
// Save from the test step (the precise Save button, not Back which is also submit)
await page.locator('#wizard-form button:has-text("Save")').first().click();
await page.waitForTimeout(1000);
// modal closed + a new rule row added
await expect(page.locator('#wizard-form')).toBeHidden();
expect(await page.locator('#entity-table tbody tr').count()).toBe(before + 1);
});
});