diff --git a/.jules/bolt.md b/.jules/bolt.md index f1a8c1466..2ec7bb9ca 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -77,3 +77,9 @@ Optimized metric route processing to O(N) by creating a mapping of routes direct ## 2024-07-13 - [Optimize Export Dictionary FK lookups] **Learning:** Found O(N * C * E) performance bottleneck in ERD export dictionaries due to repeated array searching with `edges.some()` inside a nested loop over nodes and columns. **Action:** Replace repeated linear array scans for edges by precomputing O(1) Set lookups of foreign key column handles per node before looping. +## 2024-07-28 - Optimize ERD dangling edge lookups +**Learning:** Checking for edge validity in ERD exporters by matching edge handle strings against all column arrays nested inside all graph nodes using `Array.some` or `Array.find` takes $O(N * C)$ time for every edge ($O(E * N * C)$). This causes lag on large schemas. +**Action:** Since edge handles are hex-encoded strings representing the original column name, directly parsing the column name out of the edge handle allows for an $O(1)$ column lookup instead of iterating through nodes and re-encoding their column properties to find a match. +## 2026-09-04 - Asynchronous Query Resolution in Test Suites +**Learning:** Tests can fail unpredictably with a "TestingLibraryElementError: Unable to find an element" exception when synchronous queries like `getByText` or `getByRole` are used immediately after user interactions (e.g. `fireEvent.change`) that trigger asynchronous state updates. Even if the state appears to update immediately locally, it can fall victim to race conditions. +**Action:** When validating visual changes tied to state updates dependent on async operations or complex renders (like diagram search results in ERDs), always use asynchronous queries (e.g., `await screen.findByText(...)`) instead of their synchronous equivalents (`screen.getByText(...)`). diff --git a/frontend/src/App.coverage.test.tsx b/frontend/src/App.coverage.test.tsx index 0b9a20aa8..78baff565 100644 --- a/frontend/src/App.coverage.test.tsx +++ b/frontend/src/App.coverage.test.tsx @@ -326,9 +326,9 @@ describe('App orchestration coverage', () => { fireEvent.click(screen.getAllByRole('button', { name: '열기' })[1]!) expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument() fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'no-match' } }) - expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument() + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'failed' } }) - expect(screen.getByText('ERD_all_2')).toBeInTheDocument() + expect(await screen.findByText('ERD_all_2')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '편집기 열기' })) expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument() diff --git a/frontend/src/erd/__tests__/prisma.test.ts b/frontend/src/erd/__tests__/prisma.test.ts index 73bca1a66..5570947fb 100644 --- a/frontend/src/erd/__tests__/prisma.test.ts +++ b/frontend/src/erd/__tests__/prisma.test.ts @@ -63,8 +63,8 @@ describe('exportPrisma', () => { id: 'e1', source: '2', target: '1', - sourceHandle: 'src-user_id', - targetHandle: 'tgt-id', + sourceHandle: 'src-c-0075-0073-0065-0072-005f-0069-0064', + targetHandle: 'tgt-c-0069-0064', label: 'users_posts', }, ]; @@ -224,8 +224,8 @@ describe('exportPrisma', () => { id: 'e1', source: '2', target: '1', - sourceHandle: 'src-user_id', - targetHandle: 'tgt-id', + sourceHandle: 'src-c-0075-0073-0065-0072-005f-0069-0064', + targetHandle: 'tgt-c-0069-0064', label: '1to1', }, ]; diff --git a/frontend/src/erd/export.ts b/frontend/src/erd/export.ts index 62ce7219e..808bd85f7 100644 --- a/frontend/src/erd/export.ts +++ b/frontend/src/erd/export.ts @@ -2,7 +2,7 @@ import type { Node, Edge } from '@xyflow/react'; import { normalizeBusinessGroupColor } from './businessGroups'; import type { IndexRecommendation } from './cardinality'; import type { ForeignKeyEdgeData, TableNodeData } from './convert'; -import { sourceColumnHandleId, targetColumnHandleId } from './handleUtils'; +import { parseColumnNameFromHandle } from './handleUtils'; export * from './exportDataDictionary'; @@ -67,12 +67,22 @@ function fkColumnsForEdge( return { sourceColumns, targetColumns }; } - const sourceHandleColumn = (sourceNode.data.columns || []) - .find((column) => sourceColumnHandleId(column.column_name) === edge.sourceHandle) - ?.column_name; - const targetHandleColumn = (targetNode.data.columns || []) - .find((column) => targetColumnHandleId(column.column_name) === edge.targetHandle) - ?.column_name; + let sourceHandleColumn: string | undefined = undefined; + if (edge.sourceHandle && edge.sourceHandle.startsWith('src-')) { + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle.slice(4)); + if (parsedSource && (sourceNode.data.columns || []).some(c => c.column_name === parsedSource)) { + sourceHandleColumn = parsedSource; + } + } + + let targetHandleColumn: string | undefined = undefined; + if (edge.targetHandle && edge.targetHandle.startsWith('tgt-')) { + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle.slice(4)); + if (parsedTarget && (targetNode.data.columns || []).some(c => c.column_name === parsedTarget)) { + targetHandleColumn = parsedTarget; + } + } + if (sourceHandleColumn && targetHandleColumn) { return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] }; } diff --git a/frontend/src/erd/handleUtils.ts b/frontend/src/erd/handleUtils.ts index 054d5ab2a..546f8a016 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -14,3 +14,9 @@ export function sourceColumnHandleId(columnName: string): string { export function targetColumnHandleId(columnName: string): string { return `tgt-${sanitizeHandleId(columnName)}` } + +export function parseColumnNameFromHandle(handleId: string): string { + if (!handleId || handleId === 'c-empty' || !handleId.startsWith('c-')) return ''; + const parts = handleId.slice(2).split('-'); + return parts.map(p => String.fromCodePoint(parseInt(p, 16))).join(''); +} diff --git a/frontend/src/erd/prisma.ts b/frontend/src/erd/prisma.ts index 211dfdd8d..589641a0d 100644 --- a/frontend/src/erd/prisma.ts +++ b/frontend/src/erd/prisma.ts @@ -1,6 +1,6 @@ import type { Node, Edge } from "@xyflow/react"; import type { TableNodeData } from "./convert"; -import { sanitizeHandleId } from "./handleUtils"; +import { sanitizeHandleId, parseColumnNameFromHandle } from "./handleUtils"; function sanitizeName(name: string): string { // Prisma model and field names must start with a letter and contain only alphanumeric characters and underscores @@ -69,20 +69,29 @@ export function exportPrisma( const relName = sanitizeName(String(edge.label || `${sourceNode.data.title}_${targetNode.data.title}`)); let sourceField = ""; - if (edge.sourceHandle?.startsWith("src-")) { - sourceField = edge.sourceHandle.slice(4); + if (edge.sourceHandle && edge.sourceHandle.startsWith("src-")) { + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle.slice(4)); + if (parsedSource && (sourceNode.data.columns || []).some(c => c.column_name === parsedSource)) { + sourceField = parsedSource; + } + } + + if (sourceField) { fkNodeColumnPairs.add(`${edge.source}:${sourceField}`); } else if (!edge.sourceHandle) { fkNodesWithoutHandles.add(edge.source); } let targetField = "id"; // fallback - if (edge.targetHandle?.startsWith("tgt-")) { - targetField = edge.targetHandle.slice(4); + if (edge.targetHandle && edge.targetHandle.startsWith("tgt-")) { + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle.slice(4)); + if (parsedTarget && (targetNode.data.columns || []).some(c => c.column_name === parsedTarget)) { + targetField = parsedTarget; + } } if (sourceField) { - const isUnique = sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_pk || false; + const isUnique = sourceNode.data.columns.some(c => c.column_name === sourceField && c.is_pk); const relList = incomingRelationsByNode.get(edge.target) || []; relList.push({ diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..f5c39a933 --- /dev/null +++ b/plan.md @@ -0,0 +1,3 @@ +1. **Fix Flaky Test in `frontend/src/App.coverage.test.tsx`**: Update the failing test `navigates dashboard, project, and diagram states including empty/search branches` to use asynchronous queries (`findByText`, `findAllByText`, `findByRole`) when checking for elements that render based on asynchronous state updates or API mocks. According to the `.jules/bolt.md` (or general memory) and the traceback, we need to use `await screen.findByText('검색 결과가 없습니다.')` instead of `screen.getByText` because filtering depends on state changes that may not have painted immediately after the user interaction. The test is failing due to a race condition (TestingLibraryElementError: Unable to find an element with the text...). +2. **Run Frontend checks**: `cd frontend && pnpm run typecheck && pnpm test -- --run` to verify the fix works. +3. **Complete pre-commit steps**: Complete pre-commit steps to ensure proper testing, verification, review, and reflection are done.