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
23 changes: 23 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,29 @@ export default tseslint.config(
'@typescript-eslint/no-unused-expressions': 'off',
},
},
// -------------------------------------------------------------------------
// Unit tests wire collaborators in, they do not replace modules.
//
// Expressed with the built-in `no-restricted-syntax` for the same reason the
// feature boundaries use `no-restricted-imports`: no new plugin dependency.
// `vi.hoisted` is deliberately not listed: it exists to hoist values used by
// `vi.mock`, so flagging it as well would only double the report on a site
// the `vi.mock` selector already catches.
// -------------------------------------------------------------------------
{
files: ['**/__tests__/**/*.{js,ts}', '**/tests/unit/**/*.spec.{js,ts}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.object.name='vi'][callee.property.name=/^(mock|doMock)$/]",
message:
'Do not replace a module with vi.mock. Give the unit under test a parameter for the collaborator with the real one as its default (`useThing(dep = realDep)`), then hand a real object in from the spec. vi.fn and vi.spyOn on a real object are still fine.',
},
],
},
},
{
files: ['src/vtk/**/*.{js,ts}'],
rules: {
Expand Down
68 changes: 35 additions & 33 deletions src/actions/__tests__/loadDataSourcesNotices.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import { setActivePinia, createPinia } from 'pinia';
import {
loadDataSources,
loadUrlsWithOutcome,
type DataSourceImporter,
} from '@/src/actions/loadUserFiles';
import useLoadDataStore from '@/src/store/load-data';
import type { DataSource } from '@/src/io/import/dataSource';
import { asErrorResult, asOkayResult } from '@/src/io/import/common';
import {
asErrorResult,
asOkayResult,
type ImportDataSourcesResult,
} from '@/src/io/import/common';

// ---------------------------------------------------------------------------
// ONE consolidated notice for degraded composed opens: importDataSources owns
Expand All @@ -18,15 +23,13 @@ import { asErrorResult, asOkayResult } from '@/src/io/import/common';
// the error results loadDataSources receives, no more and no less.
// ---------------------------------------------------------------------------

const mocks = vi.hoisted(() => ({
importDataSources: vi.fn(),
}));

vi.mock('@/src/io/import/importDataSources', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/src/io/import/importDataSources')>();
return { ...actual, importDataSources: mocks.importDataSources };
});
// Returns whatever the test lined up, one queued batch per call.
const importerServing = (
...batches: Array<ImportDataSourcesResult[]>
): DataSourceImporter => {
const queue = [...batches];
return async () => queue.shift() ?? [];
};

// What importDataSources returns for a failure it already surfaced itself.
const coveredFailure = (source: DataSource) => asOkayResult(source);
Expand All @@ -47,27 +50,25 @@ const standaloneSource = (): DataSource => ({
describe('loadDataSources — notice exclusivity for restore-covered failures', () => {
beforeEach(() => {
setActivePinia(createPinia());
mocks.importDataSources.mockReset();
});

it('a restore-covered failure does NOT raise the generic load error', async () => {
const leaf = composedLeaf('ds-a');
mocks.importDataSources.mockResolvedValue([coveredFailure(leaf)]);
const spy = vi.spyOn(useLoadDataStore(), 'setError');

await loadDataSources([leaf]);
await loadDataSources([leaf], importerServing([coveredFailure(leaf)]));

expect(spy).not.toHaveBeenCalled();
});

it('a returned error result still raises the generic load error', async () => {
const source = standaloneSource();
mocks.importDataSources.mockResolvedValue([
asErrorResult(new Error('boom'), source),
]);
const spy = vi.spyOn(useLoadDataStore(), 'setError');

await loadDataSources([source]);
await loadDataSources(
[source],
importerServing([asErrorResult(new Error('boom'), source)])
);

expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0][0])).toContain('plain.nrrd');
Expand All @@ -76,13 +77,15 @@ describe('loadDataSources — notice exclusivity for restore-covered failures',
it('a mixed result reports ONLY the error entries', async () => {
const leaf = composedLeaf('ds-a');
const source = standaloneSource();
mocks.importDataSources.mockResolvedValue([
coveredFailure(leaf),
asErrorResult(new Error('boom'), source),
]);
const spy = vi.spyOn(useLoadDataStore(), 'setError');

await loadDataSources([leaf, source]);
await loadDataSources(
[leaf, source],
importerServing([
coveredFailure(leaf),
asErrorResult(new Error('boom'), source),
])
);

expect(spy).toHaveBeenCalledTimes(1);
const message = String(spy.mock.calls[0][0]);
Expand All @@ -92,21 +95,20 @@ describe('loadDataSources — notice exclusivity for restore-covered failures',

it('distinguishes a successful zero-dataset restore from an uncovered error', async () => {
const leaf = composedLeaf('ds-a');
mocks.importDataSources.mockResolvedValueOnce([coveredFailure(leaf)]);

await expect(
loadUrlsWithOutcome({
urls: ['https://example.com/session.volview.json'],
})
loadUrlsWithOutcome(
{ urls: ['https://example.com/session.volview.json'] },
importerServing([coveredFailure(leaf)])
)
).resolves.toEqual({ datasetIds: [], hadErrors: false });

mocks.importDataSources.mockResolvedValueOnce([
asErrorResult(new Error('not found'), standaloneSource()),
]);
await expect(
loadUrlsWithOutcome({
urls: ['https://example.com/missing.volview.json'],
})
loadUrlsWithOutcome(
{ urls: ['https://example.com/missing.volview.json'] },
importerServing([
asErrorResult(new Error('not found'), standaloneSource()),
])
)
).resolves.toEqual({ datasetIds: [], hadErrors: true });
});
});
14 changes: 9 additions & 5 deletions src/actions/loadUserFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ function loadSegmentations(
});
}

type DataSourceImporter = (
export type DataSourceImporter = (
sources: DataSource[]
) => Promise<ImportDataSourcesResult[]>;

Expand Down Expand Up @@ -371,8 +371,11 @@ function loadDataSourcesWithOutcome(
return wrapWithLoading(load)();
}

export function loadDataSources(sources: DataSource[]) {
return loadDataSourcesWithOutcome(sources, importDataSources).then(
export function loadDataSources(
sources: DataSource[],
importer: DataSourceImporter = importDataSources
) {
return loadDataSourcesWithOutcome(sources, importer).then(
({ datasetIds, completed }) => (completed ? datasetIds : undefined)
);
}
Expand Down Expand Up @@ -425,7 +428,8 @@ export async function loadUrls(params: UrlParams | LoadUrlsParams) {
}

export async function loadUrlsWithOutcome(
params: UrlParams | LoadUrlsParams
params: UrlParams | LoadUrlsParams,
importer: DataSourceImporter = importDataSources
): Promise<Pick<LoadDataSourcesOutcome, 'datasetIds' | 'hadErrors'>> {
if (!params.urls) {
return { datasetIds: [], hadErrors: false };
Expand All @@ -434,7 +438,7 @@ export async function loadUrlsWithOutcome(
const urls = wrapInArray(params.urls);
const names = wrapInArray(params.names ?? []);
const sources = urlsToDataSources(urls, names);
const outcome = await loadDataSourcesWithOutcome(sources, importDataSources);
const outcome = await loadDataSourcesWithOutcome(sources, importer);
return {
datasetIds: outcome.datasetIds,
hadErrors: outcome.hadErrors,
Expand Down
25 changes: 17 additions & 8 deletions src/components/__tests__/MeasurementDetails.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it } from 'vitest';
import MeasurementToolDetails from '@/src/components/MeasurementToolDetails.vue';
import MeasurementRulerDetails from '@/src/components/MeasurementRulerDetails.vue';
import { useRulerStore } from '@/src/store/tools/rulers';
import { ToolID } from '@/src/types/annotation-tool';

vi.mock('@/src/store/tools/rulers', () => ({
useRulerStore: () => ({
lengthByID: { 'tool-1': 12.345 },
}),
}));
beforeEach(() => {
setActivePinia(createPinia());
});

const stubs = {
'v-row': { template: '<div><slot /></div>' },
Expand Down Expand Up @@ -48,18 +48,27 @@ describe('MeasurementToolDetails', () => {
});

describe('MeasurementRulerDetails', () => {
// The component reads the length off the ruler store, so the ruler has to
// exist there: a 3-4-5 triangle gives a length of 5.00mm.
const seatRuler = () =>
useRulerStore().addRuler({
firstPoint: [0, 0, 0],
secondPoint: [3, 4, 0],
});

it('shows slice number for a volume ruler', () => {
const wrapper = mount(MeasurementRulerDetails, {
props: { tool: { ...baseTool, slice: 9 } },
props: { tool: { ...baseTool, id: seatRuler(), slice: 9 } },
global: { stubs },
});
expect(wrapper.text()).toContain('Slice: 10');
expect(wrapper.text()).toContain('5.00mm');
expect(wrapper.text()).not.toContain('Frame:');
});

it('shows frame number for a cine ruler', () => {
const wrapper = mount(MeasurementRulerDetails, {
props: { tool: { ...baseTool, slice: 0, frame: 2 } },
props: { tool: { ...baseTool, id: seatRuler(), slice: 0, frame: 2 } },
global: { stubs },
});
expect(wrapper.text()).toContain('Frame: 3');
Expand Down
30 changes: 5 additions & 25 deletions src/components/__tests__/PlayControls.spec.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,22 @@
import { mount, VueWrapper } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ComponentPublicInstance, nextTick, ref } from 'vue';
import { ComponentPublicInstance, nextTick } from 'vue';
import PlayControls from '@/src/components/PlayControls.vue';
import { seatCineImage } from '@/src/core/cine/__tests__/cineFixtures';

type PlayControlsProps = { viewId: string; imageId: string | null };
type PlayControlsWrapper = VueWrapper<
ComponentPublicInstance<PlayControlsProps>
>;

vi.mock('@/src/core/cine/isCineImage', () => ({
getCineImage: (imageId: string | null) => {
const frameTimesByImageId: Record<string, number> = {
'image-1': 50,
'image-2': 25,
};

return imageId
? {
header: {
frameTimeMs: frameTimesByImageId[imageId] ?? 40,
},
}
: null;
},
}));

vi.mock('@/src/composables/useSliceConfig', () => ({
useSliceConfig: () => ({
slice: ref(0),
range: ref([0, 2]),
}),
}));

describe('PlayControls', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.useFakeTimers();
// Frame time drives the default FPS: 50ms is 20fps, 25ms is 40fps.
seatCineImage('image-1', { frameTimeMs: 50 });
seatCineImage('image-2', { frameTimeMs: 25 });
});

afterEach(() => {
Expand Down
32 changes: 10 additions & 22 deletions src/components/__tests__/latentGating.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,17 @@
// - Jobs tab ⇒ ModulePanel reveals it only when `configs.size > 0`.
// - Remote save ⇒ the surface/egress engage only when `saveUrl !== ''`.

import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import { shallowMount, flushPromises, VueWrapper } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';

// Keep the Jobs async-component cheap and DOM-safe if it ever renders.
vi.mock('@/src/processing/components/JobsModule.vue', () => ({
default: { name: 'JobsModule', template: '<div />' },
}));

// Observe remote-save egress without a network round-trip or the heavy
// serialize path.
vi.mock('@/src/utils/fetch', () => ({
$fetch: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock('@/src/io/state-file/serialize', () => ({
serialize: vi
.fn()
.mockResolvedValue(new Blob(['x'], { type: 'application/zip' })),
}));

import ModulePanel from '@/src/components/ModulePanel.vue';
import { useProcessingJobsStore } from '@/src/processing';
import useRemoteSaveStateStore from '@/src/store/remote-save-state';
import { ConnectionState, useServerStore } from '@/src/store/server';
import { $fetch } from '@/src/utils/fetch';
import { savePost, saveDependencies } from '@/src/store/__tests__/saveFixtures';
import type { ProcessingProviderConfig } from '@/src/processing';

// Stub the Vuetify shell so mounting ModulePanel exercises only its own gating
Expand Down Expand Up @@ -110,9 +96,11 @@ describe('Jobs tab is latent — gated on provider presence', () => {
});

describe('Remote save is latent — gated on a save target', () => {
let post = savePost();

beforeEach(() => {
setActivePinia(createPinia());
vi.mocked($fetch).mockClear();
post = savePost();
});

it('exposes no save target and performs no egress when unconfigured', async () => {
Expand All @@ -122,19 +110,19 @@ describe('Remote save is latent — gated on a save target', () => {
// hidden (ControlsStrip/WelcomePage gate on `saveUrl !== ''`).
expect(store.saveUrl).toBe('');

await store.saveState();
await store.saveState(saveDependencies(post));

expect($fetch).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();
});

it('performs egress only after a save target is set', async () => {
const store = useRemoteSaveStateStore();
const saveUrl = `${window.location.origin}/save`;
store.setSaveUrl(saveUrl);

await store.saveState();
await store.saveState(saveDependencies(post));

expect($fetch).toHaveBeenCalledTimes(1);
expect(vi.mocked($fetch).mock.calls[0][0]).toBe(saveUrl);
expect(post).toHaveBeenCalledTimes(1);
expect(post.mock.calls[0][0]).toBe(saveUrl);
});
});
Loading
Loading