perf: bound OneNote TOC hierarchy building
This commit is contained in:
@@ -1,6 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveTocChildPath } from './toc-hierarchy.js';
|
||||
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', () => {
|
||||
@@ -18,3 +41,244 @@ describe('ONEPKG TOC child paths', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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]!;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user