mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-18 19:42:04 +00:00
c917bba54a
Since the grid transport change, opening a Pokémon fetched /api/pokedexes/[id]/entries/[entryId]. That request re-scanned the whole dex to authorise one entry, on top of an auth call and an ownership query, and production runs its functions in a different region from the database — so each card took seconds to fill in. The page's 60-second reconcile also wiped the detail cache, making cards refetch about once a minute. Details are catalog text plus personal notes, so the dex is now read once per account/dex in the background at the interactive/idle boundary and fills that cache; a card opens straight from memory. That read reuses the existing combined-data endpoint, which gains an opt-out for its count query since a caller taking every row already knows the total. The per-entry endpoint stays as the fallback for a card opened before the background read lands, and now checks membership for the single entry instead of materialising the dex. Catch status still comes from the live grid row and any queued write, so a cached detail cannot show stale progress. The packed grid transport and virtualised rendering are unchanged.
172 lines
7.7 KiB
JavaScript
172 lines
7.7 KiB
JavaScript
import { chromium } from '@playwright/test';
|
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
import assert from 'node:assert/strict';
|
|
const dir = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
|
|
const fixture = JSON.parse(await readFile(`${dir}/fixture.json`, 'utf8'));
|
|
const browser = await chromium.launch();
|
|
const context = await browser.newContext({
|
|
baseURL: process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173',
|
|
storageState: `${dir}/storage-state.json`,
|
|
viewport: { width: 1350, height: 940 }
|
|
});
|
|
await context.addInitScript(() => {
|
|
window.__cls = 0;
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__cls += entry.value;
|
|
}).observe({ type: 'layout-shift', buffered: true });
|
|
});
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
const national = fixture.dexes.find((dex) => !dex.gameScope);
|
|
const scoped = fixture.dexes.find((dex) => dex.gameScope);
|
|
const results = [];
|
|
try {
|
|
await page.goto(`/pokedex/${national.id}`);
|
|
await page.waitForSelector('[data-grid-interactive]');
|
|
for (const width of [1350, 390]) {
|
|
await page.setViewportSize({ width, height: 940 });
|
|
for (const density of ['comfortable', 'compact', 'ultra']) {
|
|
await page.getByLabel('Choose box view layout density').selectOption(density);
|
|
await page.reload();
|
|
await page.waitForSelector('[data-grid-interactive]');
|
|
await page.waitForTimeout(350);
|
|
assert.equal(await page.getByLabel('Choose box view layout density').inputValue(), density);
|
|
const geometry = await page.evaluate(() => {
|
|
const cell = document.querySelector('[data-entry-index="0"]').getBoundingClientRect();
|
|
const shell = document.querySelector('[data-box-number="1"]').getBoundingClientRect();
|
|
return {
|
|
cellWidth: cell.width,
|
|
cellHeight: cell.height,
|
|
cls: window.__cls,
|
|
horizontalOverflow: document.documentElement.scrollWidth > innerWidth,
|
|
shellHeight: shell.height
|
|
};
|
|
});
|
|
assert.ok(Math.abs(geometry.cellWidth - geometry.cellHeight) < 1, JSON.stringify(geometry));
|
|
assert.ok(!geometry.horizontalOverflow, `Horizontal overflow at ${width}/${density}`);
|
|
assert.ok(geometry.cls <= 0.1, `CLS ${geometry.cls} at ${width}/${density}`);
|
|
results.push({ width, density, ...geometry });
|
|
}
|
|
}
|
|
await page.setViewportSize({ width: 1350, height: 940 });
|
|
await page.getByLabel('Choose box view layout density').selectOption('comfortable');
|
|
await page.getByLabel('Not caught', { exact: true }).check();
|
|
assert.equal(await page.locator('[data-entry-index="0"]').getAttribute('aria-disabled'), 'true');
|
|
assert.equal(await page.locator('[data-entry-index="0"]').getAttribute('data-entry-id'), '1');
|
|
await page.getByLabel('Not caught', { exact: true }).uncheck();
|
|
// Resizing across the mobile breakpoint keeps the same box at the scroll anchor.
|
|
await page.locator('[data-box-number="15"]').evaluate((node) => node.scrollIntoView());
|
|
await page.waitForTimeout(100);
|
|
const anchorTop = await page
|
|
.locator('[data-box-number="15"]')
|
|
.evaluate((node) => node.getBoundingClientRect().top);
|
|
await page.setViewportSize({ width: 390, height: 940 });
|
|
await page.waitForTimeout(200);
|
|
const resizedTop = await page
|
|
.locator('[data-box-number="15"]')
|
|
.evaluate((node) => node.getBoundingClientRect().top);
|
|
assert.ok(
|
|
Math.abs(anchorTop - resizedTop) < 2,
|
|
`Scroll anchor moved: ${anchorTop} to ${resizedTop}`
|
|
);
|
|
await page.setViewportSize({ width: 1350, height: 940 });
|
|
// Let the resize settle first: restoring the box anchor scrolls the page, and a late restore
|
|
// would undo the scroll below.
|
|
await page.waitForTimeout(200);
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
await page.waitForSelector('[data-entry-index="1024"]');
|
|
assert.ok((await page.locator('[data-entry-index]').count()) < 300);
|
|
await page.locator('[data-entry-index="1024"]').focus();
|
|
await page.keyboard.press('ArrowRight');
|
|
assert.equal(
|
|
await page.evaluate(() => document.activeElement.getAttribute('data-entry-index')),
|
|
'1024'
|
|
);
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
await page.waitForSelector('[data-entry-index="0"]');
|
|
// Closing an in-flight modal must prevent its response from replacing the next selection.
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.route(`**/api/pokedexes/${national.id}/entries/1`, async (route) => {
|
|
await held;
|
|
await route.continue().catch(() => {});
|
|
});
|
|
await page.locator('[data-entry-index="0"]').click();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
|
await page.locator('[data-entry-index="1"]').click();
|
|
release();
|
|
await page.getByRole('dialog').getByLabel('Notes:', { exact: true }).waitFor();
|
|
assert.equal(
|
|
await page.getByRole('dialog').getByRole('heading', { name: 'Ivysaur', exact: true }).count(),
|
|
1
|
|
);
|
|
assert.equal(
|
|
await page.getByRole('dialog').getByRole('heading', { name: 'Bulbasaur', exact: true }).count(),
|
|
0
|
|
);
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
|
await page.unrouteAll({ behavior: 'wait' });
|
|
// Navigate through actual list cards, preserving fixture-specific totals and positions.
|
|
await page.getByRole('link', { name: 'My Pokédexes' }).click();
|
|
await page
|
|
.locator('.card')
|
|
.filter({ hasText: scoped.name })
|
|
.first()
|
|
.getByRole('button', { name: 'View', exact: true })
|
|
.click();
|
|
await page.waitForSelector('[data-grid-interactive]');
|
|
assert.ok((await page.locator('body').innerText()).includes('Showing 439 of 439'));
|
|
await page.goBack();
|
|
// Block the background detail read so the cache stays empty: these checks are about the offline
|
|
// snapshot answering a click, not about details the browser already holds.
|
|
await page.route(`**/api/pokedexes/${national.id}/combined-data*`, (route) => route.abort());
|
|
await page
|
|
.locator('.card')
|
|
.filter({ hasText: national.name })
|
|
.first()
|
|
.getByRole('button', { name: 'View', exact: true })
|
|
.click();
|
|
await page.waitForSelector('[data-grid-interactive]');
|
|
// Wait for the existing worker's full snapshot, then read an uncached detail without a network.
|
|
await page.waitForFunction(
|
|
async () => {
|
|
const meta = await (
|
|
await caches.open('livingdex-offline-meta-v1')
|
|
).match('/__offline/current');
|
|
return !!(await meta?.json())?.dataCache;
|
|
},
|
|
{ timeout: 15000 }
|
|
);
|
|
await context.setOffline(true);
|
|
await page.locator('[data-entry-index="5"]').click();
|
|
await page.waitForFunction(() =>
|
|
document.querySelector('[role="dialog"]')?.textContent.includes('Where to catch:')
|
|
);
|
|
assert.equal(await page.getByRole('dialog').locator('textarea').count(), 0);
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
|
// A mismatched cache owner is never used as fallback.
|
|
await page.evaluate(async () => {
|
|
const cache = await caches.open('livingdex-offline-meta-v1');
|
|
const response = await cache.match('/__offline/current');
|
|
const meta = await response.json();
|
|
await cache.put(
|
|
'/__offline/current',
|
|
new Response(JSON.stringify({ ...meta, userId: 'other-account' }))
|
|
);
|
|
});
|
|
await page.locator('[data-entry-index="6"]').click();
|
|
await page.getByRole('alert').filter({ hasText: 'not saved for offline use' }).waitFor();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
|
assert.deepEqual(errors, []);
|
|
await writeFile(`${dir}/behavior.json`, JSON.stringify(results, null, 2));
|
|
console.log(
|
|
'Passed density/mobile geometry, filtered placement, virtual boundaries, modal races, client navigation and isolated offline details.'
|
|
);
|
|
console.log(JSON.stringify(results, null, 2));
|
|
} finally {
|
|
await browser.close();
|
|
}
|