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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions frontend/src/__tests__/componentTests/CartContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,34 @@ function Probe() {
<button onClick={() => removeFromCart('/a')}>remove</button>
<button
onClick={() =>
removeManyFromCart([{ path: '/a' }, { path: '/b', channel: 'DAPI' }])
removeManyFromCart([{ path: '/a' }, { path: '/b', channelIndex: 0 }])
}
>
removeMany
</button>
<button
onClick={() =>
addToCart([
{
fsp_name: 'fsp',
path: '/r',
channel: 'Gpb5',
channelIndex: 1,
label: 'Gpb5'
},
{
fsp_name: 'fsp',
path: '/r',
channel: 'Gpb5',
channelIndex: 4,
label: 'Gpb5'
}
])
}
>
addRepeated
</button>
<button onClick={() => removeFromCart('/r', 4)}>removeRepeated</button>
<button onClick={() => clearCart()}>clear</button>
</div>
);
Expand Down Expand Up @@ -87,7 +110,13 @@ describe('CartContext', () => {
it('removeManyFromCart filters all removals in a single persist call', async () => {
cartData = [
{ fsp_name: 'fsp', path: '/a', label: 'a' },
{ fsp_name: 'fsp', path: '/b', channel: 'DAPI', label: 'DAPI' },
{
fsp_name: 'fsp',
path: '/b',
channel: 'DAPI',
channelIndex: 0,
label: 'DAPI'
},
{ fsp_name: 'fsp', path: '/c', label: 'c' }
];
const user = userEvent.setup();
Expand All @@ -104,6 +133,28 @@ describe('CartContext', () => {
});
});

it('keys channels by index, so repeated channel names stay distinct', async () => {
const user = userEvent.setup();
const { rerender } = render(
<CartProvider>
<Probe />
</CartProvider>
);
await user.click(screen.getByText('addRepeated'));
expect(mutateAsync.mock.calls[0][0].value).toHaveLength(2);

cartData = mutateAsync.mock.calls[0][0].value;
rerender(
<CartProvider>
<Probe />
</CartProvider>
);
await user.click(screen.getByText('removeRepeated'));
expect(mutateAsync.mock.calls[1][0].value).toEqual([
expect.objectContaining({ channelIndex: 1 })
]);
});

it('clearCart persists an empty array', async () => {
const user = userEvent.setup();
render(
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/__tests__/componentTests/CartTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const cartAGfp: CartItem = {
fsp_name: 'fsp1',
path: '/a',
channel: 'GFP',
channelIndex: 1,
label: 'GFP'
};
const cartB: CartItem = { fsp_name: 'fsp2', path: '/b', label: 'Dataset B' };
Expand Down Expand Up @@ -267,8 +268,8 @@ describe('Layer Cart tab', () => {
expect(removeManyFromCart).toHaveBeenCalledTimes(1);
});
expect(removeManyFromCart).toHaveBeenCalledWith([
{ path: '/a', channel: undefined },
{ path: '/a', channel: 'GFP' }
{ path: '/a', channelIndex: undefined },
{ path: '/a', channelIndex: 1 }
]);
// The bug being regression-tested: no per-item loop calling single-remove.
expect(removeFromCart).not.toHaveBeenCalled();
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/__tests__/componentTests/useAddToView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';

import toast from 'react-hot-toast';

import { makeFakeBridge } from '@/__tests__/mocks/fakeNeuroglancer';
import type { CartItem } from '@/contexts/CartContext';
import type { ViewLayer } from '@/queries/viewQueries';
Expand Down Expand Up @@ -62,6 +64,22 @@ describe('useAddToView', () => {
expect(onAdded).toHaveBeenCalledWith([resolvedOf(ok, 'k1')]);
});

it('counts channels, not datasets, when adding channels', async () => {
const gpb5 = { ...ok, channel: 'Gpb5', channelIndex: 4 };
resolveCartDatasets.mockResolvedValue([resolvedOf(gpb5, 'k1')]);
buildViewState.mockResolvedValue({
ng_state: { layers: [{ name: 'Gpb5' }] },
layers: [
{ sharing_key: 'k1', layer_index: 0, channel: 'Gpb5', opts: null }
]
});
const success = vi.spyOn(toast, 'success');
const { addToView } = setup();
await addToView([gpb5]);
expect(success).toHaveBeenCalledWith('Added 1 channel. Save to keep it.');
success.mockRestore();
});

it('records unsupported datasets as sources', async () => {
resolveCartDatasets.mockResolvedValue([
resolvedOf(ok, 'k1'),
Expand Down
42 changes: 41 additions & 1 deletion frontend/src/__tests__/unitTests/omezarrHelper.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { getNeuroglancerSource } from '@/omezarr-helper';
import {
generateNeuroglancerStateForOmeZarr,
getNeuroglancerSource
} from '@/omezarr-helper';

describe('getNeuroglancerSource with version selection', () => {
it('should generate v3 source URL when v3 is selected', () => {
Expand Down Expand Up @@ -35,3 +38,40 @@ describe('getNeuroglancerSource with version selection', () => {
);
});
});

describe('generateNeuroglancerStateForOmeZarr per-channel layers', () => {
it('gives repeated channel labels unique layer names', () => {
const multiscale = {
axes: [
{ name: 'c', type: 'channel' },
{ name: 'y', type: 'space' },
{ name: 'x', type: 'space' }
],
datasets: [
{
path: '0',
coordinateTransformations: [{ type: 'scale', scale: [1, 1, 1] }]
}
]
};
const omero = {
channels: [{ label: 'Gpb5' }, { label: 'Pale' }, { label: 'Gpb5' }]
};
const encoded = generateNeuroglancerStateForOmeZarr(
'https://example.com/data.zarr',
3,
'image',
multiscale as any,
{ shape: [3, 10, 10], dtype: 'uint16' } as any,
undefined,
omero as any,
true
);
const state = JSON.parse(decodeURIComponent(encoded!));
expect(state.layers.map((l: any) => l.name)).toEqual([
'Gpb5',
'Pale',
'Gpb5 (2)'
]);
});
});
38 changes: 37 additions & 1 deletion frontend/src/__tests__/unitTests/useViewSources.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';

import { makeFakeBridge } from '@/__tests__/mocks/fakeNeuroglancer';
import { groupLayersBySource, useViewSources } from '@/hooks/useViewSources';
import {
channelRows,
groupLayersBySource,
useViewSources
} from '@/hooks/useViewSources';
import type { ViewLayer } from '@/queries/viewQueries';
import type { ResolvedCheckoutDataset } from '@/utils/viewCheckout';

Expand Down Expand Up @@ -34,6 +38,38 @@ const row = (
...extra
});

describe('channelRows', () => {
const channelLayer = (name: string, index: number) => ({
...layer(name, 'K1', 'a.zarr'),
localPosition: [index]
});

it('lists every channel of a per-channel dataset, linked to its layer', () => {
const [source] = groupLayersBySource(
[channelLayer('Gpb5 (2)', 2), channelLayer('DAPI', 0)],
[ds('K1', 'data/a.zarr')],
[]
);
expect(channelRows(source, ['DAPI', 'Gpb5', 'Gpb5'])).toEqual([
{ key: 'channel:0', label: 'DAPI', layerName: 'DAPI' },
{ key: 'channel:1', label: 'Gpb5', layerName: undefined },
{ key: 'channel:2', label: 'Gpb5', layerName: 'Gpb5 (2)' }
]);
});

it('falls back to the layer names when a layer is not one channel', () => {
const [source] = groupLayersBySource(
[layer('a DAPI', 'K1', 'a.zarr'), layer('a GFP', 'K1', 'a.zarr')],
[ds('K1', 'data/a.zarr')],
[]
);
expect(channelRows(source, ['DAPI', 'GFP'])?.map(r => r.label)).toEqual([
'a DAPI',
'a GFP'
]);
});
});

describe('groupLayersBySource', () => {
it('groups per-channel layers into their dataset, in first-layer order', () => {
const sources = groupLayersBySource(
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/__tests__/unitTests/viewCheckout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,24 @@ describe('buildViewState', () => {
expect(layers[0].localPosition).toEqual([0]);
expect(layers[1].localPosition).toEqual([2]);
});
it('renames layers that would share a name across datasets', async () => {
(generateNeuroglancerStateForOmeZarr as any).mockReturnValue(
encoded({ layers: [{ name: 'DAPI' }] })
);
const ds = (path: string) => ({
url: path,
sharing_key: path,
url_prefix: path,
fsp_name: 'f',
path,
label: path
});
const { ng_state } = await buildViewState([ds('/a'), ds('/b')]);
expect((ng_state as any).layers.map((l: any) => l.name)).toEqual([
'DAPI',
'DAPI (2)'
]);
});
});

describe('probeDataset', () => {
Expand Down Expand Up @@ -350,6 +368,15 @@ describe('appendLayers', () => {
expect(merged.layers?.[2].name).toBe('img.zarr (3)');
});

it('keeps added layers shown past index 4 when asked', () => {
const merged = appendLayers(
{ layers: [{ name: 'a' }, { name: 'b' }, { name: 'c' }, { name: 'd' }] },
[{ name: 'e' }],
true
);
expect(merged.layers?.[4].archived).toBe(false);
});

it('archives added layers from index 4 on and leaves existing layers alone', () => {
const merged = appendLayers(
{
Expand Down
34 changes: 19 additions & 15 deletions frontend/src/components/ui/Views/CartDatasetRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ export default function CartDatasetRow({
}: CartDatasetRowProps) {
const { addToCart, removeFromCart, removeManyFromCart } = useCartContext();

const checkedChannels = new Set(
items.map(item => item.channel).filter((c): c is string => Boolean(c))
);
const checkedIndices = new Set(items.map(item => item.channelIndex));

const handleRemoveDataset = async () => {
try {
await removeManyFromCart(
items.map(item => ({ path: item.path, channel: item.channel }))
items.map(item => ({
path: item.path,
channelIndex: item.channelIndex
}))
);
} catch (error) {
toast.error(
Expand All @@ -58,7 +59,7 @@ export default function CartDatasetRow({
{ fsp_name, path, channel, label: channel, channelIndex: index }
]);
} else {
await removeFromCart(path, channel);
await removeFromCart(path, index);
}
} catch (error) {
toast.error(
Expand All @@ -82,16 +83,19 @@ export default function CartDatasetRow({
<Typography className="text-foreground/70 text-xs font-semibold">
Optional: select channels to create per-channel layers
</Typography>
{channels.map((channel, index) => (
<FgCheckbox
checked={checkedChannels.has(channel)}
key={channel}
label={channel}
onChange={e =>
void handleToggleChannel(channel, index, e.target.checked)
}
/>
))}
{/* Column-major, so channels still read in c-axis order. */}
<div className="columns-[8rem] gap-x-4 [&>*]:mb-1 [&>*]:break-inside-avoid">
{channels.map((channel, index) => (
<FgCheckbox
checked={checkedIndices.has(index)}
key={index}
label={channel}
onChange={e =>
void handleToggleChannel(channel, index, e.target.checked)
}
/>
))}
</div>
</div>
)}
/>
Expand Down
Loading
Loading