test: badge E2E tests — fix __version wipe, extract idb_helpers, add render + workflow tests

Root cause fix: tests/_helpers/ae_defaults.ts was missing __version: 1, causing
store_versions.ts to wipe ae_loc from localStorage on every test page load. This
made trusted_access fall back to false, hiding the print button (can_print guard)
and failing all attendee workflow tests.

Changes:
- ae_defaults.ts: add __version: 1 with comment explaining the store_versions guard
- idb_helpers.ts: extract inject_badge_and_template() from print layout test into
  shared helper; now used by all three print/render test files
- event_badge_render.test.ts: new — 4 tests covering full_name_override priority,
  full_name fallback, duplex=0 hides badge back, duplex=1 shows badge back
- event_badge_attendee_workflow.test.ts: cleaned up (diagnostic code removed);
  all 3 tests now pass
- event_badge_print_layout.test.ts: renamed from badge_print_layout.test.ts;
  inline inject_idb() replaced with shared idb_helpers import
- event_badge_smoke.test.ts: renamed from event_badge.test.ts
- playwright.config.ts: use system /usr/bin/chromium on Arch Linux (avoids
  Playwright's bundled Chromium which requires Ubuntu libs not present on Arch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-18 16:58:55 -04:00
parent 07d21c29c8
commit af02e38528
7 changed files with 426 additions and 379 deletions

View File

@@ -0,0 +1,216 @@
/**
* Badge Print Page — Print Layout CSS Tests
*
* Verifies that the badge is horizontally (and vertically) centered on the page
* when print media CSS is active, using Playwright's emulateMedia({ media: 'print' }).
*
* Strategy: inject badge + template data directly into IndexedDB so liveQuery
* fires without needing a full API round-trip, then check bounding boxes.
*
* Cross-browser: add Firefox to playwright.config.ts projects to catch the
* overflow:auto + display:contents incompatibility that Firefox enforces strictly.
*/
import { test, expect } from '@playwright/test';
import { ae_app_local_data_defaults } from './_helpers/ae_defaults';
import { testing_event_id, testing_account_id, mock_site_domain } from './_helpers/env';
import { inject_badge_and_template } from './_helpers/idb_helpers';
const event_id = testing_event_id;
// Use the demo badge/template IDs from tests/README.md
const badge_id = 'UIJT-73-63-61';
const template_id = 'jgfixEpYp1B';
// Minimal badge record — both *_id and *_id_random set to the same value so
// db_events.badge.get(event_badge_id) finds the record via the Dexie primary key.
const mock_badge = {
id: badge_id,
event_badge_id: badge_id,
event_badge_id_random: badge_id,
event_id: event_id,
event_id_random: event_id,
event_badge_template_id: template_id,
event_badge_template_id_random: template_id,
full_name_override: 'Scott Idem',
given_name: 'Scott',
family_name: 'Idem',
email: 'scott@example.com',
badge_type: 'presenter',
badge_type_code: 'presenter',
print_count: 0,
affiliations: 'One Sky IT',
location: 'Minneapolis, MN',
enable: true,
};
// Minimal template record — layout drives the @page size injection
const mock_template = {
id: template_id,
id_random: template_id,
event_badge_template_id: template_id,
badge_template_id: template_id,
event_id: event_id,
event_id_random: event_id,
name: 'Dev Demo 202x',
layout: 'badge_3.5x5.5_pvc',
cfg_json: '{}',
enable: true,
};
test.describe('Badge Print Page — print layout centering', () => {
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
// Minimal API mocks — enough for the events layout to render without errors
await page.route('**/v3/**', async (route) => {
const url = route.request().url();
const method = route.request().method();
if (url.includes('site_domain/search')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [mock_site_domain] }) });
}
// Return a minimal valid event so the layout header doesn't error
if (url.includes(`/v3/crud/event/${event_id}`) && !url.includes('event_badge') && method === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
data: { id: event_id, event_id, name: 'Test Event', cfg_json: {}, mod_badges_json: {}, mod_pres_mgmt_json: {}, mod_abstracts_json: {}, mod_exhibits_json: {}, mod_meetings_json: {} }
}) });
}
// All other calls: empty list (no-op)
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [] }) });
});
// Seed localStorage auth so the app renders
await page.addInitScript(([defaults, e_id, a_id]) => {
const data = {
...defaults,
account_id: a_id,
allow_access: true,
authenticated_access: true,
trusted_access: true,
manager_access: true,
edit_mode: false,
} as any;
data.mod = { ...defaults.mod, events: { ...defaults.mod.events, event_id: e_id } };
window.localStorage.setItem('ae_loc', JSON.stringify(data));
}, [ae_app_local_data_defaults, event_id, testing_account_id] as const);
});
test('badge is horizontally centered on a letter-size page', async ({ page }) => {
// Letter paper: 8.5in × 11in at 96 dpi = 816 × 1056 px
await page.setViewportSize({ width: 816, height: 1056 });
// First navigation initializes the Dexie schema; inject_idb then writes the data.
// After injection we reload so liveQuery starts fresh against the populated IDB —
// direct IDB writes bypass Dexie's notification system and won't trigger a re-query.
await page.goto(`/events/${event_id}/badges/${badge_id}/print`);
await page.waitForLoadState('domcontentloaded');
await page.evaluate(inject_badge_and_template, { badge: mock_badge, template: mock_template });
await page.reload();
await page.waitForLoadState('domcontentloaded');
// Wait for LiveQuery to render the badge wrapper
await page.waitForSelector('.event_badge_wrapper', { timeout: 8000 });
// Apply print media CSS (activates @media print rules)
await page.emulateMedia({ media: 'print' });
// Wait for layout to settle under print CSS
await page.waitForFunction(() => {
const el = document.querySelector('.event_badge_wrapper') as HTMLElement | null;
return el !== null && el.offsetWidth > 0;
}, { timeout: 5000 });
const badge_box = await page.locator('.event_badge_wrapper').boundingBox();
expect(badge_box, 'badge wrapper must have a bounding box').not.toBeNull();
const viewport = page.viewportSize()!;
const badge_center_x = badge_box!.x + badge_box!.width / 2;
const page_center_x = viewport.width / 2;
// Badge horizontal center must be within 5px of the page center
expect(
Math.abs(badge_center_x - page_center_x),
`badge center (${badge_center_x.toFixed(1)}px) should equal page center (${page_center_x}px)`
).toBeLessThan(5);
});
test('badge is vertically centered on a letter-size page', async ({ page }) => {
await page.setViewportSize({ width: 816, height: 1056 });
await page.goto(`/events/${event_id}/badges/${badge_id}/print`);
await page.waitForLoadState('domcontentloaded');
await page.evaluate(inject_badge_and_template, { badge: mock_badge, template: mock_template });
await page.reload();
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('.event_badge_wrapper', { timeout: 8000 });
await page.emulateMedia({ media: 'print' });
await page.waitForFunction(() => {
const el = document.querySelector('.event_badge_wrapper') as HTMLElement | null;
return el !== null && el.offsetHeight > 0;
}, { timeout: 5000 });
const badge_box = await page.locator('.event_badge_wrapper').boundingBox();
expect(badge_box).not.toBeNull();
const viewport = page.viewportSize()!;
const badge_center_y = badge_box!.y + badge_box!.height / 2;
const page_center_y = viewport.height / 2;
// Badge vertical center must be within 5px of the page center
expect(
Math.abs(badge_center_y - page_center_y),
`badge center Y (${badge_center_y.toFixed(1)}px) should equal page center Y (${page_center_y}px)`
).toBeLessThan(5);
});
test('#ae_main_content is a full-page positioned block in print mode', async ({ page }) => {
// Verifies overflow:visible (overflow:auto fix) and position:relative (centering context).
await page.goto(`/events/${event_id}/badges/${badge_id}/print`);
// domcontentloaded is too early — wait for the element to exist after hydration.
await page.waitForSelector('#ae_main_content', { timeout: 8000 });
await page.emulateMedia({ media: 'print' });
const styles = await page.evaluate(() => {
const el = document.getElementById('ae_main_content');
if (!el) return null;
const cs = getComputedStyle(el);
return { overflow: cs.overflow, position: cs.position };
});
expect(styles, '#ae_main_content must exist').not.toBeNull();
expect(styles!.overflow, '#ae_main_content overflow should be visible in print').toBe('visible');
// position:static — print CSS strips #ae_main_content's overflow clipping by setting
// display:block + overflow:visible + position:static so it doesn't interfere with centering.
expect(styles!.position, '#ae_main_content should be static in print').toBe('static');
});
test('badge wrapper is absolutely positioned and centered in print mode', async ({ page }) => {
await page.setViewportSize({ width: 816, height: 1056 });
await page.goto(`/events/${event_id}/badges/${badge_id}/print`);
await page.waitForLoadState('domcontentloaded');
await page.evaluate(inject_badge_and_template, { badge: mock_badge, template: mock_template });
await page.reload();
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('.event_badge_wrapper', { timeout: 8000 });
await page.emulateMedia({ media: 'print' });
const styles = await page.evaluate(() => {
const el = document.querySelector('.event_badge_wrapper') as HTMLElement | null;
if (!el) return null;
const cs = getComputedStyle(el);
return {
position: cs.position,
transform: cs.transform,
};
});
expect(styles).not.toBeNull();
// position:fixed — anchors to the @page content area in print, bypassing all ancestor
// overflow/height constraints. This is intentional (see print layout architecture docs).
expect(styles!.position, 'badge wrapper should be fixed in print').toBe('fixed');
// transform should contain a matrix (translate(-50%,-50%) for centering)
expect(styles!.transform, 'badge wrapper should have a transform applied').not.toBe('none');
});
});