379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { OneNoteApplication } from './App.js';
|
|
import type {
|
|
OneNotePackageDto,
|
|
OneNoteSectionDto,
|
|
} from './onenote/model/dto.js';
|
|
import {
|
|
MAX_SOURCE_FILE_BYTES,
|
|
sourceKindFromFileName,
|
|
} from './source-file.js';
|
|
import {
|
|
OneNoteWorkerClient,
|
|
type WorkerPort,
|
|
} from './worker/onenote.client.js';
|
|
import type {
|
|
WorkerRequest,
|
|
WorkerResponse,
|
|
} from './worker/worker-protocol.js';
|
|
|
|
const section: OneNoteSectionDto = {
|
|
format: 'one',
|
|
sourceName: 'Section.one',
|
|
sectionName: 'Project notes',
|
|
pages: [
|
|
{
|
|
id: 'page-1',
|
|
title: 'First meeting',
|
|
text: 'This is the complete extracted page text.',
|
|
textPreview: 'This is the complete extracted page text.',
|
|
createdAt: '2024-01-02T10:00:00.000Z',
|
|
},
|
|
],
|
|
diagnostics: [
|
|
{
|
|
severity: 'warning',
|
|
code: 'UNSUPPORTED_IMAGE',
|
|
message: 'An image was not rendered.',
|
|
recoverable: true,
|
|
},
|
|
],
|
|
parserInfo: {
|
|
implementation: 'typescript',
|
|
supportedVariant: 'desktop-revision-store',
|
|
referenceRevision: 'test',
|
|
},
|
|
};
|
|
|
|
class SuccessfulParserWorker implements WorkerPort {
|
|
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
|
|
onerror: ((event: ErrorEvent) => void) | null = null;
|
|
terminated = false;
|
|
|
|
constructor(private readonly packageResult?: OneNotePackageDto) {}
|
|
|
|
postMessage(message: WorkerRequest): void {
|
|
queueMicrotask(() => {
|
|
if (message.type === 'parse-one') {
|
|
this.respond({
|
|
type: 'section',
|
|
requestId: message.requestId,
|
|
sessionId: 'session-one',
|
|
section,
|
|
});
|
|
} else if (message.type === 'parse-onepkg' && this.packageResult) {
|
|
this.respond({
|
|
type: 'package',
|
|
requestId: message.requestId,
|
|
sessionId: 'session-package',
|
|
notebook: this.packageResult,
|
|
});
|
|
} else if (message.type === 'get-page') {
|
|
const page = section.pages[0]!;
|
|
this.respond({
|
|
type: 'page',
|
|
requestId: message.requestId,
|
|
sessionId: message.sessionId,
|
|
page: {
|
|
...page,
|
|
id: message.pageId,
|
|
blocks: [
|
|
{
|
|
kind: 'text',
|
|
id: 'text-1',
|
|
sourceText: page.text,
|
|
text: page.text,
|
|
runs: [
|
|
{
|
|
start: 0,
|
|
end: page.text.length,
|
|
text: page.text,
|
|
style: {},
|
|
},
|
|
],
|
|
paragraphStyle: {},
|
|
layout: {},
|
|
},
|
|
],
|
|
diagnostics: [],
|
|
},
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
terminate(): void {
|
|
this.terminated = true;
|
|
}
|
|
|
|
private respond(response: WorkerResponse): void {
|
|
this.onmessage?.(new MessageEvent('message', { data: response }));
|
|
}
|
|
}
|
|
|
|
class FailingParserWorker implements WorkerPort {
|
|
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
|
|
onerror: ((event: ErrorEvent) => void) | null = null;
|
|
|
|
postMessage(message: WorkerRequest): void {
|
|
if (message.type !== 'parse-one' && message.type !== 'parse-onepkg') return;
|
|
queueMicrotask(() => {
|
|
this.onmessage?.(
|
|
new MessageEvent('message', {
|
|
data: {
|
|
type: 'failure',
|
|
requestId: message.requestId,
|
|
error: {
|
|
code: 'invalid-format',
|
|
message: 'The source header is not a recognized OneNote file.',
|
|
recoverable: false,
|
|
structure: 'OneStore header',
|
|
},
|
|
} satisfies WorkerResponse,
|
|
})
|
|
);
|
|
});
|
|
}
|
|
|
|
terminate(): void {}
|
|
}
|
|
|
|
describe('OneNoteApplication', () => {
|
|
it('recognizes only supported local filename extensions', () => {
|
|
expect(sourceKindFromFileName('Section.ONE')).toBe('one');
|
|
expect(sourceKindFromFileName('Notebook.OnePkg')).toBe('onepkg');
|
|
expect(sourceKindFromFileName('notes.zip')).toBeNull();
|
|
});
|
|
|
|
it('opens a section, navigates its page, shows diagnostics, and clears', async () => {
|
|
const user = userEvent.setup();
|
|
const worker = new SuccessfulParserWorker();
|
|
render(
|
|
<OneNoteApplication
|
|
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
|
/>
|
|
);
|
|
|
|
await user.upload(
|
|
screen.getByLabelText('Choose a OneNote file'),
|
|
new File([new Uint8Array([1, 2, 3])], 'Section.one')
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('heading', { name: 'First meeting' })
|
|
).toBeInTheDocument();
|
|
expect(
|
|
within(screen.getByRole('article', { name: 'First meeting' })).getByText(
|
|
'This is the complete extracted page text.'
|
|
)
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText('UNSUPPORTED_IMAGE')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Clear file' }));
|
|
expect(worker.terminated).toBe(true);
|
|
expect(
|
|
screen.getByRole('heading', { name: 'Open a OneNote file' })
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('opens a dropped OneNote package through the local worker', async () => {
|
|
const notebook: OneNotePackageDto = {
|
|
format: 'onepkg',
|
|
sourceName: 'Notebook.onepkg',
|
|
sourceSize: 1,
|
|
entries: [],
|
|
sections: [],
|
|
tree: { interpreted: false, nodes: [] },
|
|
diagnostics: [],
|
|
};
|
|
render(
|
|
<OneNoteApplication
|
|
createWorkerClient={() =>
|
|
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
|
|
}
|
|
/>
|
|
);
|
|
|
|
fireEvent.drop(screen.getByTestId('file-drop-zone'), {
|
|
dataTransfer: {
|
|
files: [new File([new Uint8Array([1])], 'Notebook.onepkg')],
|
|
},
|
|
});
|
|
|
|
expect(
|
|
await screen.findByRole('heading', { name: 'Package summary' })
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps a failed packaged section visible beside a readable one', async () => {
|
|
const notebook: OneNotePackageDto = {
|
|
format: 'onepkg',
|
|
sourceName: 'Notebook.onepkg',
|
|
sourceSize: 4096,
|
|
entries: [
|
|
{
|
|
path: 'Notes/Readable.one',
|
|
normalizedPath: 'Notes/Readable.one',
|
|
kind: 'section',
|
|
uncompressedSize: 2048,
|
|
compressionMethod: 'LZX',
|
|
parseStatus: 'parsed',
|
|
},
|
|
{
|
|
path: 'Notes/Broken.one',
|
|
normalizedPath: 'Notes/Broken.one',
|
|
kind: 'section',
|
|
uncompressedSize: 1024,
|
|
compressionMethod: 'LZX',
|
|
parseStatus: 'failed',
|
|
},
|
|
],
|
|
sections: [
|
|
{
|
|
id: 'readable',
|
|
path: 'Notes/Readable.one',
|
|
displayName: 'Readable',
|
|
parseStatus: 'parsed',
|
|
section,
|
|
diagnostics: [],
|
|
},
|
|
{
|
|
id: 'broken',
|
|
path: 'Notes/Broken.one',
|
|
displayName: 'Broken',
|
|
parseStatus: 'failed',
|
|
diagnostics: [
|
|
{
|
|
severity: 'error',
|
|
code: 'SECTION_PARSE_FAILED',
|
|
message: 'This section is malformed.',
|
|
recoverable: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
tree: {
|
|
interpreted: true,
|
|
tocPath: 'Open Notebook.onetoc2',
|
|
nodes: [
|
|
{
|
|
kind: 'section-group',
|
|
id: 'Notes',
|
|
displayName: 'Notes',
|
|
children: [
|
|
{
|
|
kind: 'section',
|
|
id: 'readable',
|
|
sectionId: 'readable',
|
|
displayName: 'Readable',
|
|
},
|
|
{
|
|
kind: 'section',
|
|
id: 'broken',
|
|
sectionId: 'broken',
|
|
displayName: 'Broken',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
diagnostics: [
|
|
{
|
|
severity: 'warning',
|
|
code: 'TEST_PACKAGE_WARNING',
|
|
message: 'Example recoverable package warning.',
|
|
recoverable: true,
|
|
},
|
|
],
|
|
};
|
|
const user = userEvent.setup();
|
|
render(
|
|
<OneNoteApplication
|
|
createWorkerClient={() =>
|
|
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
|
|
}
|
|
/>
|
|
);
|
|
|
|
await user.upload(
|
|
screen.getByLabelText('Choose a OneNote file'),
|
|
new File([new Uint8Array([1])], 'Notebook.onepkg')
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('heading', { name: 'Package summary' })
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText('Notes')).toBeVisible();
|
|
expect(screen.getByText('OneNote notebook order')).toBeVisible();
|
|
expect(
|
|
screen.getByRole('button', { name: /Broken, failed/i })
|
|
).toBeVisible();
|
|
expect(screen.getByText('TEST_PACKAGE_WARNING')).toBeInTheDocument();
|
|
expect(
|
|
await within(
|
|
screen.getByRole('article', { name: 'First meeting' })
|
|
).findByText('This is the complete extracted page text.')
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: /Broken, failed/i }));
|
|
expect(screen.getByText('SECTION_PARSE_FAILED')).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText('This section did not produce a readable page list.')
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows a controlled parser failure', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<OneNoteApplication
|
|
createWorkerClient={() =>
|
|
new OneNoteWorkerClient(() => new FailingParserWorker())
|
|
}
|
|
/>
|
|
);
|
|
|
|
await user.upload(
|
|
screen.getByLabelText('Choose a OneNote file'),
|
|
new File(['not OneNote'], 'Broken.one')
|
|
);
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
'The source header is not a recognized OneNote file.'
|
|
);
|
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
'invalid-format · OneStore header'
|
|
);
|
|
});
|
|
|
|
it('rejects unrelated extensions without starting a worker', async () => {
|
|
const user = userEvent.setup({ applyAccept: false });
|
|
render(<OneNoteApplication />);
|
|
|
|
await user.upload(
|
|
screen.getByLabelText('Choose a OneNote file'),
|
|
new File(['not a notebook'], 'notes.zip')
|
|
);
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
'Choose a file whose name ends in .one or .onepkg.'
|
|
);
|
|
});
|
|
|
|
it('enforces the source-size safety limit before reading', async () => {
|
|
const user = userEvent.setup();
|
|
render(<OneNoteApplication />);
|
|
const file = new File(['tiny'], 'large.one');
|
|
Object.defineProperty(file, 'size', {
|
|
configurable: true,
|
|
value: MAX_SOURCE_FILE_BYTES + 1,
|
|
});
|
|
|
|
await user.upload(screen.getByLabelText('Choose a OneNote file'), file);
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
|
|
});
|
|
});
|