|
| 1 | +/** |
| 2 | + * Spec 026 — NeuroScape year-aware backdrop density e2e. |
| 3 | + * |
| 4 | + * Verifies the render-integration contract (B1–B5, |
| 5 | + * specs/026-neuroscape-year-density/contracts/render-integration.md) |
| 6 | + * against the deployed `/neuroscape/` preview: |
| 7 | + * |
| 8 | + * B1 full span: a baseline rendered backdrop dot count exists. |
| 9 | + * B2 a narrow window in the EARLY era and one in the RECENT era render |
| 10 | + * backdrop dot counts within a bounded ratio (compressed) — not the |
| 11 | + * order-of-magnitude swing a raw spatial sample gives (SC-001). |
| 12 | + * B4 the RESULT-COUNT still reflects TRUE volume (recent ≫ early), i.e. |
| 13 | + * the feature changes only backdrop rendering, not filtering |
| 14 | + * semantics (FR-006 / SC-004). |
| 15 | + * B5 clearing the year filter restores ~the full-span backdrop count. |
| 16 | + * |
| 17 | + * Like the other neuroscape specs this runs against the deployed preview |
| 18 | + * (the 461k corpus streams over several seconds), so we poll for a stable |
| 19 | + * result list before measuring, and use generous bands to stay robust |
| 20 | + * while still catching the 10×+ regression. |
| 21 | + */ |
| 22 | +import { test, expect, type Page } from '@playwright/test'; |
| 23 | + |
| 24 | +function neuroscapeUrl(): string { |
| 25 | + const raw = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173/ohbm2026/'; |
| 26 | + const base = raw.endsWith('/') ? raw : `${raw}/`; |
| 27 | + return base.replace(/\/ohbm2026\/$/, '/neuroscape/'); |
| 28 | +} |
| 29 | + |
| 30 | +/** Total points rendered across the 2D scatter's traces (the backdrop base |
| 31 | + * sample dominates at default zoom; viewport-detail is off when zoomed out). */ |
| 32 | +async function renderedBackdropCount(page: Page): Promise<number> { |
| 33 | + return page.evaluate(() => { |
| 34 | + const el = document.querySelector('[data-testid="umap-chart-2d"]') as unknown as { |
| 35 | + data?: Array<{ x?: unknown[] }>; |
| 36 | + } | null; |
| 37 | + if (!el?.data) return 0; |
| 38 | + return el.data.reduce((sum, tr) => sum + (Array.isArray(tr.x) ? tr.x.length : 0), 0); |
| 39 | + }); |
| 40 | +} |
| 41 | + |
| 42 | +async function resultCount(page: Page): Promise<number> { |
| 43 | + const t = (await page.getByTestId('result-count').textContent())?.trim() ?? '0'; |
| 44 | + return Number.parseInt(t.replace(/[^0-9]/g, ''), 10) || 0; |
| 45 | +} |
| 46 | + |
| 47 | +/** Drag a slider handle to an absolute fraction of the track width. */ |
| 48 | +async function dragToFraction(page: Page, testid: string, frac: number): Promise<void> { |
| 49 | + const track = page.getByTestId('neuroscape-year-slider').locator('.track'); |
| 50 | + const tbox = await track.boundingBox(); |
| 51 | + const handle = page.getByTestId(testid); |
| 52 | + const hbox = await handle.boundingBox(); |
| 53 | + if (!tbox || !hbox) throw new Error('slider not laid out'); |
| 54 | + await page.mouse.move(hbox.x + hbox.width / 2, hbox.y + hbox.height / 2); |
| 55 | + await page.mouse.down(); |
| 56 | + await page.mouse.move(tbox.x + tbox.width * frac, tbox.y + tbox.height / 2, { steps: 12 }); |
| 57 | + await page.mouse.up(); |
| 58 | +} |
| 59 | + |
| 60 | +/** Poll until the rendered backdrop count settles (two equal reads). */ |
| 61 | +async function stableBackdropCount(page: Page): Promise<number> { |
| 62 | + let prev = -1; |
| 63 | + await expect |
| 64 | + .poll( |
| 65 | + async () => { |
| 66 | + const cur = await renderedBackdropCount(page); |
| 67 | + const settled = cur > 0 && cur === prev; |
| 68 | + prev = cur; |
| 69 | + return settled ? 'stable' : 'moving'; |
| 70 | + }, |
| 71 | + { timeout: 60_000, intervals: [500, 1000, 2000] } |
| 72 | + ) |
| 73 | + .toBe('stable'); |
| 74 | + return renderedBackdropCount(page); |
| 75 | +} |
| 76 | + |
| 77 | +test.describe('Spec 026: /neuroscape/ year-aware backdrop density', () => { |
| 78 | + test.beforeEach(async ({ page }) => { |
| 79 | + test.setTimeout(240_000); |
| 80 | + await page.goto(neuroscapeUrl(), { waitUntil: 'domcontentloaded' }); |
| 81 | + await page.getByTestId('search-input').waitFor({ state: 'visible', timeout: 30_000 }); |
| 82 | + // Ensure the map is mounted (desktop shows it by default; on a narrow |
| 83 | + // project tap "Show map"). |
| 84 | + const chart = page.getByTestId('umap-chart-2d'); |
| 85 | + if (!(await chart.isVisible().catch(() => false))) { |
| 86 | + const showMap = page.getByTestId('toggle-map'); |
| 87 | + if (await showMap.isVisible().catch(() => false)) await showMap.click(); |
| 88 | + } |
| 89 | + await chart.waitFor({ state: 'visible', timeout: 60_000 }); |
| 90 | + // Wait for the corpus + scatter to settle before measuring. |
| 91 | + await stableBackdropCount(page); |
| 92 | + }); |
| 93 | + |
| 94 | + test('B1/B2/B4/B5: compressed backdrop density while true counts reflect volume', async ({ |
| 95 | + page |
| 96 | + }) => { |
| 97 | + // B1 — full-span baseline. |
| 98 | + const fullSpanDots = await stableBackdropCount(page); |
| 99 | + expect(fullSpanDots).toBeGreaterThan(0); |
| 100 | + |
| 101 | + // EARLY-era narrow window (left ~15% of the track). |
| 102 | + await dragToFraction(page, 'neuroscape-year-handle-start', 0.02); |
| 103 | + await dragToFraction(page, 'neuroscape-year-handle-end', 0.17); |
| 104 | + const earlyDots = await stableBackdropCount(page); |
| 105 | + const earlyResults = await resultCount(page); |
| 106 | + |
| 107 | + // RECENT-era narrow window (right ~15% of the track), same width. |
| 108 | + await dragToFraction(page, 'neuroscape-year-handle-start', 0.83); |
| 109 | + await dragToFraction(page, 'neuroscape-year-handle-end', 0.98); |
| 110 | + const recentDots = await stableBackdropCount(page); |
| 111 | + const recentResults = await resultCount(page); |
| 112 | + |
| 113 | + // B4 / FR-006 — the RESULT list still reflects TRUE volume: recent |
| 114 | + // years have many more articles than early years. |
| 115 | + expect(recentResults).toBeGreaterThan(earlyResults); |
| 116 | + |
| 117 | + // B2 / SC-001 — the rendered BACKDROP density is compressed: the two |
| 118 | + // same-width windows are within a bounded ratio (generous band for |
| 119 | + // e2e; the pre-feature swing was 10×+). Guard against divide-by-zero. |
| 120 | + expect(earlyDots).toBeGreaterThan(0); |
| 121 | + expect(recentDots).toBeGreaterThan(0); |
| 122 | + const ratio = recentDots / earlyDots; |
| 123 | + expect(ratio).toBeLessThanOrEqual(4); |
| 124 | + // And both windows render fewer dots than the full span. |
| 125 | + expect(recentDots).toBeLessThanOrEqual(fullSpanDots); |
| 126 | + |
| 127 | + // B5 — clearing the year filter restores ~the full-span backdrop. |
| 128 | + await page.getByTestId('neuroscape-facets-clear').click(); |
| 129 | + const restored = await stableBackdropCount(page); |
| 130 | + expect(Math.abs(restored - fullSpanDots) / fullSpanDots).toBeLessThan(0.1); |
| 131 | + }); |
| 132 | +}); |
0 commit comments