diff --git a/frontend/src/__tests__/componentTests/CartList.test.tsx b/frontend/src/__tests__/componentTests/CartList.test.tsx index f8ff6e6f..4f318d6a 100644 --- a/frontend/src/__tests__/componentTests/CartList.test.tsx +++ b/frontend/src/__tests__/componentTests/CartList.test.tsx @@ -28,9 +28,6 @@ vi.mock('@/contexts/CartContext', () => ({ clearCart }) })); -vi.mock('@/queries/proxiedPathQueries', () => ({ - useAllProxiedPathsQuery: () => ({ data: [] }) -})); vi.mock('@/components/ui/Views/CartDatasetRow', () => ({ default: ({ label }: { label: string }) => (
{label}
@@ -49,6 +46,9 @@ vi.mock('@/components/ui/Views/CreateViewButton', () => ({ ) })); +vi.mock('@/hooks/useCartDimensionCheck', () => ({ + useCartDimensionCheck: () => ({ mismatchedKeys: new Set(), hasMismatch: false }) +})); import CartList from '@/components/ui/Views/CartList'; diff --git a/frontend/src/__tests__/componentTests/CartTab.test.tsx b/frontend/src/__tests__/componentTests/CartTab.test.tsx index 408f0fa1..19185561 100644 --- a/frontend/src/__tests__/componentTests/CartTab.test.tsx +++ b/frontend/src/__tests__/componentTests/CartTab.test.tsx @@ -18,10 +18,11 @@ const view: View = { layers: [] }; -// Dataset A has an existing Data Link (channel expansion enabled) and TWO -// cart entries (a base entry + an already-checked "GFP" channel entry), to -// exercise the multi-entry "Remove" batch path. -// Dataset B has no Data Link (channel expansion disabled + hint). +// Dataset A has TWO cart entries (a base entry + an already-checked "GFP" +// channel entry), to exercise the multi-entry "Remove" batch path. +// Dataset B is a plain single-entry dataset - both expand identically now +// that metadata is fetched from the internal /api/content URL rather than +// a Data Link. const cartABase: CartItem = { fsp_name: 'fsp1', path: '/a', @@ -92,25 +93,17 @@ vi.mock('@/omezarr-helper', () => ({ getResolvedScales: () => [1, 0.65, 0.65], translateUnitToNeuroglancer: (unit?: string) => unit ?? '' })); -vi.mock('@/queries/proxiedPathQueries', () => ({ - useAllProxiedPathsQuery: () => ({ - data: [ - { - fsp_name: 'fsp1', - path: '/a', - url: 'https://data.example/a', - sharing_key: 'k1' - } - ], - error: null, - isPending: false - }) -})); vi.mock('@/components/ui/Views/CreateViewButton', () => ({ default: ({ label }: { label?: string }) => ( ) })); +vi.mock('@/hooks/useCartDimensionCheck', () => ({ + useCartDimensionCheck: () => ({ + mismatchedKeys: new Set(), + hasMismatch: false + }) +})); import CartList from '@/components/ui/Views/CartList'; @@ -140,18 +133,20 @@ describe('Layer Cart tab', () => { expect(screen.getByText('Dataset B')).toBeInTheDocument(); }); - it('lazy-loads and shows channels when expanding a dataset with a Data Link', async () => { + it('lazy-loads and shows channels when expanding a dataset', async () => { const user = await renderCartTab(); await user.click(screen.getByRole('button', { name: 'Dataset A' })); await waitFor(() => { - expect(getOmeZarrChannels).toHaveBeenCalledWith('https://data.example/a'); + expect(getOmeZarrChannels).toHaveBeenCalledWith( + expect.stringContaining('/api/content/fsp1/a') + ); }); expect(await screen.findByText('DAPI')).toBeInTheDocument(); expect(screen.getByText('GFP')).toBeInTheDocument(); }); - it('lazy-loads and shows the axis table when expanding a dataset with a Data Link', async () => { + it('lazy-loads and shows the axis table when expanding a dataset', async () => { getOmeZarrMetadata.mockResolvedValueOnce({ shapes: [[3, 2048, 2048]], arr: { chunks: [1, 512, 512] }, @@ -172,7 +167,9 @@ describe('Layer Cart tab', () => { await user.click(screen.getByRole('button', { name: /Dataset A/ })); await waitFor(() => { - expect(getOmeZarrMetadata).toHaveBeenCalledWith('https://data.example/a'); + expect(getOmeZarrMetadata).toHaveBeenCalledWith( + expect.stringContaining('/api/content/fsp1/a') + ); }); expect(await screen.findByText('Chunk Size')).toBeInTheDocument(); }); @@ -193,14 +190,30 @@ describe('Layer Cart tab', () => { expect(screen.queryByText(/×/)).not.toBeInTheDocument(); }); - it('disables expansion and shows a hint for a dataset with no Data Link', async () => { - await renderCartTab(); - const expandButton = screen.getByRole('button', { name: 'Dataset B' }); - expect(expandButton).toBeDisabled(); + it('shows a "no OME-Zarr metadata" message for a plain (non-OME) array', async () => { + // Plain Zarr array: no channels and getOmeZarrMetadata throws (no + // multiscale group), so the expanded body has nothing to show. + getOmeZarrChannels.mockResolvedValueOnce([]); + getOmeZarrMetadata.mockRejectedValueOnce(new Error('not ome-zarr')); + const user = await renderCartTab(); + await user.click(screen.getByRole('button', { name: 'Dataset B' })); + expect( - screen.getByText(/channels load after the view is created/i) + await screen.findByText('No OME-Zarr metadata to display.') ).toBeInTheDocument(); - expect(getOmeZarrChannels).not.toHaveBeenCalled(); + }); + + it('expands a dataset that has no Data Link (metadata fetched via /api/content)', async () => { + const user = await renderCartTab(); + const expandButton = screen.getByRole('button', { name: 'Dataset B' }); + expect(expandButton).not.toBeDisabled(); + await user.click(expandButton); + + await waitFor(() => { + expect(getOmeZarrChannels).toHaveBeenCalledWith( + expect.stringContaining('/api/content/fsp2/b') + ); + }); }); it('toggling a channel checkbox adds a channel-specific CartItem', async () => { diff --git a/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx index 4818f8c1..b01291ce 100644 --- a/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx +++ b/frontend/src/__tests__/componentTests/CreateViewButton.test.tsx @@ -22,6 +22,9 @@ vi.mock('react-router', () => ({ useNavigate: () => vi.fn() })); vi.mock('@/contexts/CartContext', () => ({ useCartContext: () => ({ clearCart: vi.fn().mockResolvedValue(undefined) }) })); +vi.mock('@/hooks/useCartDimensionCheck', () => ({ + useCartDimensionCheck: () => ({ mismatchedKeys: new Set(), hasMismatch: false }) +})); import CreateViewButton from '@/components/ui/Views/CreateViewButton'; diff --git a/frontend/src/__tests__/componentTests/NGViews.test.tsx b/frontend/src/__tests__/componentTests/NGViews.test.tsx index dfa2ccc0..8f17acdb 100644 --- a/frontend/src/__tests__/componentTests/NGViews.test.tsx +++ b/frontend/src/__tests__/componentTests/NGViews.test.tsx @@ -43,6 +43,12 @@ vi.mock('@/queries/proxiedPathQueries', () => ({ vi.mock('@/components/ui/Views/CreateViewButton', () => ({ default: () => })); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferencesContext: () => ({ pathPreference: ['linux_path'] }) +})); +vi.mock('@/contexts/ZonesAndFspMapContext', () => ({ + useZoneAndFspMapContext: () => ({ zonesAndFspQuery: { data: {} } }) +})); import NGViews from '@/components/NGViews'; @@ -53,7 +59,7 @@ describe('NGViews page', () => { ); - expect(screen.getByText('Neuroglancer Views')).toBeInTheDocument(); + expect(screen.getByText('Views')).toBeInTheDocument(); expect(screen.getByText('Seeded View')).toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx index ba9b154a..d980f328 100644 --- a/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx +++ b/frontend/src/__tests__/componentTests/NeuroglancerView.test.tsx @@ -77,14 +77,14 @@ describe('NeuroglancerView', () => { ).toBeInTheDocument(); }); - it('shows a breadcrumb linking back to the NG Views list', () => { + it('shows a breadcrumb linking back to the Views list', () => { useViewStateByReadKey.mockReturnValue({ data: { title: 'My View', layers: [{ name: 'L0' }] }, isPending: false, isError: false }); render(); - const crumbLink = screen.getByRole('link', { name: /ng views/i }); + const crumbLink = screen.getByRole('link', { name: /^views$/i }); expect(crumbLink).toHaveAttribute('href', '/ngviews'); }); diff --git a/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx b/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx index c43c6834..2d59452c 100644 --- a/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx +++ b/frontend/src/__tests__/componentTests/ngViewsColumns.test.tsx @@ -29,6 +29,28 @@ vi.mock('@/queries/proxiedPathQueries', () => ({ ] }) })); +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferencesContext: () => ({ pathPreference: ['linux_path'] }) +})); +vi.mock('@/contexts/ZonesAndFspMapContext', () => ({ + useZoneAndFspMapContext: () => ({ + zonesAndFspQuery: { + // key format is `fsp_` (see makeMapKey) + data: { + fsp_nrs: { + zone: 'z', + name: 'nrs', + group: '', + storage: '', + mount_path: '/nrs', + linux_path: '/nrs', + mac_path: null, + windows_path: null + } + } + } + }) +})); const view: View = { short_key: 'k1', @@ -66,7 +88,13 @@ function TableProbe({ }) { // ponytail: TableProbe is already a component, so call the hook directly // rather than nesting renderHook inside a component under render(). - const columns = useNGViewsColumns(onRename, onDelete, 'https://ng.example/'); + const columns = useNGViewsColumns( + onRename, + onDelete, + 'https://ng.example/', + 320, + () => {} + ); const table = useReactTable({ data: [view], columns, @@ -110,10 +138,11 @@ describe('useNGViewsColumns', () => { ); - const link = screen.getByText('dudman/reg.zarr/g1_r0'); + // Sources show the full path (file share path + subpath), not just the subpath. + const link = screen.getByText('/nrs/dudman/reg.zarr/g1_r0'); expect(link).toBeInTheDocument(); expect(link.closest('a')).toHaveAttribute('href'); - expect(screen.getByText('dudman/reg.zarr/g1_r1')).toBeInTheDocument(); + expect(screen.getByText('/nrs/dudman/reg.zarr/g1_r1')).toBeInTheDocument(); }); it('fires onRename and onDelete from the actions menu', async () => { diff --git a/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx b/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx index 85cca967..b4d2796e 100644 --- a/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx +++ b/frontend/src/__tests__/componentTests/useCreateViewFlow.test.tsx @@ -19,6 +19,9 @@ vi.mock('@/queries/proxiedPathQueries', () => ({ useAllProxiedPathsQuery: () => ({ data: [] }) })); vi.mock('react-router', () => ({ useNavigate: () => vi.fn() })); +vi.mock('@/hooks/useCartDimensionCheck', () => ({ + useCartDimensionCheck: () => ({ mismatchedKeys: new Set(), hasMismatch: false }) +})); import { useCreateViewFlow } from '@/hooks/useCreateViewFlow'; diff --git a/frontend/src/__tests__/dimensionSignature.test.ts b/frontend/src/__tests__/dimensionSignature.test.ts new file mode 100644 index 00000000..4930e6b9 --- /dev/null +++ b/frontend/src/__tests__/dimensionSignature.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { signaturesMatch } from '@/utils/dimensionSignature'; +import type { DimensionSignature } from '@/utils/dimensionSignature'; + +const sig = ( + axes: [string, string][], + scales: number[] +): DimensionSignature => ({ + axes: axes.map(([name, unit]) => ({ name, unit })), + scales +}); + +describe('signaturesMatch', () => { + it('matches identical axes and scales', () => { + const a = sig([['x', 'micrometer'], ['y', 'micrometer']], [0.1, 0.1]); + const b = sig([['x', 'micrometer'], ['y', 'micrometer']], [0.1, 0.1]); + expect(signaturesMatch(a, b)).toBe(true); + }); + + it('mismatches when axis names/order differ', () => { + const a = sig([['x', 'um'], ['y', 'um'], ['z', 'um']], [1, 1, 1]); + const b = sig([['x', 'um'], ['y', 'um']], [1, 1]); + expect(signaturesMatch(a, b)).toBe(false); + }); + + it('mismatches when a unit differs', () => { + const a = sig([['x', 'micrometer']], [1]); + const b = sig([['x', 'nanometer']], [1]); + expect(signaturesMatch(a, b)).toBe(false); + }); + + it('mismatches when voxel scale differs beyond relative epsilon', () => { + const a = sig([['x', 'um']], [0.1]); + const b = sig([['x', 'um']], [0.2]); + expect(signaturesMatch(a, b)).toBe(false); + }); + + it('matches when scales differ within relative epsilon', () => { + const a = sig([['x', 'um']], [0.1]); + const b = sig([['x', 'um']], [0.10005]); // 0.05% off + expect(signaturesMatch(a, b)).toBe(true); + }); + + it('matches tiny nanometer-scale values that are effectively equal', () => { + const a = sig([['x', 'nm']], [4]); + const b = sig([['x', 'nm']], [4.001]); + expect(signaturesMatch(a, b)).toBe(true); + }); +}); diff --git a/frontend/src/components/NGViews.tsx b/frontend/src/components/NGViews.tsx index 76704dd7..66ee5b29 100644 --- a/frontend/src/components/NGViews.tsx +++ b/frontend/src/components/NGViews.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; @@ -19,6 +19,18 @@ export default function NGViews() { const [renameItem, setRenameItem] = useState(undefined); const [renameValue, setRenameValue] = useState(''); const [deleteItem, setDeleteItem] = useState(undefined); + // Sources column is user-resizable via a drag handle in its header. Width + // lives here (not in the column def) so a re-render on drag actually + // re-flows the CSS grid template. + const [sourcesColWidth, setSourcesColWidth] = useState(260); + const clampSourcesWidth = useCallback( + (w: number) => Math.max(120, Math.min(900, w)), + [] + ); + const handleSourcesResize = useCallback( + (next: number) => setSourcesColWidth(clampSourcesWidth(next)), + [clampSourcesWidth] + ); const handleOpenRename = (item: View) => { setRenameItem(item); @@ -55,24 +67,35 @@ export default function NGViews() { } }; - const columns = useNGViewsColumns(handleOpenRename, setDeleteItem, baseUrl); + const columns = useNGViewsColumns( + handleOpenRename, + setDeleteItem, + baseUrl, + sourcesColWidth, + handleSourcesResize + ); + + // Fixed pixel tracks for every column except Sources (user-resizable). + // Fixed (not fr) so the row has a deterministic width — that's what lets + // the outer overflow-x-auto scroll when Sources grows past the viewport. + const gridColsStyle = `160px 80px ${sourcesColWidth}px 160px 160px 56px`; return ( <>
- Neuroglancer Views + Views - Your saved Neuroglancer Views. + Your saved Views.
diff --git a/frontend/src/components/NeuroglancerView.tsx b/frontend/src/components/NeuroglancerView.tsx index fe6b9fda..bece79a3 100644 --- a/frontend/src/components/NeuroglancerView.tsx +++ b/frontend/src/components/NeuroglancerView.tsx @@ -82,50 +82,42 @@ export default function NeuroglancerView() { className="flex h-full w-full flex-col bg-background" ref={containerRef} > -
-
+
+
- NG Views + Views / {title}
-
- + void handleCopy()} variant="ghost"> + Copy link + + + downloadTextFile( + JSON.stringify(ngState, null, 2), + `${title}.json` + ) + } + variant="ghost" > - {title} - -
- void handleCopy()} variant="ghost"> - Copy link - - - downloadTextFile( - JSON.stringify(ngState, null, 2), - `${title}.json` - ) - } - variant="ghost" - > - Download JSON - - - window.open(externalUrl, '_blank', 'noopener,noreferrer') - } - variant="ghost" - > - Open external - - - Fullscreen - -
+ Download JSON + + + window.open(externalUrl, '_blank', 'noopener,noreferrer') + } + variant="ghost" + > + Open external + + + Fullscreen +