diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index e612e51..9fd9b1f 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -12,8 +12,8 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
-
-
{children}
+
+ {children}
)
}
diff --git a/apps/web/src/app/visualization/VisualizationDemo.tsx b/apps/web/src/app/visualization/VisualizationDemo.tsx
index 8b630a0..b9c3756 100644
--- a/apps/web/src/app/visualization/VisualizationDemo.tsx
+++ b/apps/web/src/app/visualization/VisualizationDemo.tsx
@@ -11,9 +11,9 @@ import {
* Client-side demonstration of the Phase 6.1 visualization foundation.
*
* Shows the data lifecycle (loading → success / empty / error) flowing
- * through the reusable `VisualizationContainer`. The module catalog itself
- * is a placeholder — the individual visualizations arrive in later
- * milestones.
+ * through the reusable `VisualizationContainer`. The module catalog maps the
+ * full Phase 6.2–6.11 platform, each entry resolving to the viewer(s) shown
+ * above on this page.
*/
export function VisualizationDemo() {
const { status, data, error, refetch } = useVisualizationData(
@@ -24,7 +24,7 @@ export function VisualizationDemo() {
return (
Phase 6.1 foundation, the Phase 6.2 Genome Browser, the Phase 6.3 Gene / Transcript
viewer, the Phase 6.4 Variant track, the Phase 6.5 Protein Viewer, the Phase 6.6
- Biological Network Viewer, the Phase 6.7 Scientific Charts, and the Phase 6.8 Advanced
- Scientific Charts — region parsing, viewport navigation, track rendering, gene/transcript
- structure, point variants, protein sequence + annotation windows, deterministic
- relationship networks, expression charts, and heatmap / volcano / coverage / distribution
- charts over the GenomeAI API and development fixtures.
+ Biological Network Viewer, the Phase 6.7 Scientific Charts, the Phase 6.8 Advanced
+ Scientific Charts, the Phase 6.9 Integrated Research Workspace, the Phase 6.10 performance
+ work, and the Phase 6.11 testing & documentation pass — region parsing, viewport
+ navigation, track rendering, gene/transcript structure, point variants, protein sequence +
+ annotation windows, deterministic relationship networks, expression charts, and heatmap /
+ volcano / coverage / distribution charts over the GenomeAI API and development fixtures.
diff --git a/apps/web/src/components/scientific/ChartPrimitives.test.tsx b/apps/web/src/components/scientific/ChartPrimitives.test.tsx
new file mode 100644
index 0000000..9b0921e
--- /dev/null
+++ b/apps/web/src/components/scientific/ChartPrimitives.test.tsx
@@ -0,0 +1,124 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import type { PlotArea } from '@/lib/scientific/geometry'
+import { createCategoryScale, createContinuousScale } from '@/lib/scientific/scale'
+
+import { ChartAxes } from './ChartAxes'
+import { ChartLegend } from './ChartLegend'
+import { ChartTooltip, TOOLTIP_WIDTH } from './ChartTooltip'
+
+const plot: PlotArea = { x0: 40, y0: 10, width: 300, height: 200 }
+
+function categoryScale() {
+ return createCategoryScale(['A', 'B'], [plot.x0, plot.x0 + plot.width])
+}
+
+function continuousScale() {
+ return createContinuousScale([-2, 2], [plot.x0, plot.x0 + plot.width])
+}
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('ChartAxes', () => {
+ it('renders gridlines, y ticks, and the x-axis baseline', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('chart-axes')).toBeInTheDocument()
+ expect(screen.getByTestId('chart-grid')).toBeInTheDocument()
+ expect(screen.getByTestId('chart-y-ticks')).toBeInTheDocument()
+ expect(screen.getByTestId('chart-x-labels')).toBeInTheDocument()
+ })
+
+ it('renders optional axis captions', () => {
+ render(
+ ,
+ )
+ expect(screen.getByTestId('chart-x-label')).toHaveTextContent('Sample')
+ expect(screen.getByTestId('chart-y-label')).toHaveTextContent('Expression value')
+ })
+
+ it('formats tick values through the provided formatter', () => {
+ render(
+ `${value / 1_000_000}M`}
+ />,
+ )
+ expect(screen.getByTestId('chart-y-ticks')).toHaveTextContent('1M')
+ expect(screen.getByTestId('chart-x-labels')).toHaveTextContent('2M')
+ })
+})
+
+describe('ChartLegend', () => {
+ it('renders each series as a labelled list item', () => {
+ render(
+ ,
+ )
+ const list = screen.getByRole('list', { name: 'Series legend' })
+ expect(list).toBeInTheDocument()
+ const items = screen.getAllByRole('listitem')
+ expect(items.map((item) => item.textContent)).toEqual(['TP53', 'BRCA1'])
+ })
+
+ it('renders nothing for an empty legend', () => {
+ const { container } = render( )
+ expect(container.firstChild).toBeNull()
+ })
+})
+
+describe('ChartTooltip', () => {
+ const tooltip = {
+ title: 'TP53',
+ subtitle: 'Tumor-1',
+ rows: [
+ { label: 'Value', value: '4.2' },
+ { label: 'Group', value: 'Tumor' },
+ ],
+ }
+
+ it('renders as a tooltip with the point summary and labelled rows', () => {
+ render( )
+ expect(screen.getByRole('tooltip')).toBeInTheDocument()
+ expect(screen.getByText('TP53')).toBeInTheDocument()
+ expect(screen.getByText('Tumor-1')).toBeInTheDocument()
+ expect(screen.getByText('Value')).toBeInTheDocument()
+ expect(screen.getByText('4.2')).toBeInTheDocument()
+ })
+
+ it('keeps the tooltip on-screen by clamping to the canvas width', () => {
+ const { rerender } = render( )
+ const nearEdge = screen.getByRole('tooltip')
+ // Clamped so the tooltip's right edge sits on the canvas edge.
+ expect(Number.parseInt(nearEdge.style.left, 10)).toBe(400 - TOOLTIP_WIDTH)
+
+ rerender( )
+ const nearOrigin = screen.getByRole('tooltip')
+ expect(Number.parseInt(nearOrigin.style.left, 10)).toBeGreaterThanOrEqual(4)
+ expect(Number.parseInt(nearOrigin.style.top, 10)).toBeGreaterThanOrEqual(4)
+ })
+})
diff --git a/apps/web/src/components/workspace/ResearchWorkspace.test.tsx b/apps/web/src/components/workspace/ResearchWorkspace.test.tsx
index 299c5b1..eb407f2 100644
--- a/apps/web/src/components/workspace/ResearchWorkspace.test.tsx
+++ b/apps/web/src/components/workspace/ResearchWorkspace.test.tsx
@@ -128,4 +128,42 @@ describe('ResearchWorkspace', () => {
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Network unavailable'))
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})
+
+ it('shows a loading state while a panel loader is pending', () => {
+ const dataSource: WorkspaceDataSource = {
+ ...fixtureWorkspaceDataSource,
+ loadGenes: () => new Promise(() => undefined),
+ }
+ render( )
+
+ expect(
+ screen
+ .getAllByRole('status')
+ .some((node) => node.textContent?.includes('Loading gene structure...')),
+ ).toBe(true)
+ })
+
+ it('keeps whole-dataset panels independent when the context region changes', async () => {
+ const loadNetwork = vi.fn(fixtureWorkspaceDataSource.loadNetwork)
+ const loadProtein = vi.fn(fixtureWorkspaceDataSource.loadProtein)
+ const dataSource: WorkspaceDataSource = {
+ ...fixtureWorkspaceDataSource,
+ loadNetwork,
+ loadProtein,
+ }
+ render( )
+
+ await waitFor(() => expect(loadNetwork).toHaveBeenCalledTimes(1))
+ await waitFor(() => expect(loadProtein).toHaveBeenCalledTimes(1))
+
+ fireEvent.change(screen.getByRole('combobox', { name: 'Research context' }), {
+ target: { value: 'brca1-locus' },
+ })
+ await waitFor(() =>
+ expect(screen.getByTestId('active-context')).toHaveTextContent('BRCA1 locus (chr17)'),
+ )
+
+ expect(loadNetwork).toHaveBeenCalledTimes(1)
+ expect(loadProtein).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/apps/web/src/components/workspace/ResearchWorkspace.tsx b/apps/web/src/components/workspace/ResearchWorkspace.tsx
index 6b4af09..549dad4 100644
--- a/apps/web/src/components/workspace/ResearchWorkspace.tsx
+++ b/apps/web/src/components/workspace/ResearchWorkspace.tsx
@@ -84,13 +84,17 @@ export function ResearchWorkspace({
dataSource={dataSource}
/>
-
+
+
+
-
+
diff --git a/apps/web/src/lib/genome/api.test.ts b/apps/web/src/lib/genome/api.test.ts
index ed00818..96485d8 100644
--- a/apps/web/src/lib/genome/api.test.ts
+++ b/apps/web/src/lib/genome/api.test.ts
@@ -5,6 +5,7 @@ import {
GenomeApiError,
fetchIntervalFeatures,
fetchVariantFeatures,
+ requestCoordinateSearch,
toGeneFeature,
toTranscriptFeature,
toVariantFeature,
@@ -142,6 +143,108 @@ describe('toVariantFeature', () => {
const feature = toVariantFeature({ id: 'var-2', chromosome: 'chr7', position: -5 })
expect(feature.position).toBe(0)
})
+
+ it('treats a string-typed position as invalid', () => {
+ const feature = toVariantFeature({ id: 'var-5', chromosome: 'chr7', position: '100' })
+ expect(feature.position).toBe(0)
+ })
+
+ it('leaves the strand undefined for unknown strands on genes', () => {
+ const feature = toGeneFeature({
+ id: 'gene-9',
+ chromosome: 'chr1',
+ start_position: 1,
+ end_position: 10,
+ strand: '?',
+ })
+ expect(feature.strand).toBeUndefined()
+ })
+})
+
+describe('requestCoordinateSearch', () => {
+ const interval = { chromosome: 'chr1', start: 1, end: 100 }
+
+ it('throws an AbortError when the signal is already aborted', async () => {
+ const controller = new AbortController()
+ controller.abort()
+ await expect(
+ requestCoordinateSearch('gene', interval, controller.signal, 100),
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ expect(globalThis.fetch).toBe(rawFetch)
+ })
+
+ it('stops immediately on an empty items page', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ items: [], pagination: { total_count: 50 } }))
+ globalThis.fetch = fetchMock as unknown as typeof fetch
+
+ const items = await requestCoordinateSearch('gene', interval, undefined, 100)
+ expect(items).toEqual([])
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops after the first page when pagination is missing', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ items: [{ id: 'a' }, { id: 'b' }],
+ }),
+ )
+ globalThis.fetch = fetchMock as unknown as typeof fetch
+
+ const items = await requestCoordinateSearch('gene', interval, undefined, 100)
+ expect(items).toHaveLength(2)
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops paging once the result cap is reached', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ items: Array.from({ length: 100 }, () => ({ id: 'x' })),
+ pagination: { page: 1, page_size: 100, total_count: 999_999 },
+ }),
+ )
+ globalThis.fetch = fetchMock as unknown as typeof fetch
+
+ const items = await requestCoordinateSearch('gene', interval, undefined, 100)
+ expect(items).toHaveLength(10_000)
+ expect(fetchMock).toHaveBeenCalledTimes(100)
+ })
+
+ it('forwards the page size into every request body', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse({ items: [], pagination: { page: 1, page_size: 25, total_count: 0 } }),
+ )
+ globalThis.fetch = fetchMock as unknown as typeof fetch
+
+ await requestCoordinateSearch('gene', interval, undefined, 25)
+ const body = JSON.parse(String((fetchMock.mock.calls[0] as [string, RequestInit])[1].body))
+ expect(body.pagination.page_size).toBe(25)
+ })
+
+ it('aborts between pages when the signal fires', async () => {
+ const controller = new AbortController()
+ const firstPage = Array.from({ length: 100 }, () => ({ id: 'x' }))
+ const fetchMock = vi.fn().mockImplementation(() => {
+ if (!controller.signal.aborted) {
+ controller.abort()
+ }
+ return Promise.resolve(
+ jsonResponse({
+ items: firstPage,
+ pagination: { page: 1, page_size: 100, total_count: 500 },
+ }),
+ )
+ })
+ globalThis.fetch = fetchMock as unknown as typeof fetch
+
+ await expect(
+ requestCoordinateSearch('gene', interval, controller.signal, 100),
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
})
describe('fetchIntervalFeatures', () => {
diff --git a/apps/web/src/lib/genome/region.test.ts b/apps/web/src/lib/genome/region.test.ts
index bdb5bec..329b023 100644
--- a/apps/web/src/lib/genome/region.test.ts
+++ b/apps/web/src/lib/genome/region.test.ts
@@ -75,4 +75,77 @@ describe('parseGenomeRegion', () => {
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.code).toBe('invalid_end')
})
+
+ it('accepts a single-base interval', () => {
+ const result = parseGenomeRegion('chr1:100-100')
+ expect(result).toEqual({ ok: true, interval: { chromosome: 'chr1', start: 100, end: 100 } })
+ })
+
+ it('accepts the maximum safe integer coordinate', () => {
+ const result = parseGenomeRegion(`chr1:1-${Number.MAX_SAFE_INTEGER}`)
+ expect(result.ok).toBe(true)
+ })
+
+ it('rejects a start beyond the safe integer range', () => {
+ const tooLarge = `${Number.MAX_SAFE_INTEGER}0`
+ const result = parseGenomeRegion(`chr1:${tooLarge}-200000`)
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error.code).toBe('invalid_start')
+ })
+
+ it('rejects a start of zero', () => {
+ const result = parseGenomeRegion('chr1:0-100')
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error.code).toBe('negative_start')
+ })
+
+ it('rejects an end of zero', () => {
+ const result = parseGenomeRegion('chr1:100-0')
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error.code).toBe('negative_end')
+ })
+
+ it('rejects empty and whitespace-only input as malformed', () => {
+ for (const input of ['', ' ']) {
+ const result = parseGenomeRegion(input)
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error.code).toBe('malformed')
+ }
+ })
+
+ it('normalizes mixed-case chromosomes', () => {
+ const upper = parseGenomeRegion('CHR1:1-100')
+ expect(upper.ok && upper.interval.chromosome).toBe('chr1')
+
+ const mixed = parseGenomeRegion('ChrX:1-100')
+ expect(mixed.ok && mixed.interval.chromosome).toBe('chrX')
+ })
+
+ it('accepts the mitochondrial chromosome', () => {
+ const result = parseGenomeRegion('chrM:1-100')
+ expect(result.ok && result.interval.chromosome).toBe('chrM')
+ })
+
+ it('accepts whitespace around the separator', () => {
+ const result = parseGenomeRegion('chr1:100 - 200')
+ expect(result.ok && result.interval).toEqual({ chromosome: 'chr1', start: 100, end: 200 })
+ })
+
+ it('rejects comma-separated coordinates as malformed', () => {
+ const result = parseGenomeRegion('chr1:100,000-200,000')
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error.code).toBe('malformed')
+ })
+
+ it('rejects a plus-signed coordinate as malformed', () => {
+ const result = parseGenomeRegion('chr1:+100-200')
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error.code).toBe('malformed')
+ })
+
+ it('rejects trailing junk after the interval as malformed', () => {
+ const result = parseGenomeRegion('chr1:1-100 extra')
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.error.code).toBe('malformed')
+ })
})
diff --git a/apps/web/src/lib/genome/tracks.test.ts b/apps/web/src/lib/genome/tracks.test.ts
index 93d6fd3..75d4a85 100644
--- a/apps/web/src/lib/genome/tracks.test.ts
+++ b/apps/web/src/lib/genome/tracks.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { featuresInViewport, layoutRows } from './tracks'
+import { DEFAULT_TRACK_CONFIG, TRACK_ROW_HEIGHT, featuresInViewport, layoutRows } from './tracks'
import type { GenomicFeature } from './types'
function feature(id: string, start: number, end: number): GenomicFeature {
@@ -8,6 +8,27 @@ function feature(id: string, start: number, end: number): GenomicFeature {
}
describe('layoutRows', () => {
+ it('stacks features whose ends touch the next start (one-based inclusive)', () => {
+ const rows = layoutRows([feature('a', 100, 200), feature('b', 200, 250)])
+ expect(rows).toHaveLength(2)
+ expect(rows[0].features.map((f) => f.id)).toEqual(['a'])
+ expect(rows[1].features.map((f) => f.id)).toEqual(['b'])
+ })
+
+ it('breaks ties on start and end by id deterministically', () => {
+ const rows = layoutRows([
+ feature('b', 100, 100),
+ feature('a', 100, 100),
+ feature('c', 100, 100),
+ ])
+ expect(rows.map((row) => row.features[0].id)).toEqual(['a', 'b', 'c'])
+ })
+
+ it('sets exact y offsets as the row index times the row height', () => {
+ const rows = layoutRows([feature('a', 1, 10), feature('b', 5, 15), feature('c', 9, 19)])
+ expect(rows.map((row) => row.yOffset)).toEqual([0, TRACK_ROW_HEIGHT, TRACK_ROW_HEIGHT * 2])
+ })
+
it('stacks overlapping features into separate rows', () => {
const rows = layoutRows([feature('a', 100, 200), feature('b', 150, 250)])
expect(rows).toHaveLength(2)
@@ -47,6 +68,10 @@ describe('featuresInViewport', () => {
expect(keep.map((f) => f.id)).toEqual(['a', 'b'])
})
+ it('handles an empty feature list', () => {
+ expect(featuresInViewport([], { chromosome: 'chr1', start: 1, end: 100 })).toEqual([])
+ })
+
it('drops features fully outside the range', () => {
const features = [feature('a', 1, 50), feature('b', 700, 900)]
const keep = featuresInViewport(features, { chromosome: 'chr1', start: 100, end: 800 })
@@ -63,3 +88,9 @@ describe('featuresInViewport', () => {
expect(keep.map((f) => f.id)).toEqual(['a'])
})
})
+
+describe('default track configuration', () => {
+ it('enables the genes and variants tracks by default', () => {
+ expect(DEFAULT_TRACK_CONFIG.enabled).toEqual({ genes: true, variants: true })
+ })
+})
diff --git a/apps/web/src/lib/genome/useGenomeBrowser.test.tsx b/apps/web/src/lib/genome/useGenomeBrowser.test.tsx
index 30f4a2a..15a4ae4 100644
--- a/apps/web/src/lib/genome/useGenomeBrowser.test.tsx
+++ b/apps/web/src/lib/genome/useGenomeBrowser.test.tsx
@@ -27,6 +27,9 @@ function RenderViewport() {
pan left
+
+ pan right
+
reset
@@ -130,6 +133,73 @@ describe('useGenomeBrowser', () => {
fireEvent.click(screen.getByTestId('reset'))
expect(screen.getByTestId('viewport').textContent).toBe('chr1:1-100')
})
+
+ it('pans right and returns to the initial viewport on reset', () => {
+ render( )
+ fireEvent.click(screen.getByTestId('pan-right'))
+ const [, coords] = (screen.getByTestId('viewport').textContent ?? '').split(':')
+ const [start, end] = coords.split('-').map(Number)
+ expect(end - start + 1).toBe(100)
+ expect(start).toBeGreaterThan(1)
+
+ fireEvent.click(screen.getByTestId('reset'))
+ expect(screen.getByTestId('viewport').textContent).toBe('chr1:1-100')
+ })
+
+ it('clamps a same-chromosome interval to base 1', async () => {
+ function Bounded() {
+ const browser = useGenomeBrowser({
+ initialViewport: {
+ chromosome: 'chr1',
+ start: 100,
+ end: 200,
+ bounds: { length: 500 },
+ },
+ })
+ return (
+
+
+ {browser.viewport.chromosome}:{browser.viewport.start}-{browser.viewport.end}
+
+ browser.navigateTo({ chromosome: 'chr1', start: -50, end: 50 })}
+ >
+ go
+
+
+ )
+ }
+ render( )
+ fireEvent.click(screen.getByTestId('navigate'))
+ await waitFor(() => expect(screen.getByTestId('viewport').textContent).toBe('chr1:1-50'))
+ })
+
+ it('stores an unbounded interval verbatim on an open-ended viewport', async () => {
+ function Open() {
+ const browser = useGenomeBrowser({
+ initialViewport: { chromosome: 'chr1', start: 1, end: 100 },
+ })
+ return (
+
+
+ {browser.viewport.chromosome}:{browser.viewport.start}-{browser.viewport.end}
+
+ browser.navigateTo({ chromosome: 'chr2', start: 300, end: 400 })}
+ >
+ go
+
+
+ )
+ }
+ render( )
+ fireEvent.click(screen.getByTestId('navigate'))
+ await waitFor(() => expect(screen.getByTestId('viewport').textContent).toBe('chr2:300-400'))
+ })
})
function RenderTrack({ definition }: { definition: GenomeTrackDefinition }) {
@@ -182,6 +252,116 @@ describe('useGenomeTrack', () => {
expect(screen.getByTestId('track-error').textContent).toBe('genes unavailable')
})
+ it('reports an empty status for a track with no features', async () => {
+ const loadGenes = vi.fn().mockResolvedValue([])
+ const definition: GenomeTrackDefinition = {
+ id: 'genes',
+ label: 'Genes',
+ kind: 'genes',
+ loader: loadGenes,
+ }
+
+ render( )
+
+ await waitFor(() => expect(screen.getByTestId('track-status').textContent).toBe('empty'))
+ })
+
+ it('collapses rapid viewport changes into a single refetch', async () => {
+ vi.useFakeTimers()
+ const loadGenes = vi.fn().mockResolvedValue([])
+ const definition: GenomeTrackDefinition = {
+ id: 'genes',
+ label: 'Genes',
+ kind: 'genes',
+ loader: loadGenes,
+ }
+
+ function BrowserWithTrack() {
+ const browser = useGenomeBrowser({
+ initialViewport: { chromosome: 'chr1', start: 1, end: 100 },
+ debounceMs: 200,
+ })
+ const track = useGenomeTrack(definition, browser.debouncedViewport)
+ return (
+
+ {track.status}
+
+ zoom in
+
+
+ )
+ }
+
+ render( )
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(loadGenes).toHaveBeenCalledTimes(1)
+
+ fireEvent.click(screen.getByTestId('zoom-in'))
+ fireEvent.click(screen.getByTestId('zoom-in'))
+ fireEvent.click(screen.getByTestId('zoom-in'))
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100)
+ })
+ expect(loadGenes).toHaveBeenCalledTimes(1)
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100)
+ })
+ expect(loadGenes).toHaveBeenCalledTimes(2)
+
+ vi.useRealTimers()
+ })
+
+ it('aborts an in-flight track request when the region changes', async () => {
+ vi.useFakeTimers()
+ let capturedSignal: AbortSignal | undefined
+ const loadGenes = vi.fn((_interval: unknown, signal: AbortSignal) => {
+ if (capturedSignal === undefined) capturedSignal = signal
+ return new Promise(() => undefined)
+ })
+ const definition: GenomeTrackDefinition = {
+ id: 'genes',
+ label: 'Genes',
+ kind: 'genes',
+ loader: loadGenes,
+ }
+
+ function BrowserWithTrack() {
+ const browser = useGenomeBrowser({
+ initialViewport: { chromosome: 'chr1', start: 1, end: 100 },
+ debounceMs: 200,
+ })
+ const track = useGenomeTrack(definition, browser.debouncedViewport)
+ return (
+
+ {track.status}
+
+ zoom in
+
+
+ )
+ }
+
+ render( )
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(loadGenes).toHaveBeenCalledTimes(1)
+ expect(capturedSignal?.aborted).toBe(false)
+
+ fireEvent.click(screen.getByTestId('zoom-in'))
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300)
+ })
+ expect(loadGenes).toHaveBeenCalledTimes(2)
+ expect(capturedSignal?.aborted).toBe(true)
+
+ vi.useRealTimers()
+ })
+
it('refetches when the debounced viewport settles on a new region', async () => {
vi.useFakeTimers()
const loadGenes = vi.fn().mockResolvedValue([])
diff --git a/apps/web/src/lib/genome/viewport.test.ts b/apps/web/src/lib/genome/viewport.test.ts
index fbcf4d6..83e8b18 100644
--- a/apps/web/src/lib/genome/viewport.test.ts
+++ b/apps/web/src/lib/genome/viewport.test.ts
@@ -88,6 +88,12 @@ describe('zoomViewport', () => {
const viewport = { start: 1, end: 100, bounds: undefined }
expect(zoomViewport(viewport, 0)).toBe(viewport)
expect(zoomViewport(viewport, Number.NaN)).toBe(viewport)
+ expect(zoomViewport(viewport, -1)).toBe(viewport)
+ })
+
+ it('preserves the bounds on the returned window', () => {
+ const viewport = { start: 100, end: 200, bounds: { length: 1000 } }
+ expect(zoomViewport(viewport, 0.5).bounds).toEqual({ length: 1000 })
})
})
@@ -113,6 +119,18 @@ describe('panViewport', () => {
expect(panned.start).toBe(150)
})
+ it('never produces a start below base 1 when the window is wider than the contig', () => {
+ const viewport = { start: 1, end: 1000, bounds: { length: 500 } }
+ const panned = panViewport(viewport, 100)
+ expect(panned.start).toBeGreaterThanOrEqual(1)
+ expect(panned.end).toBe(500)
+ })
+
+ it('preserves the bounds on the returned window', () => {
+ const viewport = { start: 100, end: 200, bounds: { length: 1000 } }
+ expect(panViewport(viewport, 50).bounds).toEqual({ length: 1000 })
+ })
+
it('pans unbounded windows without changing width', () => {
const viewport = { start: 100, end: 200, bounds: undefined }
const panned = panViewport(viewport, 30)
diff --git a/apps/web/src/lib/genome/viewport.ts b/apps/web/src/lib/genome/viewport.ts
index cd7b35b..5b77fa0 100644
--- a/apps/web/src/lib/genome/viewport.ts
+++ b/apps/web/src/lib/genome/viewport.ts
@@ -112,7 +112,7 @@ export function panViewport(viewport: V, delta: number
}
if (endLimit !== undefined && end > endLimit) {
end = endLimit
- start = end - span + 1
+ start = Math.max(1, end - span + 1)
}
return { ...viewport, start, end, bounds: viewport.bounds } as V
diff --git a/apps/web/src/lib/scientific/advancedApi.test.ts b/apps/web/src/lib/scientific/advancedApi.test.ts
index 03e91a4..a41a9f4 100644
--- a/apps/web/src/lib/scientific/advancedApi.test.ts
+++ b/apps/web/src/lib/scientific/advancedApi.test.ts
@@ -1,9 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
+import { API_BASE_URL, GenomeApiError } from '@/lib/genome/api'
+
import { TP53_PATHWAY_HEATMAP_FIXTURE } from './advanced.fixtures'
import {
coverageFromRecords,
distributionFromRecords,
+ fetchCoverageDataset,
+ fetchDistributionDataset,
+ fetchHeatmapDataset,
+ fetchVolcanoDataset,
heatmapFromRecords,
toCoverageBin,
toDistributionValue,
@@ -11,7 +17,18 @@ import {
volcanoFromRecords,
} from './advancedApi'
+const rawFetch = globalThis.fetch
+
+function jsonResponse(payload: unknown) {
+ return {
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(payload),
+ } as Response
+}
+
afterEach(() => {
+ globalThis.fetch = rawFetch
vi.restoreAllMocks()
})
@@ -141,3 +158,88 @@ describe('toDistributionValue / distributionFromRecords', () => {
expect(distribution?.values.map((value) => value.group)).toEqual(['Normal', 'Tumor'])
})
})
+
+describe('fetch*Dataset adapters', () => {
+ it('fetches and normalizes a heatmap dataset', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ id: 'h-live',
+ title: 'Live',
+ rows: ['b', 'a'],
+ columns: ['y', 'x'],
+ values: [
+ [1, 2],
+ [3, 4],
+ ],
+ }),
+ )
+ globalThis.fetch = fetchMock as unknown as typeof fetch
+
+ const dataset = await fetchHeatmapDataset('h-live', new AbortController().signal)
+ expect(dataset.rows).toEqual(['a', 'b'])
+ expect(dataset.columns).toEqual(['x', 'y'])
+
+ const [url] = fetchMock.mock.calls[0] as [string]
+ expect(url).toBe(`${API_BASE_URL}/advanced/heatmaps/h-live`)
+ })
+
+ it('throws a typed GenomeApiError on non-2xx responses', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 } as unknown as Response)
+
+ await expect(fetchHeatmapDataset('missing')).rejects.toMatchObject({
+ name: 'GenomeApiError',
+ status: 404,
+ })
+ })
+
+ it('rethrows an AbortError from an unreadable response', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () => Promise.reject(new DOMException('aborted', 'AbortError')),
+ } as unknown as Response)
+
+ await expect(fetchVolcanoDataset('v')).rejects.toMatchObject({ name: 'AbortError' })
+ })
+
+ it('throws a GenomeApiError when the payload is invalid', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ items: [] }))
+
+ await expect(fetchDistributionDataset('d-bad')).rejects.toBeInstanceOf(GenomeApiError)
+ })
+
+ it('normalizes volcano, coverage, and distribution payloads', async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'v-live',
+ title: 'V',
+ points: [{ identifier: 'g1', effect_size: 1.5, significance: 4 }],
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'c-live',
+ title: 'C',
+ bins: [{ chromosome: 'chr1', start: 1, end: 10, coverage: 3 }],
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: 'd-live',
+ title: 'D',
+ values: [{ group: 'Tumor', value: 2 }],
+ }),
+ )
+
+ const volcano = await fetchVolcanoDataset('v-live')
+ expect(volcano.points[0]?.effectSize).toBe(1.5)
+
+ const coverage = await fetchCoverageDataset('c-live')
+ expect(coverage.bins[0]?.coverage).toBe(3)
+
+ const distribution = await fetchDistributionDataset('d-live')
+ expect(distribution.values[0]?.value).toBe(2)
+ })
+})
diff --git a/apps/web/src/lib/scientific/downsample.test.ts b/apps/web/src/lib/scientific/downsample.test.ts
index 4c82a02..ff7a947 100644
--- a/apps/web/src/lib/scientific/downsample.test.ts
+++ b/apps/web/src/lib/scientific/downsample.test.ts
@@ -36,6 +36,39 @@ describe('decimateItems', () => {
expect(sampled.length).toBeLessThan(items.length)
expect(sampled).toEqual([...sampled].sort((a, b) => a - b))
})
+
+ it('returns the same array when the length exactly matches the limit', () => {
+ const items = [1, 2, 3, 4, 5]
+ expect(decimateItems(items, 5)).toBe(items)
+ })
+
+ it('returns the same (empty) array for an empty input', () => {
+ const items: number[] = []
+ expect(decimateItems(items, 5)).toBe(items)
+ })
+
+ it('returns an empty sample for a negative limit', () => {
+ expect(decimateItems([1, 2, 3], -2)).toEqual([])
+ })
+
+ it('returns exactly the first element when the limit is one', () => {
+ const sampled = decimateItems([1, 2, 3, 4], 1)
+ expect([...sampled]).toEqual([1])
+ })
+
+ it('returns exactly the first and last elements when the limit is two', () => {
+ const sampled = decimateItems([1, 2, 3, 4, 5, 6, 7], 2)
+ expect([...sampled]).toEqual([1, 7])
+ })
+
+ it('samples odd-sized inputs deterministically with first and last preserved', () => {
+ const items = [0, 1, 2, 3, 4, 5, 6]
+ const sampled = [...decimateItems(items, 3)]
+ expect(sampled[0]).toBe(0)
+ expect(sampled[sampled.length - 1]).toBe(6)
+ expect(sampled.length).toBeLessThanOrEqual(3)
+ expect(sampled).toEqual([...sampled].sort((a, b) => a - b))
+ })
})
describe('coverageColumns', () => {
@@ -96,6 +129,78 @@ describe('coverageColumns', () => {
}))
expect(coverageColumns(bins, toX, 300, 80)).toEqual(coverageColumns(bins, toX, 300, 80))
})
+
+ it('returns the same bins for a non-positive column limit or plot width', () => {
+ const bins: CoverageBin[] = [
+ { chromosome: 'chr1', start: 1, end: 10, coverage: 5 },
+ { chromosome: 'chr1', start: 11, end: 20, coverage: 8 },
+ ]
+ expect(coverageColumns(bins, toX, 100, 0)).toBe(bins)
+ expect(coverageColumns(bins, toX, 0, 50)).toBe(bins)
+ expect(coverageColumns(bins, toX, -1, 50)).toBe(bins)
+ })
+
+ it('returns the same bins when the count exactly matches the column limit', () => {
+ const bins: CoverageBin[] = [
+ { chromosome: 'chr1', start: 1, end: 10, coverage: 5 },
+ { chromosome: 'chr1', start: 11, end: 20, coverage: 8 },
+ ]
+ expect(coverageColumns(bins, toX, 100, 2)).toBe(bins)
+ })
+
+ it('collapses the whole track into one column carrying the global peak when the limit is one', () => {
+ const bins: CoverageBin[] = Array.from({ length: 100 }, (_, index) => ({
+ chromosome: 'chr1',
+ start: index * 10 + 1,
+ end: index * 10 + 10,
+ coverage: index % 7,
+ }))
+ const columns = coverageColumns(bins, toX, 200, 1)
+ expect(columns).toHaveLength(1)
+ expect(columns[0]?.coverage).toBe(6)
+ expect(columns[0]?.start).toBe(1)
+ expect(columns[0]?.end).toBe(1000)
+ })
+
+ it('merges bins into the span of the whole bucket and keeps the first chromosome', () => {
+ const bins: CoverageBin[] = [
+ { chromosome: 'chr1', start: 10, end: 20, coverage: 1 },
+ { chromosome: 'chr2', start: 30, end: 40, coverage: 5 },
+ { chromosome: 'chr1', start: 50, end: 60, coverage: 2 },
+ ]
+ const columns = coverageColumns(bins, toX, 10, 1)
+ expect(columns).toHaveLength(1)
+ expect(columns[0]?.start).toBe(10)
+ expect(columns[0]?.end).toBe(60)
+ expect(columns[0]?.chromosome).toBe('chr1')
+ })
+
+ it('returns columns sorted ascending by start', () => {
+ const bins: CoverageBin[] = Array.from({ length: 500 }, (_, index) => ({
+ chromosome: 'chr1',
+ start: index * 5 + 1,
+ end: index * 5 + 5,
+ coverage: 1,
+ }))
+ const columns = coverageColumns(bins, toX, 50, 10)
+ const starts = columns.map((column) => column.start)
+ expect(starts).toEqual([...starts].sort((a, b) => a - b))
+ })
+
+ it('clamps bins mapped to negative pixels into the first bucket', () => {
+ const toNegative = () => -5
+ const bins: CoverageBin[] = Array.from({ length: 8 }, (_, index) => ({
+ chromosome: 'chr1',
+ start: index * 10 + 1,
+ end: index * 10 + 10,
+ coverage: index % 4,
+ }))
+ const columns = coverageColumns(bins, toNegative, 100, 5)
+ expect(columns).toHaveLength(1)
+ expect(columns[0]?.coverage).toBe(3)
+ expect(columns[0]?.start).toBe(1)
+ expect(columns[0]?.end).toBe(80)
+ })
})
describe('aggregateHeatmap', () => {
@@ -163,4 +268,123 @@ describe('aggregateHeatmap', () => {
// Whole matrix collapses to one block; finite values are 1 and 3.
expect(aggregated.values[0]?.[0]).toBe(2)
})
+
+ it('collapses a whole matrix to one block for non-positive limits', () => {
+ const dataset: HeatmapDataset = {
+ id: 'h',
+ title: 'All',
+ rows: ['r1', 'r2', 'r3'],
+ columns: ['c1', 'c2', 'c3'],
+ values: [
+ [1, 2, 3],
+ [4, 5, 6],
+ [7, 8, 9],
+ ],
+ }
+ for (const maxRows of [0, -1]) {
+ for (const maxCols of [0, -1]) {
+ const aggregated = aggregateHeatmap(dataset, maxRows, maxCols)
+ expect(aggregated.rows).toEqual(['r1'])
+ expect(aggregated.columns).toEqual(['c1'])
+ expect(aggregated.values[0]).toEqual([5])
+ }
+ }
+ })
+
+ it('aggregates along a single axis when only the other is oversized', () => {
+ const dataset: HeatmapDataset = {
+ id: 'h',
+ title: 'ColumnsOnly',
+ rows: ['r1', 'r2'],
+ columns: ['c1', 'c2', 'c3', 'c4'],
+ values: [
+ [1, 2, 3, 4],
+ [5, 6, 7, 8],
+ ],
+ }
+ const rowsOnly = aggregateHeatmap(dataset, 1, 100)
+ expect(rowsOnly.rows).toEqual(['r1'])
+ expect(rowsOnly.columns).toEqual(['c1', 'c2', 'c3', 'c4'])
+ expect(rowsOnly.values[0]).toEqual([3, 4, 5, 6])
+
+ const colsOnly = aggregateHeatmap(dataset, 100, 2)
+ expect(colsOnly.rows).toEqual(['r1', 'r2'])
+ expect(colsOnly.columns).toEqual(['c1', 'c3'])
+ expect(colsOnly.values[0]).toEqual([1.5, 3.5])
+ expect(colsOnly.values[1]).toEqual([5.5, 7.5])
+ })
+
+ it('skips NaN and Infinity values when averaging a block', () => {
+ const dataset: HeatmapDataset = {
+ id: 'h',
+ title: 'NonFinite',
+ rows: ['r1', 'r2'],
+ columns: ['c1'],
+ values: [[Number.NaN], [6]],
+ }
+ const aggregated = aggregateHeatmap(dataset, 1, 1)
+ expect(aggregated.values[0]?.[0]).toBe(6)
+
+ const infinite: HeatmapDataset = {
+ id: 'h2',
+ title: 'Infinite',
+ rows: ['r1', 'r2'],
+ columns: ['c1'],
+ values: [[Number.POSITIVE_INFINITY], [undefined]],
+ }
+ const aggregatedInfinite = aggregateHeatmap(infinite, 1, 1)
+ expect(aggregatedInfinite.values[0]?.[0]).toBeUndefined()
+ })
+
+ it('averages ragged boundary blocks', () => {
+ const dataset: HeatmapDataset = {
+ id: 'h',
+ title: 'Ragged',
+ rows: ['r1', 'r2', 'r3', 'r4', 'r5'],
+ columns: ['c1', 'c2', 'c3', 'c4', 'c5'],
+ values: Array.from({ length: 5 }, (_, r) =>
+ Array.from({ length: 5 }, (_, c) => r * 5 + c + 1),
+ ),
+ }
+ const aggregated = aggregateHeatmap(dataset, 2, 2)
+ // Block size ceil(5/2) = 3, so rows/columns sample at indices 0 and 3.
+ expect(aggregated.rows).toEqual(['r1', 'r4'])
+ expect(aggregated.columns).toEqual(['c1', 'c4'])
+ // Bottom-right block spans rows r4..r5, columns c4..c5: mean(19,20,24,25) = 22.
+ expect(aggregated.values[1]?.[1]).toBe(22)
+ })
+
+ it('tolerates ragged rows with missing value arrays', () => {
+ const dataset: HeatmapDataset = {
+ id: 'h',
+ title: 'MissingRow',
+ rows: ['r1', 'r2'],
+ columns: ['c1', 'c2'],
+ values: [[1, 2], undefined],
+ } as HeatmapDataset
+ const aggregated = aggregateHeatmap(dataset, 1, 1)
+ expect(aggregated.values[0]?.[0]).toBe(1.5)
+ })
+
+ it('propagates id, title, labels, and metadata through aggregation', () => {
+ const dataset: HeatmapDataset = {
+ id: 'h-id',
+ title: 'Named',
+ rows: ['r1', 'r2'],
+ columns: ['c1', 'c2'],
+ values: [
+ [1, 2],
+ [3, 4],
+ ],
+ rowLabels: { r1: 'Row One' },
+ columnLabels: { c1: 'Col One' },
+ metadata: { source: 'fixture' },
+ }
+ const aggregated = aggregateHeatmap(dataset, 1, 1)
+ expect(aggregated.id).toBe('h-id')
+ expect(aggregated.title).toBe('Named')
+ expect(aggregated.rowLabels).toEqual({ r1: 'Row One' })
+ expect(aggregated.columnLabels).toEqual({ c1: 'Col One' })
+ expect(aggregated.metadata).toEqual({ source: 'fixture' })
+ })
})
diff --git a/apps/web/src/lib/scientific/statistics.test.ts b/apps/web/src/lib/scientific/statistics.test.ts
index 4559a94..1bb5d48 100644
--- a/apps/web/src/lib/scientific/statistics.test.ts
+++ b/apps/web/src/lib/scientific/statistics.test.ts
@@ -28,6 +28,13 @@ describe('quantile', () => {
expect(quantile([1, 2, 3, 4], 0.5)).toBe(2.5)
})
+ it('interpolates at fractional ranks (R-7 convention)', () => {
+ // position = 0.25 * 3 = 0.75 → 1 + (2 - 1) * 0.75 = 1.75
+ expect(quantile([1, 2, 3, 4], 0.25)).toBe(1.75)
+ // position = 0.75 * 3 = 2.25 → 3 + (4 - 3) * 0.25 = 3.25
+ expect(quantile([1, 2, 3, 4], 0.75)).toBe(3.25)
+ })
+
it('returns min/max for q=0 and q=1', () => {
expect(quantile([1, 2, 3], 0)).toBe(1)
expect(quantile([1, 2, 3], 1)).toBe(3)
@@ -61,6 +68,24 @@ describe('summarize', () => {
it('returns undefined for an empty sample', () => {
expect(summarize([])).toBeUndefined()
})
+
+ it('summarizes a single-element sample with a zero IQR', () => {
+ const summary = summarize([7])
+ expect(summary).toEqual({
+ count: 1,
+ mean: 7,
+ min: 7,
+ max: 7,
+ q1: 7,
+ q2: 7,
+ q3: 7,
+ iqr: 0,
+ })
+ })
+
+ it('ignores non-finite values while summarizing', () => {
+ expect(summarize([1, Number.NaN, Number.POSITIVE_INFINITY, 3])?.count).toBe(2)
+ })
})
describe('boxPlotWhiskers', () => {
@@ -82,4 +107,17 @@ describe('boxPlotWhiskers', () => {
it('returns undefined for an empty sample', () => {
expect(boxPlotWhiskers([])).toBeUndefined()
})
+
+ it('reports low-side outliers below the lower whisker', () => {
+ const whiskers = boxPlotWhiskers([-100, 1, 2, 3, 4, 5, 6, 7, 8])
+ expect(whiskers?.lower).toBe(1)
+ expect(whiskers?.outliers).toEqual([-100])
+ })
+
+ it('collapses whiskers when every value is identical', () => {
+ const whiskers = boxPlotWhiskers([5, 5, 5, 5])
+ expect(whiskers?.lower).toBe(5)
+ expect(whiskers?.upper).toBe(5)
+ expect(whiskers?.outliers).toEqual([])
+ })
})
diff --git a/apps/web/src/lib/scientific/useChartSize.test.tsx b/apps/web/src/lib/scientific/useChartSize.test.tsx
new file mode 100644
index 0000000..98bc7ba
--- /dev/null
+++ b/apps/web/src/lib/scientific/useChartSize.test.tsx
@@ -0,0 +1,109 @@
+import { act, cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { DEFAULT_CHART_WIDTH } from './geometry'
+import { useChartSize } from './useChartSize'
+
+function Probe({ height, fallbackWidth }: { height: number; fallbackWidth?: number }) {
+ const size = useChartSize(height, fallbackWidth)
+ return (
+ <>
+
+ {size.width}
+
+ {size.height}
+ >
+ )
+}
+
+class FakeResizeObserver {
+ static instances: FakeResizeObserver[] = []
+ callback: ResizeObserverCallback
+ observed: Element[] = []
+ disconnected = false
+
+ constructor(callback: ResizeObserverCallback) {
+ this.callback = callback
+ FakeResizeObserver.instances.push(this)
+ }
+
+ observe(target: Element) {
+ this.observed.push(target)
+ }
+
+ unobserve() {}
+
+ disconnect() {
+ this.disconnected = true
+ }
+}
+
+const realResizeObserver = globalThis.ResizeObserver
+
+afterEach(() => {
+ cleanup()
+ globalThis.ResizeObserver = realResizeObserver
+ FakeResizeObserver.instances = []
+ vi.restoreAllMocks()
+})
+
+describe('useChartSize', () => {
+ it('uses the default fallback width and fixed height before measurement', () => {
+ render( )
+ expect(screen.getByTestId('width').textContent).toBe(String(DEFAULT_CHART_WIDTH))
+ expect(screen.getByTestId('height').textContent).toBe('400')
+ })
+
+ it('honours a custom fallback width', () => {
+ render( )
+ expect(screen.getByTestId('width').textContent).toBe('640')
+ })
+
+ it('measures the container width once when ResizeObserver is unavailable', () => {
+ Object.defineProperty(globalThis, 'ResizeObserver', { value: undefined, configurable: true })
+ const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(777)
+
+ render( )
+ expect(screen.getByTestId('width').textContent).toBe('777')
+ clientWidth.mockRestore()
+ })
+
+ it('observes the element and updates on a resize when ResizeObserver exists', () => {
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
+ const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
+
+ clientWidth.mockReturnValue(500)
+ render( )
+ expect(screen.getByTestId('width').textContent).toBe('500')
+ expect(FakeResizeObserver.instances).toHaveLength(1)
+ expect(FakeResizeObserver.instances[0].observed.length).toBe(1)
+
+ clientWidth.mockReturnValue(900)
+ act(() => {
+ FakeResizeObserver.instances[0].callback([], FakeResizeObserver.instances[0])
+ })
+ expect(screen.getByTestId('width').textContent).toBe('900')
+
+ clientWidth.mockRestore()
+ })
+
+ it('never overwrites the fallback width with a zero measurement', () => {
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
+ const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(0)
+
+ render( )
+ expect(screen.getByTestId('width').textContent).toBe(String(DEFAULT_CHART_WIDTH))
+
+ clientWidth.mockRestore()
+ })
+
+ it('disconnects the observer on unmount', () => {
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(500)
+
+ const { unmount } = render( )
+ expect(FakeResizeObserver.instances[0].disconnected).toBe(false)
+ unmount()
+ expect(FakeResizeObserver.instances[0].disconnected).toBe(true)
+ })
+})
diff --git a/apps/web/src/lib/scientific/useVolcanoPlot.test.tsx b/apps/web/src/lib/scientific/useVolcanoPlot.test.tsx
index 48fdf99..3be4c6b 100644
--- a/apps/web/src/lib/scientific/useVolcanoPlot.test.tsx
+++ b/apps/web/src/lib/scientific/useVolcanoPlot.test.tsx
@@ -99,4 +99,37 @@ describe('useVolcanoPlot', () => {
act(() => captured.model.clearSelection())
await waitFor(() => expect(captured.model.selectedKey).toBeNull())
})
+
+ it('clears the selection when a new dataset loads', async () => {
+ const first = {
+ id: 'a',
+ title: 'A',
+ points: [{ identifier: 'g1', effect_size: 1, significance: 2 }],
+ }
+ const second = {
+ id: 'b',
+ title: 'B',
+ points: [{ identifier: 'g1', effect_size: -1, significance: 3 }],
+ }
+ const loader = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second)
+ const captured = renderHook({ loader })
+ await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success'))
+ await act(async () => {})
+ act(() => captured.model.selectPoint('g1'))
+ await waitFor(() => expect(captured.model.selectedKey).toBe('g1'))
+
+ act(() => captured.model.refetch())
+ await waitFor(() => expect(captured.model.dataset?.id).toBe('b'))
+ await waitFor(() => expect(captured.model.selectedKey).toBeNull())
+ })
+
+ it('clears the selection when passed null', async () => {
+ const loader = vi.fn(async () => DIFFERENTIAL_EXPRESSION_VOLCANO_FIXTURE)
+ const captured = renderHook({ loader })
+ await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success'))
+ act(() => captured.model.selectPoint('TP53'))
+ await waitFor(() => expect(captured.model.selectedKey).toBe('TP53'))
+ act(() => captured.model.selectPoint(null))
+ await waitFor(() => expect(captured.model.selectedKey).toBeNull())
+ })
})
diff --git a/apps/web/src/lib/visualization/useVisualizationData.test.tsx b/apps/web/src/lib/visualization/useVisualizationData.test.tsx
index 6fd1768..e67b946 100644
--- a/apps/web/src/lib/visualization/useVisualizationData.test.tsx
+++ b/apps/web/src/lib/visualization/useVisualizationData.test.tsx
@@ -106,6 +106,63 @@ describe('useVisualizationData', () => {
expect(screen.getByTestId('data').textContent).toBe('"second"')
})
+ it('ignores a stale error when a newer request already succeeded', async () => {
+ const first = deferred()
+ const second = deferred()
+ let callCount = 0
+ const loader = () => (callCount++ === 0 ? first.promise : second.promise)
+
+ render( )
+ await waitFor(() => expect(statusText()).toBe('loading'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'refetch' }))
+
+ await act(async () => {
+ second.resolve('second')
+ })
+ expect(statusText()).toBe('success')
+
+ await act(async () => {
+ first.reject(new Error('stale failure'))
+ })
+ expect(statusText()).toBe('success')
+ expect(screen.getByTestId('error').textContent).toBe('')
+ })
+
+ it('normalizes string and object rejections into error messages', async () => {
+ render( Promise.reject('plain string failure')} />)
+ await waitFor(() => expect(statusText()).toBe('error'))
+ expect(screen.getByTestId('error').textContent).toBe('plain string failure')
+ cleanup()
+
+ render( Promise.reject({ message: 'object failure' })} />)
+ await waitFor(() => expect(statusText()).toBe('error'))
+ expect(screen.getByTestId('error').textContent).toBe('object failure')
+ })
+
+ it('clears the previous data while a refetch is loading', async () => {
+ const first = deferred()
+ const second = deferred()
+ let callCount = 0
+ const loader = () => (callCount++ === 0 ? first.promise : second.promise)
+
+ render( )
+ await act(async () => {
+ first.resolve('loaded')
+ })
+ await waitFor(() => expect(statusText()).toBe('success'))
+ expect(screen.getByTestId('data').textContent).toBe('"loaded"')
+
+ fireEvent.click(screen.getByRole('button', { name: 'refetch' }))
+ expect(statusText()).toBe('loading')
+ expect(screen.getByTestId('data').textContent).toBe('')
+
+ await act(async () => {
+ second.resolve('reloaded')
+ })
+ expect(screen.getByTestId('data').textContent).toBe('"reloaded"')
+ })
+
it('does not surface an error when the request is aborted by the hook', async () => {
let capturedSignal: AbortSignal | undefined
const loader = (signal: AbortSignal) => {
diff --git a/apps/web/src/lib/visualization/visualizationModules.test.ts b/apps/web/src/lib/visualization/visualizationModules.test.ts
new file mode 100644
index 0000000..633801b
--- /dev/null
+++ b/apps/web/src/lib/visualization/visualizationModules.test.ts
@@ -0,0 +1,83 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { fetchVisualizationModules } from './visualizationModules'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('fetchVisualizationModules', () => {
+ it('resolves the demo catalog with every delivered module', async () => {
+ const modules = await fetchVisualizationModules(new AbortController().signal, { delayMs: 0 })
+
+ expect(modules.map((module) => module.id)).toEqual([
+ 'genome-browser',
+ 'gene-transcript-viewer',
+ 'variant-viewer',
+ 'protein-viewer',
+ 'network-viewer',
+ 'scientific-charts',
+ 'advanced-scientific-charts',
+ 'integrated-research-workspace',
+ 'performance-large-datasets',
+ 'testing-documentation',
+ ])
+ for (const module of modules) {
+ expect(module.title).toBeTruthy()
+ expect(module.milestone).toMatch(/^6\.\d+$/)
+ expect(module.source.kind).toBe('api')
+ }
+ })
+
+ it('returns a fresh copy of the catalog, not the internal array', async () => {
+ const signal = new AbortController().signal
+ const first = await fetchVisualizationModules(signal, { delayMs: 0 })
+ const second = await fetchVisualizationModules(signal, { delayMs: 0 })
+ expect(first).not.toBe(second)
+ first.pop()
+ expect((await fetchVisualizationModules(signal, { delayMs: 0 })).length).toBe(10)
+ })
+
+ it('honours the simulated latency before resolving', async () => {
+ vi.useFakeTimers()
+ const signal = new AbortController().signal
+ const promise = fetchVisualizationModules(signal, { delayMs: 250 })
+ const spy = vi.fn()
+ promise.then(spy)
+
+ await vi.advanceTimersByTimeAsync(200)
+ expect(spy).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(50)
+ expect(spy).toHaveBeenCalledTimes(1)
+ vi.useRealTimers()
+ })
+
+ it('rejects with the configured failure message', async () => {
+ const signal = new AbortController().signal
+ await expect(
+ fetchVisualizationModules(signal, { delayMs: 0, failWith: 'catalog down' }),
+ ).rejects.toThrow('catalog down')
+ })
+
+ it('rejects with an AbortError when the signal is already aborted', async () => {
+ const controller = new AbortController()
+ controller.abort()
+ await expect(
+ fetchVisualizationModules(controller.signal, { delayMs: 0 }),
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ })
+
+ it('rejects with an AbortError and stops the timer when aborted while pending', async () => {
+ vi.useFakeTimers()
+ const controller = new AbortController()
+ const promise = fetchVisualizationModules(controller.signal, { delayMs: 1000 })
+ const assertion = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
+
+ controller.abort()
+ await vi.runAllTimersAsync()
+
+ await assertion
+ vi.useRealTimers()
+ })
+})
diff --git a/apps/web/src/lib/visualization/visualizationModules.ts b/apps/web/src/lib/visualization/visualizationModules.ts
index 976fba2..914db8a 100644
--- a/apps/web/src/lib/visualization/visualizationModules.ts
+++ b/apps/web/src/lib/visualization/visualizationModules.ts
@@ -1,15 +1,15 @@
import type { VisualizationDataSource, VisualizationMetadata } from './types'
/**
- * Placeholder catalog for the planned visualization modules.
+ * Catalog of the visualization modules delivered across Phase 6.
*
- * Phase 6.1 is foundation only — this data describes future modules so the
- * architecture (data flow through the visualization foundation) can be
- * demonstrated end to end. None of these modules are implemented yet.
+ * This started as a placeholder catalog for the Phase 6.1 foundation-only
+ * demo. It now reflects the modules actually implemented in Phase 6.2–6.11, so
+ * the demo catalog is an accurate map of the platform.
*/
export interface VisualizationModule extends VisualizationMetadata {
source: VisualizationDataSource
- /** Roadmap milestone that will deliver this module (e.g. `6.2`). */
+ /** Roadmap milestone that delivered this module (e.g. `6.2`). */
milestone: string
}
@@ -59,6 +59,37 @@ const MODULES: readonly VisualizationModule[] = [
milestone: '6.7',
source: { kind: 'api', reference: '/api/visualization/charts' },
},
+ {
+ id: 'advanced-scientific-charts',
+ title: 'Advanced Scientific Charts',
+ description:
+ 'Expression heatmap, volcano plot, genomic coverage, and statistical distribution charts.',
+ milestone: '6.8',
+ source: { kind: 'api', reference: '/api/visualization/advanced-charts' },
+ },
+ {
+ id: 'integrated-research-workspace',
+ title: 'Integrated Research Workspace',
+ description:
+ 'One research UI assembling the Phase 6.2–6.8 viewers around a shared genomic context.',
+ milestone: '6.9',
+ source: { kind: 'api', reference: '/api/visualization/workspace' },
+ },
+ {
+ id: 'performance-large-datasets',
+ title: 'Performance & Large-Dataset Handling',
+ description:
+ 'Deterministic downsampling, aggregation, and per-render work reduction for large datasets.',
+ milestone: '6.10',
+ source: { kind: 'api', reference: '/api/visualization/performance' },
+ },
+ {
+ id: 'testing-documentation',
+ title: 'Testing & Documentation',
+ description: 'Platform-wide coverage audit, edge-case tests, and reconciled documentation.',
+ milestone: '6.11',
+ source: { kind: 'api', reference: '/api/visualization/testing' },
+ },
]
export interface FetchVisualizationModulesOptions {
@@ -69,12 +100,12 @@ export interface FetchVisualizationModulesOptions {
}
/**
- * Placeholder loader for the visualization module catalog.
+ * Demo loader for the visualization module catalog.
*
* Resolves a typed list of `VisualizationModule`s after a short simulated
- * delay. This stands in for a future API endpoint — later milestones will
- * replace it with a real GenomeAI API/SDK-backed loader while the
- * component and data-layer contracts stay the same.
+ * delay. This stands in for a future API endpoint — production callers will
+ * replace it with a real GenomeAI API/SDK-backed loader while the component
+ * and data-layer contracts stay the same.
*/
export function fetchVisualizationModules(
signal: AbortSignal,
diff --git a/docs/visualization/README.md b/docs/visualization/README.md
index 0db6a05..435140e 100644
--- a/docs/visualization/README.md
+++ b/docs/visualization/README.md
@@ -19,6 +19,15 @@ and keeping the Genome Browser viewport-scoped — while preserving correctness,
accessibility, and scientific meaning. See
[Performance](./performance.md).
+**Phase 6.11 — Visualization Testing & Documentation** is implemented: a
+testing and documentation pass over the whole Phase 6 platform. It audits
+coverage across every Phase 6 module, adds behavior/correctness/edge tests
+where they were thin (foundation, genome, scientific, and workspace modules),
+fixes latent bugs the audit uncovered (viewport pan clamping), adds focused
+accessibility and component tests for previously untested chart primitives and
+workspace panels, and reconciles the docs with the shipped platform. See
+[Testing](./testing.md) and the [Roadmap](./roadmap.md).
+
| Milestone | Description | Status |
|-----------|-------------|--------|
| 6.1 | Visualization Foundation | ✅ Implemented |
@@ -31,7 +40,7 @@ accessibility, and scientific meaning. See
| 6.8 | Advanced Scientific Charts | ✅ Implemented |
| 6.9 | Integrated Research Workspace | ✅ Implemented |
| 6.10 | Visualization Performance & Optimization | ✅ Implemented |
-| 6.11 | Visualization Testing & Documentation | 📋 Planned |
+| 6.11 | Visualization Testing & Documentation | ✅ Implemented |
## What Phase 6.1 Provides
@@ -204,6 +213,32 @@ accessibility, and scientific meaning. See
- Deterministic, non-flaky performance tests
(`downsample.test.ts`, distribution grouping tests) and updated docs.
+## What Phase 6.11 Provides
+
+- A **coverage audit** of every Phase 6 module (foundation, genome, scientific,
+ network, protein, workspace) identifying untested modules, untested
+ components, and a11y gaps.
+- **Behavior/correctness/edge tests** added across the suite: data-contract
+ tests for the visualization-module catalog (`visualizationModules.test.ts`),
+ responsive sizing (`useChartSize.test.tsx`), downsampling/aggregation edge
+ cases, genome coordinate + viewport boundaries, Genome Browser track/region
+ lifecycle (debounce, stale-abort), coordinate-search and advanced API
+ contracts (pagination, malformed input, abort), track layout stacking,
+ statistics (quantiles, whiskers), and data-lifecycle races (stale errors,
+ selection clearing).
+- **Focused component and accessibility tests** for the previously untested
+ chart primitives (`ChartAxes`, `ChartLegend`, `ChartTooltip`) and workspace
+ panels (loading/error states, context-change independence, `aria-live` /
+ `role` semantics).
+- **A latent bug fixed by the audit**: `panViewport` could produce a start
+ below base 1 when a window wider than the contig panned right; it now clamps
+ like `zoomViewport`.
+- **Reconciled documentation**: architecture and roadmap now match the shipped
+ platform (accurate tree, milestone status, test counts), plus a
+ [Testing](./testing.md) guide describing coverage and how to run the suite.
+- The web suite grows from 725 to 813 tests with no flaky or timing-based
+ benchmarks.
+
## Documents
| Document | Description |
@@ -218,6 +253,7 @@ accessibility, and scientific meaning. See
| [Advanced Scientific Charts](advanced-scientific-charts.md) | Phase 6.8 Advanced Scientific Charts: heatmap / volcano / coverage / distribution, data models, API, fixtures, a11y, tests |
| [Research Workspace](workspace.md) | Phase 6.9 Integrated Research Workspace: context, panels, state flow, API usage, fixture boundary, a11y, tests |
| [Performance](performance.md) | Phase 6.10 Visualization performance & large-dataset handling: data flow, strategies, downsampling limitations, a11y, testing |
+| [Testing](testing.md) | Phase 6.11 Testing & documentation pass: coverage map, test conventions, and how to run the suite |
| [Roadmap](roadmap.md) | Detailed phase tracking and future work |
## Technology Notes
diff --git a/docs/visualization/advanced-scientific-charts.md b/docs/visualization/advanced-scientific-charts.md
index 4be9585..e757fe0 100644
--- a/docs/visualization/advanced-scientific-charts.md
+++ b/docs/visualization/advanced-scientific-charts.md
@@ -263,9 +263,10 @@ the real `use*` hooks with a custom loader; the loader flips to the
- Domains, statistics, and geometry are `useMemo`-cached; derivation is
O(n) over the dataset (no O(n²) matrix rescans per render).
- Cell/point/bin hit areas are flat SVG controls — no per-frame work.
-- Large-data rendering (virtualization, density downsampling) is deferred to
- Phase 6.9 per the roadmap; the transform/render separation is designed so
- that work lands without component changes.
+- Large-data rendering is handled deterministically in the client via the
+ Phase 6.10 downsampling/aggregation layer (see [Performance](performance.md));
+ a second rendering architecture (virtualization, canvas/WebGL fallback)
+ remains a measured future option.
## Testing
diff --git a/docs/visualization/architecture.md b/docs/visualization/architecture.md
index c04ca1d..79ae639 100644
--- a/docs/visualization/architecture.md
+++ b/docs/visualization/architecture.md
@@ -11,14 +11,15 @@ API/SDK.
Next.js / React (UI)
│
▼
-Visualization Components e.g. GenomeBrowser, GeneViewer (future)
- │
+Visualization Components e.g. GenomeBrowser, ExpressionChart, NetworkViewer,
+ │ ProteinViewer, ResearchWorkspace, chart primitives
▼
Visualization Foundation
├── Types (apps/web/src/lib/visualization/types.ts)
├── Container (apps/web/src/components/visualization/VisualizationContainer.tsx)
├── States (VisualizationLoading / VisualizationEmpty / VisualizationErrorState)
- └── Data Adapter (apps/web/src/lib/visualization/useVisualizationData.ts)
+ ├── Data Adapter (apps/web/src/lib/visualization/useVisualizationData.ts)
+ └── Catalog (apps/web/src/lib/visualization/visualizationModules.ts)
│
▼
GenomeAI API / SDK
@@ -29,22 +30,32 @@ FastAPI → PostgreSQL
## Component Structure
-The foundation lives in the web application:
+The foundation and all Phase 6 modules live in the web application:
```text
apps/web/src/
-├── components/visualization/
-│ ├── VisualizationContainer.tsx Composes heading, description, and one state
-│ ├── VisualizationLoading.tsx Loading state (implicit role="status")
-│ ├── VisualizationEmpty.tsx Empty state
-│ └── VisualizationErrorState.tsx Error state with optional retry
-├── lib/visualization/
-│ ├── types.ts Visualization TypeScript types
-│ ├── useVisualizationData.ts Data adapter hook (loading/error/cancellation)
-│ └── visualizationModules.ts Placeholder catalog for future modules
+├── components/
+│ ├── visualization/ VisualizationContainer + loading/empty/error states
+│ ├── genome/ GenomeBrowser, VariantTrack, GeneTranscriptViewer
+│ ├── protein/ ProteinViewer
+│ ├── network/ NetworkViewer
+│ ├── scientific/ ExpressionChart, Heatmap, VolcanoPlot,
+│ │ CoverageChart, DistributionChart, ChartAxes,
+│ │ ChartLegend, ChartTooltip
+│ └── workspace/ ResearchWorkspace + panels + fixture data source
+├── lib/
+│ ├── visualization/ types, useVisualizationData, module catalog
+│ ├── genome/ types, region/viewport/geometry/tracks,
+│ │ useGenomeBrowser, API adapters
+│ ├── protein/ types, sequence/viewport/geometry, useProteinViewer, API
+│ ├── network/ types, model/normalize/filter/layout/viewport, API
+│ ├── scientific/ chart types/scales/geometry, statistics, downsample,
+│ │ heatmap/volcano/coverage/distribution, hooks, API adapters
+│ └── workspace/ researchContext, dataSources
└── app/visualization/
- ├── page.tsx Server component route (/visualization)
- └── VisualizationDemo.tsx Client demo proving the architecture
+ ├── page.tsx Route (/visualization) — demo catalog
+ ├── VisualizationDemo.tsx Client demo proving the architecture
+ └── workspace/page.tsx Research Workspace route (/visualization/workspace)
```
## Data Flow
@@ -103,13 +114,20 @@ aborted and stale responses are discarded. Loaders must throw an
- The demo page uses a `grid-cols-1 lg:grid-cols-2` layout; the module list
inside it uses `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`.
-Performance optimization for very large data sets is deferred to Phase 6.9.
+Performance optimization for very large data sets is delivered in Phase 6.10
+(deterministic downsampling/aggregation and per-render work reduction); see
+[Performance](./performance.md).
## Tests
| File | Covers |
|------|--------|
| `components/visualization/VisualizationContainer.test.tsx` | normal/loading/empty/error/content rendering, accessibility semantics, retry |
-| `lib/visualization/useVisualizationData.test.tsx` | success, error, empty, loading, stale-response handling, abort on unmount, refetch |
+| `lib/visualization/useVisualizationData.test.tsx` | success, error, empty, loading, stale-response handling, abort on unmount, refetch, refetch lifecycle |
+| `lib/visualization/visualizationModules.test.ts` | module-catalog data contract (ids, titles, milestones), fresh-copy semantics, delay/failure/abort |
+
+Coverage for the 6.2–6.10 modules is documented per module (see the linked
+docs in [README.md](./README.md#documents)); the Phase 6.11 testing pass maps
+and extends that coverage (see [Testing](./testing.md)).
Run with `pnpm --filter @genomeai/web test`.
\ No newline at end of file
diff --git a/docs/visualization/genome-browser.md b/docs/visualization/genome-browser.md
index ba3b839..fe80384 100644
--- a/docs/visualization/genome-browser.md
+++ b/docs/visualization/genome-browser.md
@@ -30,7 +30,8 @@ Implemented on branch `feat/visualization-genome-browser`.
- D3.js / Three.js / Cytoscape.js / WebAssembly / WebGPU / C++ (see
[README](README.md) technology notes)
- Full variant-call density rendering (6.4), transcript structure (6.3)
-- Chromosome-ideogram virtualized mega-contigs (6.9 performance work)
+- Chromosome-ideogram virtualized mega-contigs (measured future option; see
+ [Performance](performance.md))
## Architecture and data flow
diff --git a/docs/visualization/network-viewer.md b/docs/visualization/network-viewer.md
index 9f0a46c..a5f2c3b 100644
--- a/docs/visualization/network-viewer.md
+++ b/docs/visualization/network-viewer.md
@@ -76,7 +76,8 @@ Consequences and rationale:
- Cytoscape.js / D3.js / WebAssembly / WebGPU (see design decision above).
- Backend network/relationship endpoints (see
[API limitation](#api-limitation)).
-- Large-graph performance work (deferred to 6.9; see the roadmap).
+- Large-graph performance work beyond the Phase 6.10 downsampling/`React.memo`
+ work (measured future option; see the roadmap).
- Import from external biological databases (STRING, Reactome, BioGRID,
IntAct, Open Targets, ...). The browser never talks to them; they feed
GenomeAI through the later connector/ingestion architecture.
diff --git a/docs/visualization/protein-viewer.md b/docs/visualization/protein-viewer.md
index 23e8c06..5ed1c45 100644
--- a/docs/visualization/protein-viewer.md
+++ b/docs/visualization/protein-viewer.md
@@ -30,7 +30,7 @@ Implemented on branch `feat/visualization-protein-viewer`.
visible window, residue-letter lane with per-residue numbering, region
input, and keyboard-accessible feature selection with a detail panel
- Demo integrated at `/visualization` (`ProteinDemo`)
-- Tests (104 across the protein modules) and docs
+- Tests (105 across the protein modules) and docs
## Out of scope (later milestones or explicitly excluded)
@@ -41,8 +41,8 @@ Implemented on branch `feat/visualization-protein-viewer`.
- D3.js / Cytoscape.js / WebAssembly / WebGPU / C++
- Backend annotation-feature support (see
[Feature data boundary](#feature-data-boundary))
-- Dense population-scale sequence statistics / conservation plots
- (deferred to 6.9 performance work)
+- Dense population-scale sequence statistics / conservation plots (measured
+ future option; see [Performance](performance.md))
## Coordinate conventions
diff --git a/docs/visualization/roadmap.md b/docs/visualization/roadmap.md
index f7c5db7..5de7eb3 100644
--- a/docs/visualization/roadmap.md
+++ b/docs/visualization/roadmap.md
@@ -4,7 +4,57 @@ Tracks the Phase 6 visualization platform milestones. See
[Phase 6 of the project ROADMAP]() for
the authoritative milestone list.
-## Current Milestone: 6.9 — Integrated Research Workspace ✅
+## Current Milestone: 6.11 — Visualization Testing & Documentation ✅
+
+A stabilization + docs pass over the whole Phase 6 platform, on top of 6.10.
+
+Delivered:
+
+- **Coverage audit** across every Phase 6 module (foundation, genome,
+ scientific, network, protein, workspace): mapped tested vs untested modules,
+ components, and a11y semantics; identified `visualizationModules.ts` and
+ `useChartSize.ts` as untested modules and the chart primitives + workspace
+ panels as untested components.
+- **Latent bug fixed by the audit**: `panViewport` produced a start below base 1
+ when a window wider than the contig panned right; it now clamps like
+ `zoomViewport` (`lib/genome/viewport.ts`).
+- **Data-contract tests** (`visualizationModules.test.ts`): catalog ids,
+ titles, milestones, fresh-copy semantics, delay/failure/abort behavior.
+- **Responsive sizing tests** (`useChartSize.test.tsx`): default/custom
+ fallbacks, resize observation, zero-measurement guard, unmount cleanup.
+- **Edge/correctness expansion** across the suite: downsampling boundaries
+ (exact caps, non-positive limits, ragged blocks, NaN/Infinity),
+ genome coordinate + viewport boundaries (invalid regions, overflow, pan/zoom
+ clamps), Genome Browser lifecycle (debounce collapse, in-flight abort,
+ navigation clamps), API contracts (paginated coordinate search, page_size,
+ abort between pages, malformed payloads), track layout (stacking,
+ tie-break ordering, empty views), statistics (single-element summaries,
+ quantile interpolation, whiskers/outliers), and data-lifecycle races
+ (stale errors ignored, selection cleared on reload, data cleared during
+ refetch).
+- **Focused component + a11y tests**: `ChartAxes`, `ChartLegend`,
+ `ChartTooltip` (labels, list semantics, tooltip clamping), and the workspace
+ panels (loading state, error + retry, whole-dataset panels independent of
+ context changes).
+- **Reconciled documentation**: README, architecture tree, roadmap test
+ counts, module out-of-scope references, and the module catalog comment now
+ match the shipped platform; new [Testing](testing.md) guide.
+- Web suite grows from 725 to **813 tests** (68 files) — deterministic, no
+ timing-based benchmarks; full validation green.
+
+Constraints honored:
+
+- No new visualization features, architecture redesign, or Phase 6.12 work;
+ no C++/WebAssembly/WebGPU/Three.js/Cytoscape.js/D3.js; no new runtime
+ dependencies; Phase 5 untouched
+- No tests deleted, weakened, or skipped; additions are behavior-oriented
+ (never implementation-detail fakes)
+- Docs claims are accurate against the current repo (test counts, milestone
+ status, component tree)
+
+## Previous milestones
+
+### 6.9 — Integrated Research Workspace ✅
Implemented on top of 6.8 (and the Phase 6.10 performance work).
@@ -28,7 +78,7 @@ Delivered:
shared `VisualizationContainer`, and keyboard-accessible controls (labeled
context select, region form with `role="alert"` errors, `aria-live` region
announcements)
-- Tests (26 workspace tests) and docs (see
+- Tests (30 workspace tests) and docs (see
[Research Workspace](workspace.md))
Constraints honored:
@@ -121,7 +171,8 @@ Delivered:
`ChartAxes` extended with a continuous x-axis
- Demo integrated at `/visualization` (`AdvancedScientificDemo` uses the dev
fixtures)
-- Tests (282 across the scientific modules) and docs (see
+- Tests (282 across the scientific modules at delivery; 343 across the
+ scientific modules today) and docs (see
[Advanced Scientific Charts](advanced-scientific-charts.md))
Constraints honored:
@@ -170,7 +221,8 @@ Delivered:
(gridlines, axes, legend, hover tooltips, keyboard-accessible point
selection, detail panel)
- Demo integrated at `/visualization` (`ScientificDemo` uses the dev fixture)
-- Tests (85 across the scientific modules) and docs (see
+- Tests (85 across the scientific modules at delivery; 343 across the
+ scientific modules today) and docs (see
[Scientific Charts](scientific-charts.md))
Constraints honored:
@@ -386,5 +438,4 @@ Constraints honored:
| # | Milestone | Notes |
|---|-----------|-------|
-| 6.11 | Visualization Testing & Documentation | Stabilization + docs pass |
| 6.12 | Molecular Structure Viewer (3D) | 3D protein structures; Three.js only if 3D is truly required |
\ No newline at end of file
diff --git a/docs/visualization/scientific-charts.md b/docs/visualization/scientific-charts.md
index 0e72a7e..42383b7 100644
--- a/docs/visualization/scientific-charts.md
+++ b/docs/visualization/scientific-charts.md
@@ -83,7 +83,8 @@ Consequences and rationale:
- Logarithmic / transformed scales and error bars.
- D3.js / WebAssembly / WebGPU (see design decision above).
- Backend expression endpoints (see [API limitation](#api-limitation)).
-- Large-data rendering work (deferred to 6.9; see the roadmap).
+- Large-data rendering work beyond the Phase 6.10 downsampling caps (measured
+ future option; see the roadmap).
- Import from external biological databases (GEO, ArrayExpress, TCGA, ...).
The browser never talks to them; they feed GenomeAI through the later
connector/ingestion architecture.
diff --git a/docs/visualization/testing.md b/docs/visualization/testing.md
new file mode 100644
index 0000000..b7a2f72
--- /dev/null
+++ b/docs/visualization/testing.md
@@ -0,0 +1,76 @@
+# Visualization Testing
+
+Phase 6.11 — Visualization Testing & Documentation. This document maps test
+coverage across the Phase 6 platform, records the conventions the suite
+follows, and explains how to run it. The milestone audited every Phase 6
+module, added behavior/correctness/edge tests where coverage was thin, fixed a
+latent bug the audit uncovered, and added focused component + accessibility
+tests for previously untested pieces.
+
+## Coverage map
+
+The full web suite is **813 tests across 68 files** (deterministic, no
+timing-based benchmarks). Coverage by module:
+
+| Module | Files | Focus |
+|--------|-------|-------|
+| Visualization foundation | `components/visualization/VisualizationContainer.test.tsx` (10), `lib/visualization/useVisualizationData.test.tsx` (13), `lib/visualization/visualizationModules.test.ts` (6) | loading/empty/error/content states, retry, a11y semantics, stale-response handling, abort on unmount, refetch lifecycle, module-catalog data contract, fresh-copy semantics, delay/failure/abort |
+| Genome Browser + genes + variants | `components/genome/*` (30), `lib/genome/*` (178) | coordinate model + region parsing (valid/malformed/boundaries), viewport pan/zoom clamping (incl. window-wider-than-contig), track stacking + tie-breaking, Genome Browser navigation/zoom/track lifecycle, debounce collapse, in-flight aborts, data-contract edge cases (string positions, gene strand, pagination, 10,000 cap, abort between pages) |
+| Protein Viewer | `components/protein/ProteinViewer.test.tsx` (16), `lib/protein/*` (89) | sequence/viewport/geometry, feature selection, detail panel, a11y |
+| Network Viewer | `components/network/NetworkViewer.test.tsx` (14), `lib/network/*` (84) | layout determinism, filtering, viewport, node/edge selection, a11y |
+| Scientific charts + advanced charts | `components/scientific/*` (85), `lib/scientific/*` (258) | scales/geometry, validation + normalization, statistics (quantiles, whiskers, single-element), deterministic downsampling/aggregation (exact caps, ragged blocks, NaN/Infinity), per-chart transforms (heatmap/volcano/coverage/distribution), chart hooks (domains, selection, selection-clearing), chart primitives (axes/labels/legend/tooltip clamping), API adapters (URLs, normalization, error mapping, abort) |
+| Research Workspace | `components/workspace/*` (19), `lib/workspace/*` (11) | research-context state, context-select a11y, data-source contract, panel rendering, loading/error/retry states, whole-dataset panels independent of context changes, region-driven remounting |
+
+Phase 6.11 added coverage in bolded areas: `visualizationModules.test.ts` (new),
+`useChartSize.test.tsx` (new), `ChartPrimitives.test.tsx` (new), plus expanded
+`downsample`, `region`, `viewport`, `tracks`, `useGenomeBrowser`, `api`,
+`advancedApi`, `statistics`, `useVolcanoPlot`, `useVisualizationData`, and
+`ResearchWorkspace` suites.
+
+## What the audit caught
+
+- **`panViewport` clamp bug**: a window wider than the contig panned right could
+ produce a `start` below base 1. Fixed in `lib/genome/viewport.ts` by clamping
+ `start = Math.max(1, end - span + 1)` (matching `zoomViewport`), with
+ regression + edge tests.
+- **Untested modules**: `visualizationModules.ts` and `useChartSize.ts` had no
+ tests — both now covered.
+- **Untested components**: `ChartAxes`, `ChartLegend`, `ChartTooltip` had no
+ direct tests (the container test covered only the shared states) — now
+ covered, including the tooltip on-screen clamp and legend list semantics.
+- **Thin lifecycle coverage**: the shared data hooks lacked tests for
+ selection-clearing on reload, stale errors, data clearing during refetch,
+ and abort timing — all added.
+
+## Conventions
+
+- **Behavior over implementation**: assertions target rendered output, roles,
+ accessible names, and observable state — never internal call sequences.
+- **Deterministic**: fake timers for debounce/delay tests; `FakeResizeObserver`
+ for sizing tests. No wall-clock assertions.
+- **Flush pattern**: after `waitFor(...)` confirms a state change, settle React
+ updates with `await act(async () => {})` before making follow-up selection or
+ navigation changes, to avoid act-environment flakes (mirrors the
+ `useNetworkViewer` / `useProteinViewer` / `useHeatmap` pattern).
+- **Promise handling**: attach `expect(promise).rejects…` **before** triggering
+ the rejection (e.g. calling `controller.abort()`), so the rejection is never
+ reported as unhandled by Vitest.
+- **No fakes that assert internals**: mocks supply data or signals, never
+ replace the behavior under test.
+
+## Running
+
+- Web suite: `pnpm --filter @genomeai/web test` (Vitest + Testing Library).
+- Single file: `pnpm --filter @genomeai/web exec vitest run ` from
+ `apps/web`.
+- Full project: `make test` (web + backend pytest).
+
+## Intentional gaps
+
+- **No timing-based performance benchmarks**: Phase 6.10 coverage is
+ deterministic (downsampling output is asserted, not timed).
+- **Per-pixel / visual-regression tests** are out of scope for now; SVG
+ geometry is asserted through the pure `lib/*/geometry` modules instead.
+- Future visualization milestones should keep the per-module testing
+ convention documented here (pure modules unit-tested, components exercised
+ through Testing Library with role/name queries, a11y semantics asserted).
\ No newline at end of file
diff --git a/docs/visualization/variant.md b/docs/visualization/variant.md
index 2bdb728..f273798 100644
--- a/docs/visualization/variant.md
+++ b/docs/visualization/variant.md
@@ -45,7 +45,7 @@ Implemented on branch `feat/visualization-variant`.
- D3.js / Three.js / Cytoscape.js / WebAssembly / WebGPU / C++ (see
[README](README.md) technology notes)
- Dense variant-call / histogram density rendering at population scale
- (deferred to 6.9 performance work)
+ (measured future option; see [Performance](performance.md))
- Protein / network / chart views (6.5–6.7)
## Architecture and data flow
diff --git a/docs/visualization/workspace.md b/docs/visualization/workspace.md
index 741b352..0b15e31 100644
--- a/docs/visualization/workspace.md
+++ b/docs/visualization/workspace.md
@@ -169,10 +169,12 @@ Every panel routes through the shared lifecycle (`useVisualizationData` /
| `lib/workspace/dataSources.test.ts` | `resolveFixture` contract (resolve / abort / no cache) |
| `components/workspace/fixtureDataSources.test.ts` | interval filtering, abort handling, fixture resolution for every loader |
| `components/workspace/ResearchContextSelector.test.tsx` | labeled select, aria-live output, preset change, valid/invalid custom region, alert |
-| `components/workspace/ResearchWorkspace.test.tsx` | panel rendering, context→browser sync, custom-region navigation, gene empty state, panel error + retry, controls a11y |
+| `components/workspace/ResearchWorkspace.test.tsx` | panel rendering, context→browser sync, custom-region navigation, gene empty state, panel error + retry, loading state, whole-dataset panel independence, controls a11y |
-Existing tests are unchanged; the full suite must stay green (see
-`Makefile` targets `lint` / `typecheck` / `test` / `build`).
+Phase 6.11 added the loading-state, panel error, and context-change
+independence tests (30 workspace tests today). Existing tests are unchanged;
+the full suite must stay green (see `Makefile` targets `lint` / `typecheck` /
+`test` / `build`).
## Limitations