fix: дерево ОСВ устойчиво к самоссылке, циклам и дублям id; key документа (I-1, M-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 01:19:17 +04:00
parent 5d1c28d0de
commit edaa2b6d58
3 changed files with 35 additions and 4 deletions

View File

@ -54,7 +54,7 @@ export function DocumentPage() {
/>
<main style={{ flex: '1 1 auto', minHeight: 0, background: 'white', borderRadius: 12, padding: 12, overflow: 'auto' }}>
{broken && <Empty description='Документ не удалось распознать — данных для проверки нет' />}
{!broken && kind === 'osv' && <OsvDocument document={doc} onChanged={load} />}
{!broken && kind === 'osv' && <OsvDocument key={doc.id} document={doc} onChanged={load} />}
{!broken && kind === 'statement' && <Empty description='Проверка формы — в следующей задаче' />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main>

View File

@ -31,4 +31,20 @@ describe('buildEntryTree', () => {
it('ребёнок с неизвестным родителем — в корень', () => {
expect(buildEntryTree([e('x', 'missing', 1)]).map((n) => n.id)).toEqual(['x']);
});
it('самоссылка — в корень, без рекурсии', () => {
const tree = buildEntryTree([e('a', 'a', 1), e('b', null, 2)]);
expect(tree.map((n) => n.id)).toEqual(['a', 'b']);
expect(tree[0].children).toBeUndefined();
});
it('цикл из двух — оба в корень, ничего не теряется', () => {
const tree = buildEntryTree([e('a', 'b', 1), e('b', 'a', 2)]);
expect(tree.map((n) => n.id)).toEqual(['a', 'b']);
expect(tree.every((n) => n.children === undefined)).toBe(true);
});
it('дубль id — второй экземпляр пропускается', () => {
const tree = buildEntryTree([e('a', null, 1), e('a', null, 2), e('c', 'a', 3)]);
expect(tree.map((n) => n.id)).toEqual(['a']);
expect(tree[0].row_number).toBe(1);
expect(tree[0].children?.map((n) => n.id)).toEqual(['c']);
});
});

View File

@ -2,14 +2,29 @@ import type { EntryOut } from '../../types/api';
export type EntryNode = EntryOut & { key: string; children?: EntryNode[] };
/** Дерево строк ОСВ по parent_entry_id; порядок — как пришёл (по row_number). */
export function buildEntryTree(entries: EntryOut[]): EntryNode[] {
/** Дерево строк ОСВ по parent_entry_id; порядок — как пришёл (по row_number).
* Защита от «грязных» данных: дубль id — второй экземпляр пропускается; самоссылка и цикл — такие строки идут в корень. */
export function buildEntryTree(input: EntryOut[]): EntryNode[] {
const seen = new Set<string>();
const entries = input.filter((entry) => !seen.has(entry.id) && seen.add(entry.id));
const byId = new Map(entries.map((entry) => [entry.id, entry]));
// Строка в цикле, если, идя вверх по родителям, мы вернёмся к ней самой.
const inCycle = (start: EntryOut): boolean => {
let current = start.parent_entry_id ? byId.get(start.parent_entry_id) : undefined;
for (let steps = 0; current && steps <= entries.length; steps++) {
if (current.id === start.id) return true;
current = current.parent_entry_id ? byId.get(current.parent_entry_id) : undefined;
}
return false;
};
const nodes = new Map<string, EntryNode>();
for (const entry of entries) nodes.set(entry.id, { ...entry, key: entry.id });
const roots: EntryNode[] = [];
for (const entry of entries) {
const node = nodes.get(entry.id) as EntryNode;
const parent = entry.parent_entry_id ? nodes.get(entry.parent_entry_id) : undefined;
const parent = entry.parent_entry_id && !inCycle(entry) ? nodes.get(entry.parent_entry_id) : undefined;
if (parent) {
parent.children = [...(parent.children ?? []), node];
} else {