From edaa2b6d5822e7cce838e5478dd6228cd8d2e1d2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 01:19:17 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=9E=D0=A1=D0=92=20=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=20=D0=BA=20=D1=81=D0=B0=D0=BC=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B5,=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B8=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8F=D0=BC=20id;=20?= =?UTF-8?q?key=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?(I-1,=20M-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- src/layouts/DocumentPage.tsx | 2 +- src/widgets/DocumentReview/entryTree.test.ts | 16 +++++++++++++++ src/widgets/DocumentReview/entryTree.ts | 21 +++++++++++++++++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/layouts/DocumentPage.tsx b/src/layouts/DocumentPage.tsx index edf618d..528e048 100644 --- a/src/layouts/DocumentPage.tsx +++ b/src/layouts/DocumentPage.tsx @@ -54,7 +54,7 @@ export function DocumentPage() { />
{broken && } - {!broken && kind === 'osv' && } + {!broken && kind === 'osv' && } {!broken && kind === 'statement' && } {kind === 'none' && }
diff --git a/src/widgets/DocumentReview/entryTree.test.ts b/src/widgets/DocumentReview/entryTree.test.ts index a55e89a..8276241 100644 --- a/src/widgets/DocumentReview/entryTree.test.ts +++ b/src/widgets/DocumentReview/entryTree.test.ts @@ -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']); + }); }); diff --git a/src/widgets/DocumentReview/entryTree.ts b/src/widgets/DocumentReview/entryTree.ts index 5e3c68e..504d2b3 100644 --- a/src/widgets/DocumentReview/entryTree.ts +++ b/src/widgets/DocumentReview/entryTree.ts @@ -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(); + 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(); 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 {