Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/web/src/explore/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
DataErrorEventDetail,
LegendErrorEventDetail,
RendererDegradedDetail,
SelectionDisabledNotificationDetail,
} from '@protspace/core';
import type { NotifyOptions } from '../lib/notify';
Expand Down Expand Up @@ -167,6 +168,32 @@ export function getSelectionDisabledNotification(
};
}

/**
* Copy for a renderer capability reduction.
*
* Every one of these was previously silent or console-only, on hardware that
* never said anything was out of range. The toast shows the user-facing message;
* the report action carries the measured numbers instead, because the device
* limit and the point count are what identify the device class in a bug report.
*/
export function getRendererDegradedNotification(detail: RendererDegradedDetail): NotifyOptions {
const context = detail.context;
const reason = context?.reason ?? 'unknown';
return {
title: 'Rendering quality reduced.',
description: detail.message,
durationMs: 10_000,
dedupeKey: `renderer-degraded:${reason}`,
action: buildReportAction(
'Rendering',
context
? `${reason} (maxTextureSize=${context.maxTextureSize}, stride=${context.stride}, ` +
`points=${context.pointCount}${context.detail ? `, cause=${context.detail}` : ''})`
: detail.message,
),
};
}

export function getLegendErrorNotification(detail: LegendErrorEventDetail): NotifyOptions {
return {
title: 'Legend update failed.',
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/explore/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import '@protspace/core'; // Registers all web components
import type { RendererDegradedDetail } from '@protspace/core';
import type { EatReliabilityState } from '@protspace/utils';
import { notify } from '../lib/notify';
import { getRendererDegradedNotification } from './notifications';
import { startProductTour } from '../tour/product-tour';
import { bindControlBarEvents } from './control-bar-events';
import { createDatasetController } from './dataset-controller';
Expand Down Expand Up @@ -319,6 +322,13 @@ export async function initializeExploreRuntime(): Promise<ExploreController> {
'data-change',
interactionController.handlePlotDataChange,
);
// The renderer reports capability reductions (device texture limits, a refused
// GPU allocation, the gamma fallback) that were previously silent or
// console-only. Latched once per reason in the renderer, deduped again here.
addTrackedEventListener(lifecycle, plotElement, 'renderer-degraded', (event: Event) => {
const detail = (event as CustomEvent<RendererDegradedDetail>).detail;
notify.warning(getRendererDegradedNotification(detail));
});
addTrackedEventListener(lifecycle, plotElement, 'file-dropped', (event: Event) => {
const file = (event as CustomEvent<{ file?: File }>).detail.file;
if (file) {
Expand Down
145 changes: 145 additions & 0 deletions apps/web/tests/helpers/gl-simulation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { Page } from '@playwright/test';

/**
* Simulate a WebGL2 device with a given `MAX_TEXTURE_SIZE`, including the
* driver-side refusal of anything larger.
*
* Stubbing `gl.getParameter` alone proves nothing: it changes what the app
* believes, while the real driver on the test machine accepts the allocation
* regardless. Simulating the refusal is what makes an assertion about the
* renderer's behaviour meaningful — and it reproduces the exact shape of the
* real failure: `texImage2D` past the limit raises `GL_INVALID_VALUE`, which is
* not a JS exception, so nothing unwinds and the texture is left unallocated.
*
* It patches the prototype, so it sees EVERY WebGL2 texture in the page, not
* just the label atlas — including the renderer's canvas-sized gamma-pipeline
* colour target. Pick a `limit` above the plot canvas's physical size, or the
* stats will blame the renderer for an allocation the atlas work never made.
*/
interface SimulatedGlStats {
/** [width, height] of every allocation the simulated device refused. */
refusedAllocations: Array<[number, number]>;
/** Updates issued against a texture that was never successfully allocated. */
refusedUpdates: number;
}

declare global {
interface Window {
__glSim?: SimulatedGlStats;
}
}

export async function simulateTextureLimit(page: Page, limit: number): Promise<void> {
await page.addInitScript((maxTextureSize: number) => {
const MAX_TEXTURE_SIZE = 0x0d33;
const INVALID_VALUE = 0x0501;
const INVALID_OPERATION = 0x0502;

const stats: SimulatedGlStats = { refusedAllocations: [], refusedUpdates: 0 };
window.__glSim = stats;

const proto = WebGL2RenderingContext.prototype;
const originalGetParameter = proto.getParameter;
const originalTexImage2D = proto.texImage2D;
const originalTexSubImage2D = proto.texSubImage2D;
const originalGetError = proto.getError;
const originalBindTexture = proto.bindTexture;

const boundTexture = new WeakMap<WebGL2RenderingContext, WebGLTexture | null>();
const allocated = new WeakSet<WebGLTexture>();
const pendingError = new WeakMap<WebGL2RenderingContext, number>();

proto.getParameter = function patchedGetParameter(this: WebGL2RenderingContext, name: number) {
if (name === MAX_TEXTURE_SIZE) return maxTextureSize;
return originalGetParameter.call(this, name);
};

proto.bindTexture = function patchedBindTexture(
this: WebGL2RenderingContext,
target: number,
texture: WebGLTexture | null,
) {
boundTexture.set(this, texture);
return originalBindTexture.call(this, target, texture);
};

proto.texImage2D = function patchedTexImage2D(
this: WebGL2RenderingContext,
...args: unknown[]
) {
const texture = boundTexture.get(this) ?? null;
// Only the explicit-dimension overloads carry width/height at 3 and 4. The
// 6-argument DOM-source form — texImage2D(target, level, internalformat,
// format, type, source) — puts enum values there instead (RGBA is 6408,
// UNSIGNED_BYTE is 5121), which would read as an enormous allocation and be
// recorded as a refusal the renderer never issued, then evict the texture
// from `allocated` so its next update counted as a refused update too.
const hasExplicitSize = args.length >= 9;
const width = hasExplicitSize ? (args[3] as number) : null;
const height = hasExplicitSize ? (args[4] as number) : null;

if (
typeof width === 'number' &&
typeof height === 'number' &&
(width > maxTextureSize || height > maxTextureSize)
) {
stats.refusedAllocations.push([width, height]);
pendingError.set(this, INVALID_VALUE);
if (texture) allocated.delete(texture);
return undefined;
}

if (texture) allocated.add(texture);
return (originalTexImage2D as (...a: unknown[]) => unknown).apply(this, args);
};

proto.texSubImage2D = function patchedTexSubImage2D(
this: WebGL2RenderingContext,
...args: unknown[]
) {
const texture = boundTexture.get(this) ?? null;
if (texture && !allocated.has(texture)) {
stats.refusedUpdates += 1;
pendingError.set(this, INVALID_OPERATION);
return undefined;
}
return (originalTexSubImage2D as (...a: unknown[]) => unknown).apply(this, args);
};

proto.getError = function patchedGetError(this: WebGL2RenderingContext) {
const simulated = pendingError.get(this);
if (simulated !== undefined) {
pendingError.delete(this); // getError clears the flag it reports
return simulated;
}
return originalGetError.call(this);
};
}, limit);
}

export async function simulatedGlStats(page: Page): Promise<SimulatedGlStats> {
return page.evaluate(() => window.__glSim ?? { refusedAllocations: [], refusedUpdates: 0 });
}

/** Distinct opaque colours present in the plot canvas, as "r,g,b" keys. */
export async function distinctCanvasColors(page: Page): Promise<string[]> {
return page.evaluate(() => {
const canvas = document
.querySelector('#myPlot')
?.shadowRoot?.querySelector('canvas') as HTMLCanvasElement | null;
if (!canvas) return [];
const copy = document.createElement('canvas');
copy.width = canvas.width;
copy.height = canvas.height;
const ctx = copy.getContext('2d');
if (!ctx) return [];
ctx.drawImage(canvas, 0, 0);
const { data } = ctx.getImageData(0, 0, copy.width, copy.height);
const seen = new Set<string>();
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 200) continue; // skip background and anti-alias fringe
seen.add(`${data[i]},${data[i + 1]},${data[i + 2]}`);
}
return [...seen];
});
}
114 changes: 114 additions & 0 deletions apps/web/tests/label-atlas-limit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expect, test, type Page } from '@playwright/test';
import { dismissTourIfPresent, waitForExploreDataLoad } from './helpers/explore';
import {
distinctCanvasColors,
simulateTextureLimit,
simulatedGlStats,
} from './helpers/gl-simulation';

/**
* The label atlas against a device whose texture limit it cannot fit.
*
* jsdom has no WebGL, so this is the only layer that exercises the real
* renderer. But a limit *stub* alone proves nothing: `gl.MAX_TEXTURE_SIZE` only
* changes what the app believes, while the actual driver on this machine happily
* accepts the allocation. So the driver's refusal is simulated too —
* `texImage2D` past the simulated limit does not call through and arms
* `getError`, exactly as a real driver does: no exception, no unwinding, the
* texture left unallocated.
*
* That simulation is what makes the assertion meaningful. The reported failure
* is silent — nothing in the app called `gl.getError()` — so "no console errors"
* would pass on the broken code too. What the test asserts instead is that the
* renderer never *issues* an allocation the device would refuse.
*
* The demo dataset is ~7.8K proteins, so its atlas is 2048x31 — no realistic
* limit forces a stride reduction at that size. This spec therefore covers the
* "no atlas fits at all" path; the reduced-stride path needs the 573K fixture
* and lives in load-large-bundle.spec.ts.
*/

// A multi-value annotation in the shipped demo dataset, so the atlas is actually
// in play. `cath` and `superfamily` are the others.
const MULTI_VALUE_ANNOTATION = 'keyword';

/**
* One texel below the narrowest atlas width (2048), so no atlas layout fits and
* the renderer must allocate none. The broken code ignored the limit entirely
* and issued 2048 x 31, which this simulated driver refuses.
*
* Deliberately not something far lower like 1024: the simulation intercepts
* *every* texture in the page, and the renderer's own gamma-pipeline colour
* target is allocated at the plot canvas's physical size (`framebuffer.ts`). A
* limit under that size would record it as a refusal the atlas work never
* caused, coupling these assertions to the viewport and the sidebar's width.
*/
const NO_ATLAS_FITS_LIMIT = 2047;

test.describe('label atlas on a device that cannot hold it', () => {
test('never issues an allocation the device would refuse', async ({ page }) => {
await simulateTextureLimit(page, NO_ATLAS_FITS_LIMIT);

await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`);
await dismissTourIfPresent(page);
await waitForExploreDataLoad(page);

const stats = await simulatedGlStats(page);
expect(
stats.refusedAllocations,
`renderer issued texture allocations the device refuses: ${JSON.stringify(stats.refusedAllocations)}`,
).toEqual([]);
// The permanent half: once an allocation is refused, every later update
// targets storage that does not exist.
expect(stats.refusedUpdates).toBe(0);
});

test('still draws every point, in colour rather than black', async ({ page }) => {
await simulateTextureLimit(page, NO_ATLAS_FITS_LIMIT);

await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`);
await dismissTourIfPresent(page);
await waitForExploreDataLoad(page);

const proteinCount = await page.evaluate(() => {
const plot = document.querySelector('#myPlot') as
| (Element & { data?: { protein_ids?: { length?: number } } })
| null;
return plot?.data?.protein_ids?.length ?? 0;
});
expect(proteinCount).toBeGreaterThan(0);

// Fidelity degrades; coverage does not. Markers fall back to their dominant
// colour, which is what the legend shows — never the solid black an
// unallocated atlas produced.
const colors = await distinctCanvasColors(page);
expect(colors.length).toBeGreaterThan(1);
expect(colors.every((c) => c === '0,0,0')).toBe(false);
});

test('tells the user that marker fidelity was reduced', async ({ page }) => {
await simulateTextureLimit(page, NO_ATLAS_FITS_LIMIT);

await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`);
await dismissTourIfPresent(page);
await waitForExploreDataLoad(page);

await expect(page.getByText('Rendering quality reduced.')).toBeVisible({ timeout: 15_000 });
});

test('is inert on a device with ample limits', async ({ page }) => {
// Same simulation, a limit nothing reaches: the atlas allocates normally and
// no refusal is recorded, so the simulation itself cannot be what fails the
// tests above.
await simulateTextureLimit(page, 8192);

await page.goto(`/explore?annotation=${MULTI_VALUE_ANNOTATION}`);
await dismissTourIfPresent(page);
await waitForExploreDataLoad(page);

const stats = await simulatedGlStats(page);
expect(stats.refusedAllocations).toEqual([]);
expect(stats.refusedUpdates).toBe(0);
await expect(page.getByText('Rendering quality reduced.')).toHaveCount(0);
});
});
57 changes: 57 additions & 0 deletions apps/web/tests/load-large-bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import {
waitForExploreDataLoad,
getFirstLegendItemValue,
} from './helpers/explore';
import {
distinctCanvasColors,
simulateTextureLimit,
simulatedGlStats,
} from './helpers/gl-simulation';

const SPEC_DIR = path.dirname(new URL(import.meta.url).pathname);
const SPROT_FIXTURE = path.resolve(SPEC_DIR, 'fixtures/sprot_50.parquetbundle');
Expand Down Expand Up @@ -190,3 +195,55 @@ test.describe('large bundle load (sprot_50, 573k proteins)', () => {
}
});
});

/**
* The reduced-stride path, which only a real large bundle reaches.
*
* At 573,649 proteins the atlas is 2048 x 2241, so a device reporting the
* WebGL2 floor of 2048 cannot hold it — this is the case the issue reported, on
* the dataset the app ships. The demo dataset is far too small to reach it
* (2048 x 31), which is why this lives here rather than in the default suite.
*/
test.describe('label atlas at Swiss-Prot scale on a floor-limit device', () => {
test.skip(
!fixtureAvailable,
'Fixture sprot_50.parquetbundle not present; copy from protspace/data/other/sprot/.',
);
test.setTimeout(180_000);

test('reduces slices to fit, and still draws every protein', async ({ page }) => {
await simulateTextureLimit(page, 2048);

await page.goto('/explore');
await dismissTourIfPresent(page);

await page.locator('protspace-control-bar [data-driver-id="import"] .dropdown-trigger').click();
await page
.locator('protspace-data-loader')
.locator('input[type="file"]')
.setInputFiles(SPROT_FIXTURE);
await waitForExploreDataLoad(page, 120_000);

// Nothing the device would refuse was ever issued. On the pre-fix renderer
// this records [[2048, 2241]] and then a refused update on every restage.
const stats = await simulatedGlStats(page);
expect(
stats.refusedAllocations,
`renderer issued allocations the device refuses: ${JSON.stringify(stats.refusedAllocations)}`,
).toEqual([]);
expect(stats.refusedUpdates).toBe(0);

// Fidelity drops; coverage does not.
const proteinCount = await page.evaluate(() => {
const plot = document.querySelector('#myPlot') as
| (Element & { data?: { protein_ids?: { length?: number } } })
| null;
return plot?.data?.protein_ids?.length ?? 0;
});
expect(proteinCount).toBe(573_649);

const colors = await distinctCanvasColors(page);
expect(colors.length).toBeGreaterThan(1);
expect(colors.every((c) => c === '0,0,0')).toBe(false);
});
});
Loading
Loading