test(import): cover drag and drop, button wiring, and fixture structures

Three gaps where the implementation could break with a green suite. The
drag listeners and their enter/leave depth counter had no test at all, so
replacing the counter with a boolean passed everything. The Confirm and
Cancel buttons were only asserted on for labels and disabled state, so the
panel could have called mutate with a stale id or nothing at all. And the
fixtures' own specs only checked archive layout, so deleting the U+E202
citation marker - which renders as nothing in an editor and in a diff -
would have left every citation assertion downstream passing vacuously.
This commit is contained in:
Marco Beretta 2026-07-29 04:03:45 +02:00
parent c58f6c54ce
commit 5e9aae7784
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 286 additions and 4 deletions

View file

@ -0,0 +1,107 @@
import { render, screen, fireEvent } from 'test/layout-test-utils';
import Dropzone from '../Dropzone';
function transfer(files: File[] = [], types: string[] = ['Files']) {
return { types, files };
}
const zip = () => new File(['zip-bytes'], 'export.zip', { type: 'application/zip' });
/** The tint that appears over the settings row while a file is dragged across
* it. Selected on `pointer-events-none` rather than `aria-hidden` alone: the
* button's decorative icon is also aria-hidden, and an SVG's `className` is an
* `SVGAnimatedString` rather than a string. */
function overlayOf(container: HTMLElement): HTMLElement {
const overlay = container.querySelector('div[aria-hidden="true"].pointer-events-none');
if (!overlay) {
throw new Error('drag overlay not found');
}
return overlay as HTMLElement;
}
describe('Dropzone drag and drop', () => {
it('imports a dropped export', () => {
const onFile = jest.fn();
const file = zip();
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
const zone = container.firstChild as HTMLElement;
fireEvent.drop(zone, { dataTransfer: transfer([file]) });
expect(onFile).toHaveBeenCalledWith(file);
});
/**
* The counter is load-bearing: dragenter and dragleave both fire for every
* child element crossed, so a plain boolean flickers the overlay off as soon
* as the pointer moves between the label and the button. Replacing the
* counter with a boolean passes every other test in this file.
*/
it('keeps the overlay up while the pointer crosses a child element', () => {
const { container } = render(<Dropzone onFile={jest.fn()} isUploading={false} />);
const zone = container.firstChild as HTMLElement;
const child = screen.getByRole('button');
const overlay = () => overlayOf(container);
fireEvent.dragEnter(zone, { dataTransfer: transfer() });
expect(overlay().className).toContain('opacity-100');
fireEvent.dragEnter(child, { dataTransfer: transfer() });
fireEvent.dragLeave(child, { dataTransfer: transfer() });
expect(overlay().className).toContain('opacity-100');
fireEvent.dragLeave(zone, { dataTransfer: transfer() });
expect(overlay().className).toContain('opacity-0');
});
it('ignores a drag that carries no files', () => {
const onFile = jest.fn();
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
const zone = container.firstChild as HTMLElement;
const overlay = () => overlayOf(container);
fireEvent.dragEnter(zone, { dataTransfer: transfer([], ['text/plain']) });
expect(overlay().className).toContain('opacity-0');
fireEvent.drop(zone, { dataTransfer: transfer([], ['text/plain']) });
expect(onFile).not.toHaveBeenCalled();
});
/** A drop accepted mid-upload would start a second, concurrent import of a
* different archive over the top of the first. */
it('accepts no drop while an upload is already in flight', () => {
const onFile = jest.fn();
const { container } = render(<Dropzone onFile={onFile} isUploading />);
const zone = container.firstChild as HTMLElement;
fireEvent.drop(zone, { dataTransfer: transfer([zip()]) });
expect(onFile).not.toHaveBeenCalled();
});
it('imports a file chosen through the button', () => {
const onFile = jest.fn();
const file = zip();
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
expect(onFile).toHaveBeenCalledWith(file);
expect(input.value).toBe('');
});
it('recovers after a drag that ends outside the row', () => {
const onFile = jest.fn();
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
const zone = container.firstChild as HTMLElement;
const overlay = () => overlayOf(container);
fireEvent.dragEnter(zone, { dataTransfer: transfer() });
fireEvent.dragLeave(zone, { dataTransfer: transfer() });
expect(overlay().className).toContain('opacity-0');
fireEvent.dragEnter(zone, { dataTransfer: transfer() });
expect(overlay().className).toContain('opacity-100');
});
});

