feat: add local OneNote and ONEPKG reader
This commit is contained in:
333
src/App.test.tsx
Normal file
333
src/App.test.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
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', text: page.text }],
|
||||
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: [],
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'TOC_NOT_INTERPRETED',
|
||||
message: 'Package path order is temporary.',
|
||||
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.getByRole('button', { name: /Broken, failed/i })
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('TOC_NOT_INTERPRETED')).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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user