285 lines
8.1 KiB
TypeScript
285 lines
8.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { OneNotePackagedSectionDto } from '../model/dto.js';
|
|
import type {
|
|
OneNoteTableOfContents,
|
|
OneNoteTableOfContentsEntry,
|
|
} from '../parser/parse-toc.js';
|
|
import {
|
|
DESKTOP_REVISION_STORE_FORMAT,
|
|
ONE_SECTION_FILE_TYPE,
|
|
} from '../parser/detect-format.js';
|
|
import type { OnePkgError } from './errors.js';
|
|
import {
|
|
buildOneNotePackageTree,
|
|
resolveTocChildPath,
|
|
} from './toc-hierarchy.js';
|
|
import type { ExtractedCabFile } from './types.js';
|
|
|
|
const parseTocMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../parser/parse-toc.js', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../parser/parse-toc.js')>()),
|
|
parseOneNoteTableOfContents: parseTocMock,
|
|
}));
|
|
|
|
beforeEach(() => parseTocMock.mockReset());
|
|
|
|
describe('ONEPKG TOC child paths', () => {
|
|
it('joins a single NFC-normalized child name within its section group', () => {
|
|
expect(resolveTocChildPath('Projects', 'Cafe\u0301.one')).toBe(
|
|
'Projects/Café.one'
|
|
);
|
|
});
|
|
|
|
it.each(['../Secret.one', '..', 'nested/Secret.one', 'C:\\Secret.one'])(
|
|
'rejects unsafe FolderChildFilename %s',
|
|
(filename) => {
|
|
expect(() => resolveTocChildPath('Projects', filename)).toThrow(
|
|
/Unsafe FolderChildFilename/u
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('ONEPKG TOC hierarchy budgets and indexes', () => {
|
|
it('resolves identity fallbacks in authored order with near-linear section lookups', async () => {
|
|
const count = 400;
|
|
const pathReads = { count: 0 };
|
|
const entries: OneNoteTableOfContentsEntry[] = [];
|
|
const extracted: ExtractedCabFile[] = [tocFile()];
|
|
const sections: OneNotePackagedSectionDto[] = [];
|
|
for (let index = 0; index < count; index += 1) {
|
|
const identity = indexedGuid(index + 1);
|
|
const path = `Section-${index.toString().padStart(4, '0')}.one`;
|
|
extracted.push(sectionFile(path, identity, index + 1));
|
|
sections.push(sectionWithObservedPath(path, pathReads));
|
|
entries.push({
|
|
filename: `Alias-${index.toString().padStart(4, '0')}.one`,
|
|
fileIdentity: identity,
|
|
orderingId: index,
|
|
});
|
|
}
|
|
parseTocMock.mockReturnValue(toc(entries));
|
|
let yields = 0;
|
|
|
|
const result = await buildOneNotePackageTree(extracted, sections, {
|
|
limits: { maxTocOperations: count * 8 + 100 },
|
|
cancellation: {
|
|
yield: () => {
|
|
yields += 1;
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.tree.nodes).toHaveLength(count);
|
|
expect(
|
|
result.tree.nodes.map((node) =>
|
|
node.kind === 'section' ? node.sectionId : `group:${node.id}`
|
|
)
|
|
).toEqual(sections.map(({ id }) => id));
|
|
expect(pathReads.count).toBeLessThanOrEqual(count * 5);
|
|
expect(yields).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('keeps ambiguous identities unresolved and fallback sections sorted', async () => {
|
|
const identity = indexedGuid(42);
|
|
parseTocMock.mockReturnValue(
|
|
toc([{ filename: 'Alias.one', fileIdentity: identity, orderingId: 1 }])
|
|
);
|
|
const sections = [section('Z.one'), section('A.one')];
|
|
|
|
const result = await buildOneNotePackageTree(
|
|
[
|
|
tocFile(),
|
|
sectionFile('Z.one', identity, 1),
|
|
sectionFile('A.one', identity, 2),
|
|
],
|
|
sections
|
|
);
|
|
|
|
expect(
|
|
result.tree.nodes.map((node) =>
|
|
node.kind === 'section' ? node.sectionId : `group:${node.id}`
|
|
)
|
|
).toEqual(['A.one', 'Z.one']);
|
|
expect(result.diagnostics.map(({ code }) => code)).toEqual(
|
|
expect.arrayContaining([
|
|
'onetoc2-entry-unresolved',
|
|
'onetoc2-unlisted-sections',
|
|
])
|
|
);
|
|
expect(result.diagnostics).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: 'onetoc2-section-filename-mismatch',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('caps adversarial unresolved-entry diagnostics with an explicit marker', async () => {
|
|
parseTocMock.mockReturnValue(
|
|
toc(
|
|
Array.from({ length: 50 }, (_, index) => ({
|
|
filename: `Missing-${index}.one`,
|
|
fileIdentity: indexedGuid(index + 1),
|
|
orderingId: index,
|
|
}))
|
|
)
|
|
);
|
|
|
|
const result = await buildOneNotePackageTree([tocFile()], [], {
|
|
limits: { maxTocDiagnostics: 5, maxTocOperations: 1_000 },
|
|
});
|
|
|
|
expect(result.diagnostics).toHaveLength(5);
|
|
expect(result.diagnostics.slice(0, 4).map(({ code }) => code)).toEqual([
|
|
'onetoc2-entry-unresolved',
|
|
'onetoc2-entry-unresolved',
|
|
'onetoc2-entry-unresolved',
|
|
'onetoc2-entry-unresolved',
|
|
]);
|
|
expect(result.diagnostics[4]?.code).toBe('onetoc2-diagnostic-limit');
|
|
});
|
|
|
|
it('fails closed at the hierarchy operation limit', async () => {
|
|
parseTocMock.mockReturnValue(
|
|
toc(
|
|
Array.from({ length: 20 }, (_, index) => ({
|
|
filename: `Missing-${index}.one`,
|
|
fileIdentity: indexedGuid(index + 1),
|
|
orderingId: index,
|
|
}))
|
|
)
|
|
);
|
|
|
|
await expect(
|
|
buildOneNotePackageTree([tocFile()], [], {
|
|
limits: { maxTocOperations: 10 },
|
|
})
|
|
).rejects.toEqual(
|
|
expect.objectContaining<Partial<OnePkgError>>({
|
|
diagnostic: expect.objectContaining({
|
|
code: 'onetoc2-operation-limit',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('yields and observes cancellation during a large hierarchy build', async () => {
|
|
parseTocMock.mockReturnValue(
|
|
toc(
|
|
Array.from({ length: 1_000 }, (_, index) => ({
|
|
filename: `Missing-${index}.one`,
|
|
fileIdentity: indexedGuid(index + 1),
|
|
orderingId: index,
|
|
}))
|
|
)
|
|
);
|
|
const controller = new AbortController();
|
|
let yields = 0;
|
|
|
|
await expect(
|
|
buildOneNotePackageTree([tocFile()], [], {
|
|
cancellation: {
|
|
signal: controller.signal,
|
|
yield: () => {
|
|
yields += 1;
|
|
controller.abort();
|
|
},
|
|
},
|
|
})
|
|
).rejects.toEqual(
|
|
expect.objectContaining<Partial<OnePkgError>>({
|
|
diagnostic: expect.objectContaining({ code: 'onepkg-cancelled' }),
|
|
})
|
|
);
|
|
expect(yields).toBe(1);
|
|
});
|
|
});
|
|
|
|
function toc(entries: OneNoteTableOfContentsEntry[]): OneNoteTableOfContents {
|
|
return {
|
|
sourceName: 'Open Notebook.onetoc2',
|
|
fileIdentity: indexedGuid(0),
|
|
entries,
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
function tocFile(): ExtractedCabFile {
|
|
return extractedFile('Open Notebook.onetoc2', Uint8Array.of(1), 0);
|
|
}
|
|
|
|
function sectionFile(
|
|
path: string,
|
|
identity: string,
|
|
index: number
|
|
): ExtractedCabFile {
|
|
const bytes = new Uint8Array(64);
|
|
writeGuid(bytes, 0, ONE_SECTION_FILE_TYPE);
|
|
writeGuid(bytes, 16, identity);
|
|
writeGuid(bytes, 48, DESKTOP_REVISION_STORE_FORMAT);
|
|
return extractedFile(path, bytes, index);
|
|
}
|
|
|
|
function extractedFile(
|
|
path: string,
|
|
bytes: Uint8Array,
|
|
index: number
|
|
): ExtractedCabFile {
|
|
return {
|
|
index,
|
|
path,
|
|
normalizedPath: path,
|
|
folderIndex: 0,
|
|
uncompressedOffset: 0,
|
|
uncompressedSize: bytes.byteLength,
|
|
attributes: 0,
|
|
compression: { kind: 'none', raw: 0, label: 'None' },
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
function section(path: string): OneNotePackagedSectionDto {
|
|
return {
|
|
id: path,
|
|
path,
|
|
displayName: path.slice(0, -4),
|
|
parseStatus: 'not-requested',
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
function sectionWithObservedPath(
|
|
path: string,
|
|
reads: { count: number }
|
|
): OneNotePackagedSectionDto {
|
|
const result = section(path);
|
|
Object.defineProperty(result, 'path', {
|
|
enumerable: true,
|
|
get: () => {
|
|
reads.count += 1;
|
|
return path;
|
|
},
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function indexedGuid(index: number): string {
|
|
const plain = index.toString(16).toUpperCase().padStart(32, '0');
|
|
return `{${plain.slice(0, 8)}-${plain.slice(8, 12)}-${plain.slice(12, 16)}-${plain.slice(16, 20)}-${plain.slice(20)}}`;
|
|
}
|
|
|
|
function writeGuid(target: Uint8Array, offset: number, guid: string): void {
|
|
const plain = guid.replace(/[{}-]/gu, '');
|
|
const canonical = Array.from({ length: 16 }, (_, index) =>
|
|
Number.parseInt(plain.slice(index * 2, index * 2 + 2), 16)
|
|
);
|
|
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
|
|
for (let index = 0; index < order.length; index += 1) {
|
|
target[offset + order[index]!] = canonical[index]!;
|
|
}
|
|
}
|