View file

@ -62,6 +62,8 @@ describe('Import panel', () => {
let capturedStartOptions: { onError?: (error: unknown) => void } = {};
let showToast: jest.Mock;
let uploadMutate: jest.Mock;
let startMutate: jest.Mock;
let cancelMutate: jest.Mock;
beforeEach(() => {
capturedUploadOptions = {};
@ -77,13 +79,18 @@ describe('Import panel', () => {
return { mutate: uploadMutate, isLoading: false };
},
);
startMutate = jest.fn();
cancelMutate = jest.fn();
dataProvider.useStartImportMutation.mockImplementation(
(options: typeof capturedStartOptions) => {
capturedStartOptions = options ?? {};
return { mutate: jest.fn(), isLoading: false };
return { mutate: startMutate, isLoading: false };
},
);
dataProvider.useCancelImportMutation.mockReturnValue({ mutate: jest.fn(), isLoading: false });
dataProvider.useCancelImportMutation.mockReturnValue({
mutate: cancelMutate,
isLoading: false,
});
dataProvider.useImportJobQuery.mockReturnValue({ data: undefined });
window.localStorage.clear();
});
@ -374,12 +381,54 @@ describe('Import panel', () => {
render(<Import />);
/* Exactly once: the count is the <details> summary. It used to also be
repeated in the status block above, which this assertion enshrined. */
expect(screen.getAllByText(/1 items could not be imported/i)).toHaveLength(1);
repeated in the status block above, which this assertion enshrined.
Singular, because a single failure is one item, not "1 items". */
expect(screen.getAllByText(/^1 item could not be imported$/i)).toHaveLength(1);
expect(screen.getByText(/conversation 11 malformed/i)).toBeInTheDocument();
expect(screen.getByText(/archive truncated/i)).toBeInTheDocument();
});
it('pluralizes the error count for more than one failure', () => {
dataProvider.useImportJobQuery.mockReturnValue({
data: job({
phase: 'failed',
status: 'failed',
report: {
imported: 10,
skipped: 0,
assetsImported: 0,
assetsUnavailable: 0,
errors: ['conversation 11 malformed', 'conversation 12 malformed'],
},
}),
});
render(<Import />);
expect(screen.getByText(/^2 items could not be imported$/i)).toBeInTheDocument();
});
it('pluralizes the report counts, so a single conversation is not "1 conversations"', () => {
dataProvider.useImportJobQuery.mockReturnValue({
data: job({
phase: 'completed',
status: 'completed',
report: {
imported: 1,
skipped: 0,
assetsImported: 1,
assetsUnavailable: 0,
errors: [],
},
}),
});
render(<Import />);
expect(screen.getByText('1 conversation imported, 0 skipped')).toBeInTheDocument();
expect(screen.getByText('1 attachment imported, 0 unavailable')).toBeInTheDocument();
});
it('moves focus back to the import control after starting another import', () => {
dataProvider.useImportJobQuery.mockReturnValue({
data: job({
@ -549,6 +598,66 @@ describe('Import panel', () => {
expect(window.localStorage.getItem('importJobId')).toBeNull();
});
/**
* The buttons were only ever asserted on for their labels and disabled
* state, so the panel could have called mutate with undefined, an empty
* string, or a stale id and every test still passed. On a multi-minute
* import that is a Cancel button that silently does nothing.
*/
it('starts the job the panel is currently tracking', () => {
window.localStorage.setItem('importJobId', 'job-99');
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
render(<Import />);
fireEvent.click(screen.getByRole('button', { name: /^import$/i }));
expect(startMutate).toHaveBeenCalledWith('job-99');
});
it('cancels the job it is tracking, from the confirmation screen', () => {
window.localStorage.setItem('importJobId', 'job-99');
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
render(<Import />);
fireEvent.click(screen.getByRole('button', { name: /cancel import/i }));
expect(cancelMutate).toHaveBeenCalledWith('job-99');
});
it('cancels the job it is tracking, mid-run', () => {
window.localStorage.setItem('importJobId', 'job-99');
dataProvider.useImportJobQuery.mockReturnValue({
data: job({
phase: 'conversations',
status: 'active',
progress: {
conversations: { done: 10, total: 100 },
messages: { done: 0, total: 0 },
assets: { done: 0, total: 0 },
},
}),
});
render(<Import />);
fireEvent.click(screen.getByRole('button', { name: /cancel import/i }));
expect(cancelMutate).toHaveBeenCalledWith('job-99');
});
it('starts the job returned by the upload, not a stale one', () => {
render(<Import />);
/* Queued before the state change so the re-render the upload triggers
already shows the confirmation screen. */
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
act(() => {
capturedUploadOptions.onSuccess?.({ jobId: 'job-42', summary: summary() });
});
fireEvent.click(screen.getByRole('button', { name: /^import$/i }));
expect(startMutate).toHaveBeenCalledWith('job-42');
});
it('shows a distinct toast for an unsupported file type', () => {
render(<Import />);

View file

@ -37,3 +37,69 @@ describe('buildFixtureExport', () => {
archive.close();
});
});
/**
* The fixture's structures, not its layout.
*
* `CITE` is U+E202, which renders as nothing in an editor and in a diff, so
* "tidying" it away is a plausible accident rather than a hypothetical one
* and every citation assertion downstream would still pass, because they match
* on the text before the marker. These guards fail loudly instead.
*/
describe('fixture structures every downstream test depends on', () => {
async function readShard(name: string): Promise<Array<Record<string, never>>> {
const filepath = await buildFixtureExport();
const archive = await openArchive(filepath);
try {
return JSON.parse((await archive.read(name)).toString('utf8'));
} finally {
archive.close();
}
}
it('keeps a real citation anchor with its content reference', async () => {
const shard = await readShard('conversations-000.json');
const mapping = (shard[0] as unknown as { mapping: Record<string, { message?: unknown }> })
.mapping;
const cited = (mapping.a1 as { message: { content: { parts: string[] }; metadata: unknown } })
.message;
expect(cited.content.parts[0]).toBe('Stay in Positano.turn0search0');
expect(cited.content.parts[0]).toContain('');
expect((cited.metadata as { content_references: unknown[] }).content_references).toHaveLength(
1,
);
});
it('keeps the thoughts to reasoning_recap chain the thinking part is built from', async () => {
const shard = await readShard('conversations-000.json');
const mapping = (
shard[0] as unknown as {
mapping: Record<string, { message: { content: { content_type: string } } }>;
}
).mapping;
expect(mapping.t1.message.content.content_type).toBe('thoughts');
expect(mapping.r1.message.content.content_type).toBe('reasoning_recap');
});
it('keeps the multimodal parts the asset pipeline resolves', async () => {
const shard = await readShard('conversations-001.json');
const mapping = (
shard[0] as unknown as {
mapping: Record<
string,
{ message: { content: { parts: Array<{ content_type?: string }> } } }
>;
}
).mapping;
const types = Object.values(mapping)
.flatMap((node) => node.message?.content?.parts ?? [])
.map((part) => (typeof part === 'string' ? 'text' : part.content_type));
expect(types).toContain('image_asset_pointer');
expect(types).toContain('audio_transcription');
expect(types).toContain('audio_asset_pointer');
});
});