) {
React.useEffect(() => {
- props.onInit?.({ fitView: vi.fn() })
- }, [props.onInit])
+ props.onInit?.({ fitView: vi.fn() });
+ }, [props.onInit]);
return (
{props.nodes.length}
{props.edges.length}
- props.onConnect?.({ source: 'table-1', target: 'table-2' })} />
- props.onEdgeClick?.(event, props.edges[0] ?? edge)} />
- props.onEdgeClick?.(event, { ...edge, label: undefined })} />
- props.onNodeDoubleClick?.(event, props.nodes[0] ?? initialNode)} />
+
+ props.onConnect?.({ source: "table-1", target: "table-2" })
+ }
+ />
+
+ props.onEdgeClick?.(event, props.edges[0] ?? edge)
+ }
+ />
+
+ props.onEdgeClick?.(event, { ...edge, label: undefined })
+ }
+ />
+
+ props.onNodeDoubleClick?.(event, props.nodes[0] ?? initialNode)
+ }
+ />
{props.children}
- )
+ );
}
return {
@@ -84,55 +132,97 @@ vi.mock('@xyflow/react', async () => {
Controls: () => ,
MiniMap: () => ,
Handle: () => ,
- Position: { Top: 'top', Left: 'left', Right: 'right', Bottom: 'bottom' },
+ Position: { Top: "top", Left: "left", Right: "right", Bottom: "bottom" },
ReactFlow: ReactFlowMock,
- ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ ReactFlowProvider: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
addEdge: (next: unknown, current: unknown[]) => [...current, next],
useNodesState: (initial: unknown[]) => {
- const [value, setValue] = React.useState(initial)
- return [value, setValue, vi.fn()]
+ const [value, setValue] = React.useState(initial);
+ return [value, setValue, vi.fn()];
},
useEdgesState: (initial: unknown[]) => {
- const [value, setValue] = React.useState(initial)
- return [value, setValue, vi.fn()]
+ const [value, setValue] = React.useState(initial);
+ return [value, setValue, vi.fn()];
},
__fixtures: { initialNode, otherNode, edge },
- }
-})
+ };
+});
-vi.mock('./erd/convert', async () => {
- const flow = (await import('@xyflow/react')) as any
+vi.mock("./erd/convert", async () => {
+ const flow = (await import("@xyflow/react")) as any;
return {
snapshotToGraph: vi.fn(() => ({
nodes: [flow.__fixtures.initialNode, flow.__fixtures.otherNode],
edges: [flow.__fixtures.edge],
})),
- }
-})
+ };
+});
-vi.mock('./components/modals', () => ({
+vi.mock("./components/modals", () => ({
AddTableModal: (props: any) => (
-
+
{props.isOpen ? (
<>
- props.setNewTableName('audit_log')} />
-
-
+ props.setNewTableName("audit_log")}
+ />
+
+
>
) : null}
),
EditEdgeModal: (props: any) => (
-
-
+
+
{props.editingEdge ? (
<>
- props.setRelLabel(' fk_new ')} />
-
-
-
+ props.setRelLabel(" fk_new ")}
+ />
+
+
+
>
) : null}
@@ -141,87 +231,267 @@ vi.mock('./components/modals', () => ({
{props.shareLinkUrl}
{props.shareLinkError}
-
+
{props.isOpen ? (
<>
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
>
) : null}
),
GroupModal: (props: any) => (
-
+
{props.isOpen ? (
<>
- props.setNewGroupName('Billing')} />
-
- props.onAssignBusinessGroup(props.nodes[0]?.id ?? 'missing-node', 'missing')} />
- props.onAssignBusinessGroup(props.nodes[0]?.id ?? 'missing-node', props.businessGroups[0]?.id ?? '')} />
- props.onDeleteBusinessGroup(props.businessGroups[0]?.id ?? 'missing')} />
-
+ props.setNewGroupName("Billing")}
+ />
+
+
+ props.onAssignBusinessGroup(
+ props.nodes[0]?.id ?? "missing-node",
+ "missing",
+ )
+ }
+ />
+
+ props.onAssignBusinessGroup(
+ props.nodes[0]?.id ?? "missing-node",
+ props.businessGroups[0]?.id ?? "",
+ )
+ }
+ />
+
+ props.onDeleteBusinessGroup(
+ props.businessGroups[0]?.id ?? "missing",
+ )
+ }
+ />
+
>
) : null}
),
CardinalityModal: (props: any) => {
const recommendation = {
- index_name: 'idx_users_email',
- columns: ['email'],
- access_method: 'btree',
+ index_name: "idx_users_email",
+ columns: ["email"],
+ access_method: "btree",
estimated_distinct: 50,
cardinality_ratio: 0.5,
- strength: 'recommended',
- reason: 'selective',
- source: 'cardinality-wizard',
- }
+ strength: "recommended",
+ reason: "selective",
+ source: "cardinality-wizard",
+ };
return (
{props.formatPercent(0.5)}
- {props.strengthLabel('recommended')}
- {props.strengthLabel('consider')}
- {props.strengthLabel('skip')}
- props.onApplyCardinalityRecommendation({ ...recommendation, strength: 'skip' })} />
+
+ {props.strengthLabel("recommended")}
+
+
+ {props.strengthLabel("consider")}
+
+
+ {props.strengthLabel("skip")}
+
+
+ props.onApplyCardinalityRecommendation({
+ ...recommendation,
+ strength: "skip",
+ })
+ }
+ />
{props.isOpen ? (
<>
- props.onCardinalityTableChange('missing')} />
- props.onCardinalityTableChange('table-2')} />
- props.onCardinalityColumnToggle('email', true)} />
- props.onCardinalityDistinctCountChange('email', 'bad')} />
- props.onCardinalityDistinctCountChange('email', '50')} />
- props.onApplyCardinalityRecommendation(recommendation)} />
- props.onApplyCardinalityRecommendation(recommendation)} />
- props.onApplyCardinalityRecommendation({ ...recommendation, columns: undefined })} />
- props.onApplyCardinalityRecommendation({ ...recommendation, index_name: '', columns: [], strength: 'consider' })} />
- props.onApplyCardinalityRecommendation({ ...recommendation, index_name: 'idx_users_second', columns: ['id'] })} />
+ props.onCardinalityTableChange("missing")}
+ />
+ props.onCardinalityTableChange("table-2")}
+ />
+ props.onCardinalityColumnToggle("email", true)}
+ />
+
+ props.onCardinalityDistinctCountChange("email", "bad")
+ }
+ />
+
+ props.onCardinalityDistinctCountChange("email", "50")
+ }
+ />
+
+ props.onApplyCardinalityRecommendation(recommendation)
+ }
+ />
+
+ props.onApplyCardinalityRecommendation(recommendation)
+ }
+ />
+
+ props.onApplyCardinalityRecommendation({
+ ...recommendation,
+ columns: undefined,
+ })
+ }
+ />
+
+ props.onApplyCardinalityRecommendation({
+ ...recommendation,
+ index_name: "",
+ columns: [],
+ strength: "consider",
+ })
+ }
+ />
+
+ props.onApplyCardinalityRecommendation({
+ ...recommendation,
+ index_name: "idx_users_second",
+ columns: ["id"],
+ })
+ }
+ />
{
- const clearButton = document.querySelector('[aria-label="모든 노드 지우기"]')
- clearButton?.click()
- props.onApplyCardinalityRecommendation(recommendation)
+ const clearButton = document.querySelector(
+ '[aria-label="모든 노드 지우기"]',
+ );
+ clearButton?.click();
+ props.onApplyCardinalityRecommendation(recommendation);
}}
/>
-
+
>
) : null}
- )
+ );
},
EditTableModal: (props: any) => (
-
-
+
+
{props.isOpen && props.editingNode ? (
<>
-
-
-
+
+
>
) : null}
),
-}))
+}));
-import App, { DiagramTable } from './App'
-import { snapshotToGraph } from './erd/convert'
+import App, { DiagramTable } from "./App";
+import { snapshotToGraph } from "./erd/convert";
const projects = [
- { project_space_uuid: 'p1', project_name: '' },
- { project_space_uuid: 'p2', project_name: 'HR' },
-]
-const connections = [{ db_connection_uuid: 'c1', conn_name: 'Warehouse' }]
+ { project_space_uuid: "p1", project_name: "" },
+ { project_space_uuid: "p2", project_name: "HR" },
+];
+const connections = [{ db_connection_uuid: "c1", conn_name: "Warehouse" }];
const snapshots = [
- { schema_snapshot_uuid: 's1', status: 'succeeded', schema_filter: 'billing' },
- { schema_snapshot_uuid: 's2', status: 'failed', schema_filter: null },
-]
+ { schema_snapshot_uuid: "s1", status: "succeeded", schema_filter: "billing" },
+ { schema_snapshot_uuid: "s2", status: "failed", schema_filter: null },
+];
beforeEach(() => {
- vi.clearAllMocks()
- api.getMe.mockResolvedValue({ subject: 'user', display_name: 'User', user_account_uuid: 'u' })
- api.listProjects.mockResolvedValue(projects)
- api.listConnections.mockResolvedValue(connections)
- api.listSnapshots.mockResolvedValue(snapshots)
- api.createProject.mockResolvedValue({ project_space_uuid: 'p3', project_name: 'New' })
- api.createConnection.mockResolvedValue({ db_connection_uuid: 'c2', conn_name: 'New DB' })
- api.createSnapshot.mockResolvedValue({ schema_snapshot_uuid: 's3', status: 'queued', schema_filter: 'public' })
+ vi.clearAllMocks();
+ api.getMe.mockResolvedValue({
+ subject: "user",
+ display_name: "User",
+ user_account_uuid: "u",
+ });
+ api.listProjects.mockResolvedValue(projects);
+ api.listConnections.mockResolvedValue(connections);
+ api.listSnapshots.mockResolvedValue(snapshots);
+ api.createProject.mockResolvedValue({
+ project_space_uuid: "p3",
+ project_name: "New",
+ });
+ api.createConnection.mockResolvedValue({
+ db_connection_uuid: "c2",
+ conn_name: "New DB",
+ });
+ api.createSnapshot.mockResolvedValue({
+ schema_snapshot_uuid: "s3",
+ status: "queued",
+ schema_filter: "public",
+ });
api.getSnapshot.mockResolvedValue({
- schema_snapshot_uuid: 's3',
- status: 'succeeded',
- schema_filter: 'public',
+ schema_snapshot_uuid: "s3",
+ status: "succeeded",
+ schema_filter: "public",
error_message: null,
snapshot_json: { relations: [], columns: [], pk_columns: [], fk_edges: [] },
- })
- api.createShareLink.mockResolvedValue({ url: 'http://localhost/api/share/one' })
+ });
+ api.createShareLink.mockResolvedValue({
+ url: "http://localhost/api/share/one",
+ });
exports.inferRelationships.mockReturnValue([
- { id: 'inferred', source: 'table-1', target: 'table-2', label: 'fk_inferred' },
- ])
- vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
- callback(0)
- return 1
- })
- Object.defineProperty(navigator, 'clipboard', {
+ {
+ id: "inferred",
+ source: "table-1",
+ target: "table-2",
+ label: "fk_inferred",
+ },
+ ]);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ });
+ Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
- })
-})
+ });
+});
afterEach(() => {
- cleanup()
- vi.useRealTimers()
- vi.unstubAllGlobals()
- vi.restoreAllMocks()
-})
+ cleanup();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
async function renderReadyApp() {
- render( )
- await screen.findByRole('heading', { name: '대시보드' })
+ render( );
+ await screen.findByRole("heading", { name: "대시보드" });
}
function forceClick(button: HTMLButtonElement) {
- button.disabled = false
- button.removeAttribute('disabled')
- button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ button.disabled = false;
+ button.removeAttribute("disabled");
+ button.dispatchEvent(
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
+ );
}
-describe('App orchestration coverage', () => {
- it('shows loading and explicit authentication failure', async () => {
- let rejectMe!: (reason?: unknown) => void
- api.getMe.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectMe = reject }))
- render( )
- expect(screen.getByText('Authenticating…')).toBeInTheDocument()
- await act(async () => rejectMe(new Error('denied')))
- expect(await screen.findByRole('heading', { name: 'Authentication required' })).toBeInTheDocument()
- expect(screen.getByRole('alert')).toHaveTextContent('denied')
- })
-
- it('navigates dashboard, project, and diagram states including empty/search branches', async () => {
- await renderReadyApp()
- expect(screen.getAllByText('<Billing & Core>').length).toBeGreaterThan(0)
- fireEvent.click(screen.getByRole('button', { name: '전체 보기' }))
- expect(screen.getByRole('heading', { name: '프로젝트' })).toBeInTheDocument()
- 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()
- fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'failed' } })
- expect(screen.getByText('ERD_all_2')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '편집기 열기' }))
- expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument()
-
- cleanup()
- api.listProjects.mockResolvedValueOnce([])
- await act(async () => render( ))
- await screen.findByText('아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.')
- fireEvent.click(screen.getByRole('button', { name: '전체 보기' }))
- expect(screen.getByText('프로젝트가 없습니다. 이름을 입력해 새 프로젝트를 만드세요.')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
- expect(screen.getByText('프로젝트를 선택하세요.')).toBeInTheDocument()
- })
-
- it('creates projects, validates and creates connections, and starts a snapshot', async () => {
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
-
- fireEvent.change(screen.getByLabelText('New project'), { target: { value: ' New ' } })
- fireEvent.click(screen.getByRole('button', { name: 'Create' }))
- await waitFor(() => expect(api.createProject).toHaveBeenCalledWith('New'))
-
- const dsn = screen.getByLabelText('Connection DSN')
- fireEvent.change(dsn, { target: { value: 'postgresql://[' } })
- fireEvent.click(screen.getByRole('button', { name: 'Save connection' }))
- expect(screen.getByRole('alert')).toHaveTextContent('Connection DSN must use')
- fireEvent.change(dsn, { target: { value: 'http://bad.example/db' } })
- fireEvent.click(screen.getByRole('button', { name: 'Save connection' }))
- expect(screen.getByRole('alert')).toHaveTextContent('Connection DSN must use')
- expect(dsn).toHaveValue('')
-
- fireEvent.change(dsn, { target: { value: 'postgresql://db.example/test' } })
- fireEvent.click(screen.getByRole('button', { name: 'Save connection' }))
- await waitFor(() => expect(api.createConnection).toHaveBeenCalledWith('p3', 'target-db', 'postgresql://db.example/test'))
-
- fireEvent.change(screen.getByLabelText('Schema filter (optional)'), { target: { value: ' public ' } })
- fireEvent.click(screen.getByRole('button', { name: 'Reverse engineer → snapshot' }))
- await waitFor(() => expect(api.createSnapshot).toHaveBeenCalledWith('p3', 'c2', 'public'))
- expect(screen.getByText('스냅샷 생성 중...')).toBeInTheDocument()
- })
-
- it('polls a terminal snapshot, builds graph state, and exercises editor handlers', async () => {
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
- const openButtons = await screen.findAllByRole('button', { name: '열기' })
- vi.useFakeTimers()
- fireEvent.click(openButtons[0]!)
+describe("App orchestration coverage", () => {
+ it("shows loading and explicit authentication failure", async () => {
+ let rejectMe!: (reason?: unknown) => void;
+ api.getMe.mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectMe = reject;
+ }),
+ );
+ render( );
+ expect(screen.getByText("Authenticating…")).toBeInTheDocument();
+ await act(async () => rejectMe(new Error("denied")));
+ expect(
+ await screen.findByRole("heading", { name: "Authentication required" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("alert")).toHaveTextContent("denied");
+ });
+
+ it("navigates dashboard, project, and diagram states including empty/search branches", async () => {
+ await renderReadyApp();
+ expect(
+ screen.getAllByText("<Billing & Core>").length,
+ ).toBeGreaterThan(0);
+ fireEvent.click(screen.getByRole("button", { name: "전체 보기" }));
+ expect(
+ screen.getByRole("heading", { name: "프로젝트" }),
+ ).toBeInTheDocument();
+ 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();
+ fireEvent.change(screen.getByLabelText("다이어그램 검색"), {
+ target: { value: "failed" },
+ });
+ expect(screen.getByText("ERD_all_2")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "편집기 열기" }));
+ expect(
+ screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }),
+ ).toBeInTheDocument();
+
+ cleanup();
+ api.listProjects.mockResolvedValueOnce([]);
+ await act(async () => render( ));
+ await screen.findByText(
+ "아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "전체 보기" }));
+ expect(
+ screen.getByText(
+ "프로젝트가 없습니다. 이름을 입력해 새 프로젝트를 만드세요.",
+ ),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "다이어그램" }));
+ expect(screen.getByText("프로젝트를 선택하세요.")).toBeInTheDocument();
+ });
+
+ it("creates projects, validates and creates connections, and starts a snapshot", async () => {
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+
+ fireEvent.change(screen.getByLabelText("New project"), {
+ target: { value: " New " },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Create" }));
+ await waitFor(() => expect(api.createProject).toHaveBeenCalledWith("New"));
+
+ const dsn = screen.getByLabelText("Connection DSN");
+ fireEvent.change(dsn, { target: { value: "postgresql://[" } });
+ fireEvent.click(screen.getByRole("button", { name: "Save connection" }));
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Connection DSN must use",
+ );
+ fireEvent.change(dsn, { target: { value: "http://bad.example/db" } });
+ fireEvent.click(screen.getByRole("button", { name: "Save connection" }));
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Connection DSN must use",
+ );
+ expect(dsn).toHaveValue("");
+
+ fireEvent.change(dsn, {
+ target: { value: "postgresql://db.example/test" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save connection" }));
+ await waitFor(() =>
+ expect(api.createConnection).toHaveBeenCalledWith(
+ "p3",
+ "target-db",
+ "postgresql://db.example/test",
+ ),
+ );
+
+ fireEvent.change(screen.getByLabelText("Schema filter (optional)"), {
+ target: { value: " public " },
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Reverse engineer → snapshot" }),
+ );
+ await waitFor(() =>
+ expect(api.createSnapshot).toHaveBeenCalledWith("p3", "c2", "public"),
+ );
+ expect(screen.getByText("스냅샷 생성 중...")).toBeInTheDocument();
+ });
+
+ it("polls a terminal snapshot, builds graph state, and exercises editor handlers", async () => {
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "다이어그램" }));
+ const openButtons = await screen.findAllByRole("button", { name: "열기" });
+ vi.useFakeTimers();
+ fireEvent.click(openButtons[0]!);
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- await Promise.resolve()
- })
- expect(api.getSnapshot).toHaveBeenCalledWith('s1')
- expect(screen.getByTestId('node-count')).toHaveTextContent('2')
-
- fireEvent.change(screen.getByLabelText('테이블 또는 컬럼 검색'), { target: { value: 'users' } })
- expect(screen.getByText('1개 테이블 일치', { exact: false })).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' }))
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(api.getSnapshot).toHaveBeenCalledWith("s1");
+ expect(screen.getByTestId("node-count")).toHaveTextContent("2");
+
+ const canvasSearchInput = screen.getByLabelText("테이블 또는 컬럼 검색");
+ fireEvent.change(canvasSearchInput, { target: { value: "users" } });
+ const canvasSearchForm = canvasSearchInput.closest("form");
+ const canvasSubmitEvent = new Event("submit", {
+ cancelable: true,
+ bubbles: true,
+ });
+ if (canvasSearchForm) canvasSearchForm.dispatchEvent(canvasSubmitEvent);
+ expect(canvasSubmitEvent.defaultPrevented).toBe(true);
+ expect(
+ screen.getByText("1개 테이블 일치", { exact: false }),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" }));
await act(async () => {
- vi.runOnlyPendingTimers()
- await Promise.resolve()
- })
- expect(screen.getByText('정렬 완료', { exact: false })).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '정렬 되돌리기' }))
- expect(screen.getByText('되돌렸습니다', { exact: false })).toBeInTheDocument()
-
- fireEvent.click(screen.getByTestId('flow-connect'))
- fireEvent.click(screen.getByTestId('edge-label'))
- fireEvent.click(screen.getByTestId('edge-submit'))
- fireEvent.click(screen.getByTestId('flow-edge'))
- fireEvent.click(screen.getByTestId('edge-cancel'))
- fireEvent.click(screen.getByTestId('flow-edge-unlabeled'))
- fireEvent.click(screen.getByTestId('edge-cancel'))
- fireEvent.click(screen.getByTestId('flow-edge'))
- vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
- fireEvent.click(screen.getByTestId('edge-delete'))
- fireEvent.click(screen.getByTestId('edge-delete'))
-
- fireEvent.doubleClick(screen.getByTestId('flow-node'))
- fireEvent.submit(screen.getByTestId('table-empty-form'))
- fireEvent.click(screen.getByTestId('table-cancel'))
- fireEvent.doubleClick(screen.getByTestId('flow-node'))
- fireEvent.submit(screen.getByTestId('table-form'))
- fireEvent.doubleClick(screen.getByTestId('flow-node'))
- fireEvent.click(screen.getByTestId('table-cancel'))
- fireEvent.doubleClick(screen.getByTestId('flow-node'))
- vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
- fireEvent.click(screen.getByTestId('table-delete'))
- fireEvent.click(screen.getByTestId('table-delete'))
- })
-
- it('adds nodes and exercises groups, cardinality, exports, inference, and clearing', async () => {
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
- fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!)
- fireEvent.click(screen.getByTestId('add-name'))
- fireEvent.click(screen.getByTestId('add-submit'))
- expect(screen.getByTestId('node-count')).toHaveTextContent('1')
-
- fireEvent.click(screen.getByRole('button', { name: '업무 그룹' }))
- fireEvent.click(screen.getByTestId('group-create-guard'))
- fireEvent.click(screen.getByTestId('group-name'))
- fireEvent.click(screen.getByTestId('group-create'))
- fireEvent.click(screen.getByTestId('group-assign-missing'))
- fireEvent.click(screen.getByTestId('group-assign'))
- vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
- fireEvent.click(screen.getByTestId('group-delete'))
- fireEvent.click(screen.getByTestId('group-delete'))
- fireEvent.click(screen.getByTestId('group-close'))
-
- fireEvent.click(screen.getByRole('button', { name: '인덱스 카디널리티 계산' }))
- expect(screen.getByTestId('card-format')).toHaveTextContent('50%')
- expect(screen.getByTestId('card-strength-recommended')).toHaveTextContent('추천')
- expect(screen.getByTestId('card-strength-consider')).toHaveTextContent('검토')
- expect(screen.getByTestId('card-strength-skip')).toHaveTextContent('보류')
- fireEvent.click(screen.getByTestId('card-table-missing'))
- fireEvent.click(screen.getByTestId('card-table'))
- fireEvent.click(screen.getByTestId('card-toggle'))
- fireEvent.click(screen.getByTestId('card-distinct-invalid'))
- fireEvent.click(screen.getByTestId('card-distinct'))
- fireEvent.click(screen.getByTestId('card-apply'))
- fireEvent.click(screen.getByTestId('card-apply-duplicate'))
- fireEvent.click(screen.getByTestId('card-apply-no-columns'))
- fireEvent.click(screen.getByTestId('card-apply-empty'))
- fireEvent.click(screen.getByTestId('card-apply-second'))
- fireEvent.click(screen.getByTestId('card-close'))
-
- fireEvent.click(screen.getByRole('button', { name: 'DDL 내보내기' }))
- for (const id of ['export-copy-ddl', 'export-svg', 'export-uml', 'export-mermaid', 'export-dbml', 'export-csv', 'export-md']) {
- fireEvent.click(screen.getByTestId(id))
+ vi.runOnlyPendingTimers();
+ await Promise.resolve();
+ });
+ expect(screen.getByText("정렬 완료", { exact: false })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "정렬 되돌리기" }));
+ expect(
+ screen.getByText("되돌렸습니다", { exact: false }),
+ ).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("flow-connect"));
+ fireEvent.click(screen.getByTestId("edge-label"));
+ fireEvent.click(screen.getByTestId("edge-submit"));
+ fireEvent.click(screen.getByTestId("flow-edge"));
+ fireEvent.click(screen.getByTestId("edge-cancel"));
+ fireEvent.click(screen.getByTestId("flow-edge-unlabeled"));
+ fireEvent.click(screen.getByTestId("edge-cancel"));
+ fireEvent.click(screen.getByTestId("flow-edge"));
+ vi.spyOn(window, "confirm")
+ .mockReturnValueOnce(false)
+ .mockReturnValueOnce(true);
+ fireEvent.click(screen.getByTestId("edge-delete"));
+ fireEvent.click(screen.getByTestId("edge-delete"));
+
+ fireEvent.doubleClick(screen.getByTestId("flow-node"));
+ fireEvent.submit(screen.getByTestId("table-empty-form"));
+ fireEvent.click(screen.getByTestId("table-cancel"));
+ fireEvent.doubleClick(screen.getByTestId("flow-node"));
+ fireEvent.submit(screen.getByTestId("table-form"));
+ fireEvent.doubleClick(screen.getByTestId("flow-node"));
+ fireEvent.click(screen.getByTestId("table-cancel"));
+ fireEvent.doubleClick(screen.getByTestId("flow-node"));
+ vi.spyOn(window, "confirm")
+ .mockReturnValueOnce(false)
+ .mockReturnValueOnce(true);
+ fireEvent.click(screen.getByTestId("table-delete"));
+ fireEvent.click(screen.getByTestId("table-delete"));
+ });
+
+ it("adds nodes and exercises groups, cardinality, exports, inference, and clearing", async () => {
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+ fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!);
+ fireEvent.click(screen.getByTestId("add-name"));
+ fireEvent.click(screen.getByTestId("add-submit"));
+ expect(screen.getByTestId("node-count")).toHaveTextContent("1");
+
+ fireEvent.click(screen.getByRole("button", { name: "업무 그룹" }));
+ fireEvent.click(screen.getByTestId("group-create-guard"));
+ fireEvent.click(screen.getByTestId("group-name"));
+ fireEvent.click(screen.getByTestId("group-create"));
+ fireEvent.click(screen.getByTestId("group-assign-missing"));
+ fireEvent.click(screen.getByTestId("group-assign"));
+ vi.spyOn(window, "confirm")
+ .mockReturnValueOnce(false)
+ .mockReturnValueOnce(true);
+ fireEvent.click(screen.getByTestId("group-delete"));
+ fireEvent.click(screen.getByTestId("group-delete"));
+ fireEvent.click(screen.getByTestId("group-close"));
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "인덱스 카디널리티 계산" }),
+ );
+ expect(screen.getByTestId("card-format")).toHaveTextContent("50%");
+ expect(screen.getByTestId("card-strength-recommended")).toHaveTextContent(
+ "추천",
+ );
+ expect(screen.getByTestId("card-strength-consider")).toHaveTextContent(
+ "검토",
+ );
+ expect(screen.getByTestId("card-strength-skip")).toHaveTextContent("보류");
+ fireEvent.click(screen.getByTestId("card-table-missing"));
+ fireEvent.click(screen.getByTestId("card-table"));
+ fireEvent.click(screen.getByTestId("card-toggle"));
+ fireEvent.click(screen.getByTestId("card-distinct-invalid"));
+ fireEvent.click(screen.getByTestId("card-distinct"));
+ fireEvent.click(screen.getByTestId("card-apply"));
+ fireEvent.click(screen.getByTestId("card-apply-duplicate"));
+ fireEvent.click(screen.getByTestId("card-apply-no-columns"));
+ fireEvent.click(screen.getByTestId("card-apply-empty"));
+ fireEvent.click(screen.getByTestId("card-apply-second"));
+ fireEvent.click(screen.getByTestId("card-close"));
+
+ fireEvent.click(screen.getByRole("button", { name: "DDL 내보내기" }));
+ for (const id of [
+ "export-copy-ddl",
+ "export-svg",
+ "export-uml",
+ "export-mermaid",
+ "export-dbml",
+ "export-csv",
+ "export-md",
+ ]) {
+ fireEvent.click(screen.getByTestId(id));
}
- fireEvent.click(screen.getByTestId('share-create'))
- await waitFor(() => expect(screen.getByTestId('share-url')).toHaveTextContent('/api/share/one'))
- fireEvent.click(screen.getByTestId('share-copy'))
- fireEvent.click(screen.getByTestId('export-close'))
- expect(exports.downloadText).toHaveBeenCalledTimes(6)
-
- fireEvent.click(screen.getByRole('button', { name: '관계 자동 추론' }))
- expect(exports.inferRelationships).toHaveBeenCalled()
- exports.inferRelationships.mockReturnValueOnce([])
- fireEvent.click(screen.getByRole('button', { name: '관계 자동 추론' }))
- vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
- fireEvent.click(screen.getByRole('button', { name: '모든 노드 지우기' }))
- fireEvent.click(screen.getByRole('button', { name: '모든 노드 지우기' }))
- expect(screen.getByText('ERD 캔버스가 비어 있습니다')).toBeInTheDocument()
- })
-
- it('covers guarded editor actions, navigation callbacks, and form selectors', async () => {
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
-
- vi.spyOn(window, 'confirm').mockReturnValue(true)
+ fireEvent.click(screen.getByTestId("share-create"));
+ await waitFor(() =>
+ expect(screen.getByTestId("share-url")).toHaveTextContent(
+ "/api/share/one",
+ ),
+ );
+ fireEvent.click(screen.getByTestId("share-copy"));
+ fireEvent.click(screen.getByTestId("export-close"));
+ expect(exports.downloadText).toHaveBeenCalledTimes(6);
+
+ fireEvent.click(screen.getByRole("button", { name: "관계 자동 추론" }));
+ expect(exports.inferRelationships).toHaveBeenCalled();
+ exports.inferRelationships.mockReturnValueOnce([]);
+ fireEvent.click(screen.getByRole("button", { name: "관계 자동 추론" }));
+ vi.spyOn(window, "confirm")
+ .mockReturnValueOnce(false)
+ .mockReturnValueOnce(true);
+ fireEvent.click(screen.getByRole("button", { name: "모든 노드 지우기" }));
+ fireEvent.click(screen.getByRole("button", { name: "모든 노드 지우기" }));
+ expect(screen.getByText("ERD 캔버스가 비어 있습니다")).toBeInTheDocument();
+ });
+
+ it("covers guarded editor actions, navigation callbacks, and form selectors", async () => {
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+
+ vi.spyOn(window, "confirm").mockReturnValue(true);
for (const id of [
- 'edge-guard-submit',
- 'edge-guard-delete',
- 'share-copy-guard',
- 'table-delete-guard',
- 'add-guard',
- 'group-create-guard',
- 'card-skip-guard',
+ "edge-guard-submit",
+ "edge-guard-delete",
+ "share-copy-guard",
+ "table-delete-guard",
+ "add-guard",
+ "group-create-guard",
+ "card-skip-guard",
]) {
- fireEvent.click(screen.getByTestId(id))
+ fireEvent.click(screen.getByTestId(id));
}
- fireEvent.submit(screen.getByTestId('table-submit-guard'))
+ fireEvent.submit(screen.getByTestId("table-submit-guard"));
for (const name of [
- 'ERD 자동 정렬',
- '정렬 되돌리기',
- '업무 그룹',
- '인덱스 카디널리티 계산',
+ "ERD 자동 정렬",
+ "정렬 되돌리기",
+ "업무 그룹",
+ "인덱스 카디널리티 계산",
]) {
- const button = screen.getByRole('button', { name }) as HTMLButtonElement
- forceClick(button)
+ const button = screen.getByRole("button", { name }) as HTMLButtonElement;
+ forceClick(button);
}
- fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' }))
- fireEvent.click(screen.getByTestId('export-close'))
-
- fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } })
- fireEvent.change(screen.getByLabelText('Connection'), { target: { value: 'c1' } })
- fireEvent.change(screen.getByLabelText('New connection (DSN)'), { target: { value: 'Analytics' } })
-
- fireEvent.change(screen.getByLabelText('New project'), { target: { value: ' ' } })
- forceClick(screen.getByRole('button', { name: 'Create' }))
- fireEvent.change(screen.getByLabelText('New project'), { target: { value: 'demo' } })
-
- fireEvent.change(screen.getByLabelText('Project'), { target: { value: '' } })
- forceClick(screen.getByRole('button', { name: 'Save connection' }))
- fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p1' } })
- fireEvent.change(screen.getByLabelText('New connection (DSN)'), { target: { value: ' ' } })
- forceClick(screen.getByRole('button', { name: 'Save connection' }))
- fireEvent.change(screen.getByLabelText('Connection'), { target: { value: '' } })
- forceClick(screen.getByRole('button', { name: 'Reverse engineer → snapshot' }))
- fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!)
- fireEvent.click(screen.getByTestId('add-cancel'))
-
- fireEvent.click(screen.getByRole('button', { name: '대시보드' }))
- fireEvent.click(screen.getByRole('button', { name: /Billing.*다이어그램 보기/ }))
- expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '대시보드' }))
- await waitFor(() => expect(screen.getAllByRole('button', { name: '열기' }).length).toBeGreaterThan(0))
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
- expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '대시보드' }))
- fireEvent.click(screen.getByRole('button', { name: '편집기로 이동' }))
- expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '대시보드' }))
- fireEvent.click(screen.getByRole('button', { name: '새 모델링' }))
- expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('button', { name: '프로젝트' }))
- fireEvent.change(screen.getByLabelText('새 프로젝트 이름'), { target: { value: 'Roadmap' } })
- fireEvent.click(screen.getByRole('button', { name: '새 프로젝트' }))
- await waitFor(() => expect(api.createProject).toHaveBeenCalledWith('Roadmap'))
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
- expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '대시보드' }))
- fireEvent.click(screen.getByRole('button', { name: '목록 보기' }))
- })
-
- it('clears replacement copy timers and pending timers during close and unmount', async () => {
- vi.useFakeTimers()
+ fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" }));
+ fireEvent.click(screen.getByTestId("export-close"));
+
+ fireEvent.change(screen.getByLabelText("Project"), {
+ target: { value: "p2" },
+ });
+ fireEvent.change(screen.getByLabelText("Connection"), {
+ target: { value: "c1" },
+ });
+ fireEvent.change(screen.getByLabelText("New connection (DSN)"), {
+ target: { value: "Analytics" },
+ });
+
+ fireEvent.change(screen.getByLabelText("New project"), {
+ target: { value: " " },
+ });
+ forceClick(screen.getByRole("button", { name: "Create" }));
+ fireEvent.change(screen.getByLabelText("New project"), {
+ target: { value: "demo" },
+ });
+
+ fireEvent.change(screen.getByLabelText("Project"), {
+ target: { value: "" },
+ });
+ forceClick(screen.getByRole("button", { name: "Save connection" }));
+ fireEvent.change(screen.getByLabelText("Project"), {
+ target: { value: "p1" },
+ });
+ fireEvent.change(screen.getByLabelText("New connection (DSN)"), {
+ target: { value: " " },
+ });
+ forceClick(screen.getByRole("button", { name: "Save connection" }));
+ fireEvent.change(screen.getByLabelText("Connection"), {
+ target: { value: "" },
+ });
+ forceClick(
+ screen.getByRole("button", { name: "Reverse engineer → snapshot" }),
+ );
+ fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!);
+ fireEvent.click(screen.getByTestId("add-cancel"));
+
+ fireEvent.click(screen.getByRole("button", { name: "대시보드" }));
+ fireEvent.click(
+ screen.getByRole("button", { name: /Billing.*다이어그램 보기/ }),
+ );
+ expect(
+ screen.getByRole("heading", { name: "다이어그램" }),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "대시보드" }));
+ await waitFor(() =>
+ expect(
+ screen.getAllByRole("button", { name: "열기" }).length,
+ ).toBeGreaterThan(0),
+ );
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
+ expect(
+ screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "대시보드" }));
+ fireEvent.click(screen.getByRole("button", { name: "편집기로 이동" }));
+ expect(
+ screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "대시보드" }));
+ fireEvent.click(screen.getByRole("button", { name: "새 모델링" }));
+ expect(
+ screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }),
+ ).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "프로젝트" }));
+ fireEvent.change(screen.getByLabelText("새 프로젝트 이름"), {
+ target: { value: "Roadmap" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "새 프로젝트" }));
+ await waitFor(() =>
+ expect(api.createProject).toHaveBeenCalledWith("Roadmap"),
+ );
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
+ expect(
+ screen.getByRole("heading", { name: "다이어그램" }),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "대시보드" }));
+ fireEvent.click(screen.getByRole("button", { name: "목록 보기" }));
+ });
+
+ it("clears replacement copy timers and pending timers during close and unmount", async () => {
+ vi.useFakeTimers();
+ await act(async () => {
+ render( );
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+ fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!);
+ fireEvent.click(screen.getByTestId("add-name"));
+ fireEvent.click(screen.getByTestId("add-submit"));
+ fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" }));
+ fireEvent.click(screen.getByTestId("export-copy-ddl"));
+ fireEvent.click(screen.getByTestId("export-copy-ddl"));
+ fireEvent.click(screen.getByTestId("share-create"));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByTestId("share-copy"));
await act(async () => {
- render( )
- await Promise.resolve()
- await Promise.resolve()
- })
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
- fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!)
- fireEvent.click(screen.getByTestId('add-name'))
- fireEvent.click(screen.getByTestId('add-submit'))
- fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' }))
- fireEvent.click(screen.getByTestId('export-copy-ddl'))
- fireEvent.click(screen.getByTestId('export-copy-ddl'))
- fireEvent.click(screen.getByTestId('share-create'))
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByTestId("share-copy"));
await act(async () => {
- await Promise.resolve()
- await Promise.resolve()
- })
- fireEvent.click(screen.getByTestId('share-copy'))
- await act(async () => { await Promise.resolve() })
- fireEvent.click(screen.getByTestId('share-copy'))
- await act(async () => { await Promise.resolve() })
+ await Promise.resolve();
+ });
await act(async () => {
- vi.advanceTimersByTime(2000)
- await Promise.resolve()
- })
- fireEvent.click(screen.getByTestId('export-close'))
-
- fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' }))
- fireEvent.click(screen.getByTestId('export-copy-ddl'))
- fireEvent.click(screen.getByTestId('share-create'))
- await act(async () => { await Promise.resolve() })
- fireEvent.click(screen.getByTestId('share-copy'))
- await act(async () => { await Promise.resolve() })
- fireEvent.click(screen.getByTestId('export-close'))
-
- fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' }))
- fireEvent.click(screen.getByTestId('export-copy-ddl'))
- fireEvent.click(screen.getByTestId('share-create'))
- await act(async () => { await Promise.resolve() })
- fireEvent.click(screen.getByTestId('share-copy'))
- await act(async () => { await Promise.resolve() })
- cleanup()
- })
-
- it('ignores authentication completions after unmount', async () => {
- let resolveMe!: (value: any) => void
- api.getMe.mockReturnValueOnce(new Promise((resolve) => { resolveMe = resolve }))
- render( )
- cleanup()
- await act(async () => resolveMe({ subject: 'late', display_name: 'Late' }))
-
- let rejectMe!: (reason: unknown) => void
- api.getMe.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectMe = reject }))
- render( )
- cleanup()
- await act(async () => rejectMe(new Error('late failure')))
- })
-
- it('logs auto-layout failures and preserves nodes added after the undo snapshot', async () => {
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
- vi.useFakeTimers()
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
+ vi.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByTestId("export-close"));
+
+ fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" }));
+ fireEvent.click(screen.getByTestId("export-copy-ddl"));
+ fireEvent.click(screen.getByTestId("share-create"));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByTestId("share-copy"));
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- await Promise.resolve()
- })
- vi.useRealTimers()
-
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
- vi.stubGlobal('requestAnimationFrame', () => { throw new Error('frame unavailable') })
- fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' }))
- await waitFor(() => expect(screen.getByText('정렬에 실패했습니다. 다시 시도해 주세요.', { exact: false })).toBeInTheDocument())
- expect(consoleError).toHaveBeenCalled()
-
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1 })
- fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' }))
- await screen.findByText('정렬 완료', { exact: false })
- fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!)
- fireEvent.click(screen.getByTestId('add-name'))
- fireEvent.click(screen.getByTestId('add-submit'))
- fireEvent.click(screen.getByRole('button', { name: '정렬 되돌리기' }))
- expect(screen.getByTestId('node-count')).toHaveTextContent('3')
- })
-
- it('shows terminal refresh failures from the polling loop', async () => {
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByTestId("export-close"));
+
+ fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" }));
+ fireEvent.click(screen.getByTestId("export-copy-ddl"));
+ fireEvent.click(screen.getByTestId("share-create"));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByTestId("share-copy"));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ cleanup();
+ });
+
+ it("ignores authentication completions after unmount", async () => {
+ let resolveMe!: (value: any) => void;
+ api.getMe.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveMe = resolve;
+ }),
+ );
+ render( );
+ cleanup();
+ await act(async () => resolveMe({ subject: "late", display_name: "Late" }));
+
+ let rejectMe!: (reason: unknown) => void;
+ api.getMe.mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectMe = reject;
+ }),
+ );
+ render( );
+ cleanup();
+ await act(async () => rejectMe(new Error("late failure")));
+ });
+
+ it("logs auto-layout failures and preserves nodes added after the undo snapshot", async () => {
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "다이어그램" }));
+ vi.useFakeTimers();
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ vi.useRealTimers();
+
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ vi.stubGlobal("requestAnimationFrame", () => {
+ throw new Error("frame unavailable");
+ });
+ fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" }));
+ await waitFor(() =>
+ expect(
+ screen.getByText("정렬에 실패했습니다. 다시 시도해 주세요.", {
+ exact: false,
+ }),
+ ).toBeInTheDocument(),
+ );
+ expect(consoleError).toHaveBeenCalled();
+
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ });
+ fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" }));
+ await screen.findByText("정렬 완료", { exact: false });
+ fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!);
+ fireEvent.click(screen.getByTestId("add-name"));
+ fireEvent.click(screen.getByTestId("add-submit"));
+ fireEvent.click(screen.getByRole("button", { name: "정렬 되돌리기" }));
+ expect(screen.getByTestId("node-count")).toHaveTextContent("3");
+ });
+
+ it("shows terminal refresh failures from the polling loop", async () => {
api.listSnapshots
.mockResolvedValueOnce(snapshots)
- .mockRejectedValueOnce(new Error('terminal refresh down'))
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
- vi.useFakeTimers()
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
+ .mockRejectedValueOnce(new Error("terminal refresh down"));
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "다이어그램" }));
+ vi.useFakeTimers();
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- await Promise.resolve()
- })
- expect(screen.getByRole('alert')).toHaveTextContent('terminal refresh down')
- })
-
- it('ignores stale project metadata failures after changing projects', async () => {
- let rejectConnections!: (reason: unknown) => void
- let rejectSnapshots!: (reason: unknown) => void
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "terminal refresh down",
+ );
+ });
+
+ it("ignores stale project metadata failures after changing projects", async () => {
+ let rejectConnections!: (reason: unknown) => void;
+ let rejectSnapshots!: (reason: unknown) => void;
api.listConnections
- .mockReturnValueOnce(new Promise((_resolve, reject) => { rejectConnections = reject }))
- .mockResolvedValueOnce(connections)
+ .mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectConnections = reject;
+ }),
+ )
+ .mockResolvedValueOnce(connections);
api.listSnapshots
- .mockReturnValueOnce(new Promise((_resolve, reject) => { rejectSnapshots = reject }))
- .mockResolvedValueOnce(snapshots)
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
- fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } })
+ .mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectSnapshots = reject;
+ }),
+ )
+ .mockResolvedValueOnce(snapshots);
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+ fireEvent.change(screen.getByLabelText("Project"), {
+ target: { value: "p2" },
+ });
await act(async () => {
- rejectConnections(new Error('stale connections'))
- rejectSnapshots(new Error('stale snapshots'))
- await Promise.resolve()
- })
- expect(screen.queryByText(/stale (connections|snapshots)/)).not.toBeInTheDocument()
- })
-
- it('renders snapshot failures and polls without a selected project', async () => {
+ rejectConnections(new Error("stale connections"));
+ rejectSnapshots(new Error("stale snapshots"));
+ await Promise.resolve();
+ });
+ expect(
+ screen.queryByText(/stale (connections|snapshots)/),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders snapshot failures and polls without a selected project", async () => {
api.getSnapshot.mockResolvedValue({
- schema_snapshot_uuid: 's3',
- status: 'failed',
+ schema_snapshot_uuid: "s3",
+ status: "failed",
schema_filter: null,
- error_message: 'database rejected snapshot',
+ error_message: "database rejected snapshot",
snapshot_json: null,
- })
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
- fireEvent.change(screen.getByLabelText('Connection DSN'), { target: { value: 'postgresql://db.example/test' } })
- fireEvent.click(screen.getByRole('button', { name: 'Save connection' }))
- await waitFor(() => expect(api.createConnection).toHaveBeenCalled())
- fireEvent.click(screen.getByRole('button', { name: 'Reverse engineer → snapshot' }))
- await waitFor(() => expect(api.createSnapshot).toHaveBeenCalledWith('p1', 'c2', undefined))
-
- vi.useFakeTimers()
- fireEvent.change(screen.getByLabelText('Project'), { target: { value: '' } })
+ });
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+ fireEvent.change(screen.getByLabelText("Connection DSN"), {
+ target: { value: "postgresql://db.example/test" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save connection" }));
+ await waitFor(() => expect(api.createConnection).toHaveBeenCalled());
+ fireEvent.click(
+ screen.getByRole("button", { name: "Reverse engineer → snapshot" }),
+ );
+ await waitFor(() =>
+ expect(api.createSnapshot).toHaveBeenCalledWith("p1", "c2", undefined),
+ );
+
+ vi.useFakeTimers();
+ fireEvent.change(screen.getByLabelText("Project"), {
+ target: { value: "" },
+ });
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- await Promise.resolve()
- })
- expect(screen.getByRole('alert')).toHaveTextContent('database rejected snapshot')
- })
-
- it('renders user identity fallbacks and a diagram list without a project label', async () => {
- api.getMe.mockResolvedValueOnce({ subject: 'subject-only', display_name: null })
- await renderReadyApp()
- expect(screen.getByText('subject-only')).toBeInTheDocument()
-
- cleanup()
- api.getMe.mockResolvedValueOnce({ subject: '', display_name: null })
- await renderReadyApp()
- expect(screen.getByText('인증 필요')).toBeInTheDocument()
-
- cleanup()
- const onOpenEditor = vi.fn()
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "database rejected snapshot",
+ );
+ });
+
+ it("renders user identity fallbacks and a diagram list without a project label", async () => {
+ api.getMe.mockResolvedValueOnce({
+ subject: "subject-only",
+ display_name: null,
+ });
+ await renderReadyApp();
+ expect(screen.getByText("subject-only")).toBeInTheDocument();
+
+ cleanup();
+ api.getMe.mockResolvedValueOnce({ subject: "", display_name: null });
+ await renderReadyApp();
+ expect(screen.getByText("인증 필요")).toBeInTheDocument();
+
+ cleanup();
+ const onOpenEditor = vi.fn();
render(
,
- )
- expect(screen.getAllByText('현재 프로젝트')).toHaveLength(2)
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
- expect(onOpenEditor).toHaveBeenCalledWith('s1')
- })
-
- it('ignores duplicate share creation while a request is pending', async () => {
- let resolveShare!: (value: { url: string }) => void
- api.createShareLink.mockReturnValueOnce(new Promise((resolve) => { resolveShare = resolve }))
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
- fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' }))
- fireEvent.click(screen.getByTestId('share-create'))
- fireEvent.click(screen.getByTestId('share-create'))
- expect(api.createShareLink).toHaveBeenCalledTimes(1)
- await act(async () => resolveShare({ url: 'http://localhost/api/share/done' }))
- })
-
- it('preserves positions across graph refresh and applies recommendations with sibling nodes', async () => {
- let pollCount = 0
+ );
+ expect(screen.getAllByText("현재 프로젝트")).toHaveLength(2);
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
+ expect(onOpenEditor).toHaveBeenCalledWith("s1");
+ });
+
+ it("ignores duplicate share creation while a request is pending", async () => {
+ let resolveShare!: (value: { url: string }) => void;
+ api.createShareLink.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveShare = resolve;
+ }),
+ );
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+ fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" }));
+ fireEvent.click(screen.getByTestId("share-create"));
+ fireEvent.click(screen.getByTestId("share-create"));
+ expect(api.createShareLink).toHaveBeenCalledTimes(1);
+ await act(async () =>
+ resolveShare({ url: "http://localhost/api/share/done" }),
+ );
+ });
+
+ it("preserves positions across graph refresh and applies recommendations with sibling nodes", async () => {
+ let pollCount = 0;
api.getSnapshot.mockImplementation(async () => ({
- schema_snapshot_uuid: 's3',
- status: pollCount++ === 0 ? 'running' : 'succeeded',
- schema_filter: 'public',
+ schema_snapshot_uuid: "s3",
+ status: pollCount++ === 0 ? "running" : "succeeded",
+ schema_filter: "public",
error_message: null,
- snapshot_json: { relations: [], columns: [], pk_columns: [], fk_edges: [] },
- }))
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
- vi.useFakeTimers()
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
+ snapshot_json: {
+ relations: [],
+ columns: [],
+ pk_columns: [],
+ fk_edges: [],
+ },
+ }));
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "다이어그램" }));
+ vi.useFakeTimers();
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- await Promise.resolve()
- })
- expect(screen.getByTestId('node-count')).toHaveTextContent('2')
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(screen.getByTestId("node-count")).toHaveTextContent("2");
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- await Promise.resolve()
- })
- vi.useRealTimers()
- expect(screen.getByTestId('node-count')).toHaveTextContent('2')
-
- fireEvent.click(screen.getByRole('button', { name: '업무 그룹' }))
- fireEvent.click(screen.getByTestId('group-name'))
- fireEvent.click(screen.getByTestId('group-create'))
- fireEvent.click(screen.getByTestId('group-assign'))
- vi.spyOn(window, 'confirm').mockReturnValue(true)
- fireEvent.click(screen.getByTestId('group-delete'))
- fireEvent.click(screen.getByTestId('group-close'))
-
- fireEvent.click(screen.getByRole('button', { name: '인덱스 카디널리티 계산' }))
- fireEvent.click(screen.getByTestId('card-table'))
- fireEvent.click(screen.getByTestId('card-apply'))
- fireEvent.click(screen.getByTestId('card-clear-apply'))
- })
-
- it('falls back to node ids when auto-layout receives legacy nodes without titles', async () => {
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ vi.useRealTimers();
+ expect(screen.getByTestId("node-count")).toHaveTextContent("2");
+
+ fireEvent.click(screen.getByRole("button", { name: "업무 그룹" }));
+ fireEvent.click(screen.getByTestId("group-name"));
+ fireEvent.click(screen.getByTestId("group-create"));
+ fireEvent.click(screen.getByTestId("group-assign"));
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+ fireEvent.click(screen.getByTestId("group-delete"));
+ fireEvent.click(screen.getByTestId("group-close"));
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "인덱스 카디널리티 계산" }),
+ );
+ fireEvent.click(screen.getByTestId("card-table"));
+ fireEvent.click(screen.getByTestId("card-apply"));
+ fireEvent.click(screen.getByTestId("card-clear-apply"));
+ });
+
+ it("falls back to node ids when auto-layout receives legacy nodes without titles", async () => {
vi.mocked(snapshotToGraph).mockReturnValueOnce({
nodes: [
- { id: 'z-node', type: 'tableNode', position: { x: 0, y: 0 }, data: { columns: [], badges: { pk: false, fk: false } } },
- { id: 'a-node', type: 'tableNode', position: { x: 1, y: 1 }, data: { columns: [], badges: { pk: false, fk: false } } },
+ {
+ id: "z-node",
+ type: "tableNode",
+ position: { x: 0, y: 0 },
+ data: { columns: [], badges: { pk: false, fk: false } },
+ },
+ {
+ id: "a-node",
+ type: "tableNode",
+ position: { x: 1, y: 1 },
+ data: { columns: [], badges: { pk: false, fk: false } },
+ },
] as any,
edges: [],
- })
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
- vi.useFakeTimers()
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
+ });
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "다이어그램" }));
+ vi.useFakeTimers();
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- await Promise.resolve()
- })
- vi.useRealTimers()
- fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' }))
- await screen.findByText('정렬 완료', { exact: false })
- })
-
- it('reports API effect failures, snapshot polling failures, share failures, and clipboard failures', async () => {
- api.listConnections.mockRejectedValueOnce(new Error('connections down'))
- api.listSnapshots.mockRejectedValueOnce(new Error('snapshots down'))
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '편집기' }))
- expect(await screen.findByRole('alert')).toHaveTextContent(/down/)
- fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!)
- fireEvent.click(screen.getByTestId('add-name'))
- fireEvent.click(screen.getByTestId('add-submit'))
- fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' }))
- api.createShareLink.mockRejectedValueOnce(new Error('share down'))
- fireEvent.click(screen.getByTestId('share-create'))
- await waitFor(() => expect(screen.getByTestId('share-error')).toHaveTextContent('share down'))
-
- api.createShareLink.mockResolvedValueOnce({ url: 'http://localhost/api/share/fail-copy' })
- fireEvent.click(screen.getByTestId('share-create'))
- await waitFor(() => expect(screen.getByTestId('share-url')).toHaveTextContent('fail-copy'))
- vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('copy down'))
- fireEvent.click(screen.getByTestId('share-copy'))
- await waitFor(() => expect(screen.getByTestId('share-error')).toHaveTextContent('복사에 실패'))
-
- cleanup()
- vi.useRealTimers()
- api.listConnections.mockResolvedValue(connections)
- api.listSnapshots.mockResolvedValue(snapshots)
- await renderReadyApp()
- fireEvent.click(screen.getByRole('button', { name: '다이어그램' }))
- await waitFor(() => expect(screen.getAllByRole('button', { name: '열기' }).length).toBeGreaterThan(0))
- vi.useFakeTimers()
- api.getSnapshot.mockRejectedValueOnce(new Error('poll down'))
- fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!)
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ vi.useRealTimers();
+ fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" }));
+ await screen.findByText("정렬 완료", { exact: false });
+ });
+
+ it("reports API effect failures, snapshot polling failures, share failures, and clipboard failures", async () => {
+ api.listConnections.mockRejectedValueOnce(new Error("connections down"));
+ api.listSnapshots.mockRejectedValueOnce(new Error("snapshots down"));
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "편집기" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(/down/);
+ fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!);
+ fireEvent.click(screen.getByTestId("add-name"));
+ fireEvent.click(screen.getByTestId("add-submit"));
+ fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" }));
+ api.createShareLink.mockRejectedValueOnce(new Error("share down"));
+ fireEvent.click(screen.getByTestId("share-create"));
+ await waitFor(() =>
+ expect(screen.getByTestId("share-error")).toHaveTextContent("share down"),
+ );
+
+ api.createShareLink.mockResolvedValueOnce({
+ url: "http://localhost/api/share/fail-copy",
+ });
+ fireEvent.click(screen.getByTestId("share-create"));
+ await waitFor(() =>
+ expect(screen.getByTestId("share-url")).toHaveTextContent("fail-copy"),
+ );
+ vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(
+ new Error("copy down"),
+ );
+ fireEvent.click(screen.getByTestId("share-copy"));
+ await waitFor(() =>
+ expect(screen.getByTestId("share-error")).toHaveTextContent(
+ "복사에 실패",
+ ),
+ );
+
+ cleanup();
+ vi.useRealTimers();
+ api.listConnections.mockResolvedValue(connections);
+ api.listSnapshots.mockResolvedValue(snapshots);
+ await renderReadyApp();
+ fireEvent.click(screen.getByRole("button", { name: "다이어그램" }));
+ await waitFor(() =>
+ expect(
+ screen.getAllByRole("button", { name: "열기" }).length,
+ ).toBeGreaterThan(0),
+ );
+ vi.useFakeTimers();
+ api.getSnapshot.mockRejectedValueOnce(new Error("poll down"));
+ fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!);
await act(async () => {
- vi.advanceTimersByTime(1000)
- await Promise.resolve()
- })
- expect(screen.getByRole('alert')).toHaveTextContent('poll down')
- })
-})
+ vi.advanceTimersByTime(1000);
+ await Promise.resolve();
+ });
+ expect(screen.getByRole("alert")).toHaveTextContent("poll down");
+ });
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0fa62ede3..278282dcd 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -72,7 +72,16 @@ const TERMINAL_SNAPSHOT_STATUSES = new Set([
"not_found",
]);
-const SUPPORTED_DSN_PROTOCOLS = new Set(["postgres:", "postgresql:", "snowflake:"]);
+const SUPPORTED_DSN_PROTOCOLS = new Set([
+ "postgres:",
+ "postgresql:",
+ "snowflake:",
+]);
+
+function sanitizeCssClass(str: unknown): string {
+ if (typeof str !== "string") return "";
+ return str.replace(/[^a-zA-Z0-9-_]/g, "");
+}
function sanitizeHtml(str: string | null | undefined): string {
if (!str) return "";
@@ -168,7 +177,9 @@ export default function App() {
const [shareLinkError, setShareLinkError] = useState(null);
const [editingEdge, setEditingEdge] = useState(null);
- const [editingNode, setEditingNode] = useState | null>(null);
+ const [editingNode, setEditingNode] = useState | null>(
+ null,
+ );
const [isEditTableModalOpen, setIsEditTableModalOpen] = useState(false);
const [isAddTableModalOpen, setIsAddTableModalOpen] = useState(false);
const [newTableName, setNewTableName] = useState("");
@@ -313,7 +324,11 @@ export default function App() {
getSnapshot(snapshotId)
.then((s) => {
setSnapshot(s);
- if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") {
+ if (
+ s.status === "succeeded" ||
+ s.status === "failed" ||
+ s.status === "not_found"
+ ) {
clearInterval(timer);
if (selectedProjectId) {
listSnapshots(selectedProjectId)
@@ -367,9 +382,7 @@ export default function App() {
// ⚡ Bolt: Removed nodesById Map creation inside useMemo which iterates over all nodes and allocates memory.
// Using nodes.find() for single lookups is O(N) but avoids Map construction overhead, providing ~10x speedup and reducing GC pressure.
const cardinalityNode = useMemo(() => {
- return (
- nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null
- );
+ return nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null;
}, [cardinalityTableId, nodes]);
const cardinalityColumns = useMemo(() => {
if (!cardinalityNode) return [];
@@ -380,11 +393,7 @@ export default function App() {
cardinalityDistinctCounts[column.column_name] ?? "",
),
}));
- }, [
- cardinalityColumnSelections,
- cardinalityDistinctCounts,
- cardinalityNode,
- ]);
+ }, [cardinalityColumnSelections, cardinalityDistinctCounts, cardinalityNode]);
const cardinalityRecommendations = useMemo(
() =>
buildIndexRecommendations({
@@ -392,7 +401,11 @@ export default function App() {
rowCount: cardinalityRowCountNumber,
columns: cardinalityColumns,
}),
- [cardinalityColumns, cardinalityNode?.data.title, cardinalityRowCountNumber],
+ [
+ cardinalityColumns,
+ cardinalityNode?.data.title,
+ cardinalityRowCountNumber,
+ ],
);
const appliedCardinalityIndexes = useMemo(
() => cardinalityNode?.data.indexes ?? [],
@@ -404,7 +417,8 @@ export default function App() {
const columns = new Set();
for (const index of appliedCardinalityIndexes) {
if (index.index_name) names.add(index.index_name);
- if (index.columns && index.columns.length > 0) columns.add(index.columns.join(","));
+ if (index.columns && index.columns.length > 0)
+ columns.add(index.columns.join(","));
}
return { names, columns };
}, [appliedCardinalityIndexes]);
@@ -503,11 +517,14 @@ export default function App() {
}
}
- const onNodeDoubleClick = useCallback((event: React.MouseEvent, node: Node) => {
- event.preventDefault();
- setEditingNode(node as Node);
- setIsEditTableModalOpen(true);
- }, []);
+ const onNodeDoubleClick = useCallback(
+ (event: React.MouseEvent, node: Node) => {
+ event.preventDefault();
+ setEditingNode(node as Node);
+ setIsEditTableModalOpen(true);
+ },
+ [],
+ );
const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => {
event.preventDefault();
@@ -622,7 +639,11 @@ export default function App() {
}
function onDownloadMermaid() {
- downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain");
+ downloadText(
+ "pg-erd-diagram.mermaid",
+ exportMermaid(nodes, edges),
+ "text/plain",
+ );
}
function onDownloadDbml() {
@@ -630,7 +651,11 @@ export default function App() {
}
function onDownloadPrisma() {
- downloadText("pg-erd-diagram.prisma", exportPrisma(nodes, edges), "text/plain");
+ downloadText(
+ "pg-erd-diagram.prisma",
+ exportPrisma(nodes, edges),
+ "text/plain",
+ );
}
function onExportDictionaryCsv() {
@@ -696,10 +721,7 @@ export default function App() {
}));
}
- function onCardinalityDistinctCountChange(
- columnName: string,
- value: string,
- ) {
+ function onCardinalityDistinctCountChange(columnName: string, value: string) {
if (!/^\d*$/.test(value)) return;
setCardinalityDistinctCounts((prev) => ({
...prev,
@@ -729,7 +751,8 @@ export default function App() {
const recColumns = recommendation.columns?.join(",") ?? "";
if (
- (recommendation.index_name && appliedIndexNames.has(recommendation.index_name)) ||
+ (recommendation.index_name &&
+ appliedIndexNames.has(recommendation.index_name)) ||
(recColumns && appliedColumns.has(recColumns))
) {
return currentNodes;
@@ -806,7 +829,12 @@ export default function App() {
}
function onDeleteBusinessGroup(groupId: string) {
- if (!window.confirm("이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?")) return;
+ if (
+ !window.confirm(
+ "이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?",
+ )
+ )
+ return;
setBusinessGroups((groups) =>
groups.filter((group) => group.id !== groupId),
);
@@ -825,7 +853,6 @@ export default function App() {
);
}
-
function onDeleteTable() {
if (!editingNode) return;
if (!window.confirm("정말로 이 테이블을 삭제하시겠습니까?")) return;
@@ -834,7 +861,11 @@ export default function App() {
setNodes((nds) => nds.filter((n) => n.id !== editingNode.id));
// Remove connected edges
- setEdges((eds) => eds.filter((e) => e.source !== editingNode.id && e.target !== editingNode.id));
+ setEdges((eds) =>
+ eds.filter(
+ (e) => e.source !== editingNode.id && e.target !== editingNode.id,
+ ),
+ );
setIsEditTableModalOpen(false);
setEditingNode(null);
@@ -851,7 +882,9 @@ export default function App() {
if (!title.trim()) return;
// Parse columns from formData
- const updatedColumns: Array["data"]["columns"][number]> = [];
+ const updatedColumns: Array<
+ Node["data"]["columns"][number]
+ > = [];
for (let i = 0; i < editingNode.data.columns.length; i++) {
const colName = formData.get(`col_name_${i}`) as string;
if (colName === null) continue; // Deleted column
@@ -881,13 +914,13 @@ export default function App() {
columns: updatedColumns,
badges: {
...n.data.badges,
- pk: updatedColumns.some(c => c.is_pk)
- }
- }
+ pk: updatedColumns.some((c) => c.is_pk),
+ },
+ },
};
}
return n;
- })
+ }),
);
setIsEditTableModalOpen(false);
@@ -899,7 +932,6 @@ export default function App() {
setEditingNode(null);
}
-
function onAddTableSubmit() {
if (!newTableName.trim()) return;
const newId = `new_table_${Date.now()}`;
@@ -964,7 +996,9 @@ export default function App() {
/* v8 ignore next -- the save control is disabled until both fields are present */
if (!nextConnectionName || !connectionDsn) return;
if (!isSupportedConnectionDsn(connectionDsn)) {
- setError("Connection DSN must use postgresql://, postgres://, or snowflake:// with a host.");
+ setError(
+ "Connection DSN must use postgresql://, postgres://, or snowflake:// with a host.",
+ );
dsnInput.value = "";
setIsDsnPresent(false);
return;
@@ -1006,12 +1040,7 @@ export default function App() {
if (isAuthLoading) {
return (
-
+
pg-erd-cloud
Authenticating…
@@ -1020,7 +1049,8 @@ export default function App() {
if (!me) {
/* v8 ignore next -- the loaded unauthenticated state always records its rejection */
- const authGateMessage = authError ?? "Sign in before managing database metadata.";
+ const authGateMessage =
+ authError ?? "Sign in before managing database metadata.";
return (
Authentication required
@@ -1045,7 +1075,11 @@ export default function App() {
setActiveView(item.id)}
aria-current={activeView === item.id ? "page" : undefined}
>
@@ -1056,158 +1090,175 @@ export default function App() {
{activeView === "editor" ? (
<>
+
+
Project
+
+ setSelectedProjectId(e.target.value || null)}
+ style={{ flex: 1, padding: 8 }}
+ >
+
+ Select…
+
+ {projects.map((p) => (
+
+ {p.project_name}
+
+ ))}
+
+
+
-
-
Project
-
-
setSelectedProjectId(e.target.value || null)}
- style={{ flex: 1, padding: 8 }}
- >
-
- Select…
-
- {projects.map((p) => (
-
- {p.project_name}
+
+ New project
+
+ {createProjectHint ? (
+
+ {createProjectHint}
+
+ ) : null}
+
+
+
+
+
+ Connection
+ setSelectedConnId(e.target.value || null)}
+ style={{ padding: 8 }}
+ >
+
+ Select…
- ))}
-
-
-
+ {connections.map((c) => (
+
+ {c.conn_name}
+
+ ))}
+
+
+
+
+
+
+ Schema filter (optional)
+ setSchemaFilter(e.target.value)}
+ placeholder="public"
+ />
+
-
-
New project
-
- setProjectName(e.target.value)}
- />
- {isCreatingProject ? "Creating…" : "Create"}
+ {isCreatingSnapshot ? "Starting…" : "Reverse engineer → snapshot"}
-
- {createProjectHint ? (
-
- {createProjectHint}
-
- ) : null}
-
-
-
-
-
- Connection
- setSelectedConnId(e.target.value || null)}
- style={{ padding: 8 }}
- >
-
- Select…
-
- {connections.map((c) => (
-
- {c.conn_name}
-
- ))}
-
-
-
-
- New connection (DSN)
- setConnName(e.target.value)}
- placeholder="name"
- />
-
- setIsDsnPresent(Boolean(e.currentTarget.value.trim()))
- }
- placeholder="postgresql://... or snowflake://..."
- aria-label="Connection DSN"
- />
-
- {isCreatingConnection ? "Saving…" : "Save connection"}
-
- {createConnectionHint ? (
-
- {createConnectionHint}
-
- ) : null}
-
-
-
- Schema filter (optional)
- setSchemaFilter(e.target.value)}
- placeholder="public"
- />
-
-
-
- {isCreatingSnapshot ? "Starting…" : "Reverse engineer → snapshot"}
-
- {createSnapshotHint ? (
-
- {createSnapshotHint}
-
- ) : null}
-
-
- Snapshot: {snapshot?.status || "—"}
- {snapshot?.error_message ? (
-
- {String(snapshot.error_message)}
+ {createSnapshotHint ? (
+
+ {createSnapshotHint}
+
+ ) : null}
+
+
+ Snapshot: {snapshot?.status || "—"}
+ {snapshot?.error_message ? (
+
+ {String(snapshot.error_message)}
+
+ ) : null}
- ) : null}
-
- {error ? (
-
- {error}
-
- ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
>
) : (
@@ -1228,7 +1279,10 @@ export default function App() {
{activeView === "dashboard" ? (
-
+
대시보드
@@ -1254,7 +1308,10 @@ export default function App() {
-
+
최근 프로젝트
setActiveView("projects")}>
@@ -1280,11 +1337,16 @@ export default function App() {
))}
) : (
- 아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.
+
+ 아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.
+
)}
-
+
최근 다이어그램
setActiveView("diagrams")}>
@@ -1293,7 +1355,9 @@ export default function App() {
{
setSnapshotId(id);
setSnapshot(null);
@@ -1307,33 +1371,55 @@ export default function App() {
프로젝트
-
프로젝트를 선택하면 해당 다이어그램 목록을 볼 수 있습니다.
+
+ 프로젝트를 선택하면 해당 다이어그램 목록을 볼 수 있습니다.
+
-
+
+
-
+
이름
연결
동작
{projects.map((project) => (
-
-
{sanitizeHtml(project.project_name)}
-
{project.project_space_uuid === selectedProjectId ? connections.length : "선택 후 표시"}
+
+
+ {sanitizeHtml(project.project_name)}
+
+
+ {project.project_space_uuid === selectedProjectId
+ ? connections.length
+ : "선택 후 표시"}
+
))}
{!projects.length ? (
- 프로젝트가 없습니다. 이름을 입력해 새 프로젝트를 만드세요.
+
+ 프로젝트가 없습니다. 이름을 입력해 새 프로젝트를 만드세요.
+
) : null}
@@ -1357,22 +1445,30 @@ export default function App() {
다이어그램
-
{selectedProject ? `${selectedProject.project_name} 프로젝트의 스냅샷` : "프로젝트를 선택하세요."}
+
+ {selectedProject
+ ? `${selectedProject.project_name} 프로젝트의 스냅샷`
+ : "프로젝트를 선택하세요."}
+
setActiveView("editor")}>
편집기 열기
-
- 다이어그램 검색
- setDiagramSearch(event.currentTarget.value)}
- />
-
+
) : (
-
-
- 테이블 또는 컬럼 검색
- setNodeSearch(event.currentTarget.value)}
- />
-
-
- {isLayouting ? "…" : "↔"}
-
-
- ↶
-
-
- 🪄
-
-
- 🗑️
-
-
- +
-
-
- ◇
-
-
- #
-
-
- SQL
-
-
- ↗
-
-
e.preventDefault()}>
+
+ 테이블 또는 컬럼 검색
+
+ setNodeSearch(event.currentTarget.value)
+ }
+ />
+
+
+
+ {isLayouting ? "…" : "↔"}
+
+
+ ↶
+
+
+ 🪄
+
+
+ 🗑️
+
+
+ +
+
+
+ ◇
+
+
+ #
+
+
+ SQL
+
+
+ ↗
+
+
+ IMG
+
+
+ UML
+
+
+ {"{}"}
+
+
+ {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")}
+
+
+
+
{
+ reactFlowRef.current = instance;
+ }}
>
- IMG
-
-
+
+
+
+
+ {nodes.length === 0 && (
+
+ {isSnapshotPending ? (
+ <>
+
+
스냅샷 생성 중...
+
+ 데이터베이스에서 스키마를 가져오고 있습니다. 잠시만
+ 기다려주세요.
+
+ >
+ ) : (
+ <>
+
+
+ ERD 캔버스가 비어 있습니다
+
+
+ 작업 패널에서 스냅샷을 생성하거나 상단의{" "}
+ 테이블 추가 버튼을 눌러 시작하세요.
+
+
+ + 테이블 추가
+
+ >
+ )}
+
+ )}
+
+
0}
+ hasDictionaryExport={nodes.length > 0}
+ hasDiagramExport={nodes.length > 0}
+ shareLinkUrl={shareLinkUrl}
+ isCreatingShareLink={isCreatingShareLink}
+ isShareLinkCopied={isShareLinkCopied}
+ shareLinkError={shareLinkError}
+ canCreateShareLink={Boolean(selectedProjectId)}
+ onCloseExport={onCloseExport}
+ onCopyExportDdl={onCopyExportDdl}
+ onDownloadSvg={onDownloadSvg}
+ onDownloadUml={onDownloadUml}
+ onDownloadMermaid={onDownloadMermaid}
+ onExportDictionaryCsv={onExportDictionaryCsv}
+ onExportDictionaryMarkdown={onExportDictionaryMarkdown}
+ onDownloadDbml={onDownloadDbml}
+ onDownloadPrisma={onDownloadPrisma}
+ onCreateShareLink={onCreateShareLink}
+ onCopyShareLink={onCopyShareLink}
+ />
+
+
+
+
+
+
- UML
-
-
- {"{}"}
-
-
- {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")}
-
-
+ parsePositiveInteger={parsePositiveInteger}
+ calculateCardinalityRatio={calculateCardinalityRatio}
+ formatPercent={formatPercent}
+ strengthLabel={strengthLabel}
+ />
- {
- reactFlowRef.current = instance;
- }}
- >
-
-
-
-
-
- {nodes.length === 0 && (
-
- {isSnapshotPending ? (
- <>
-
-
스냅샷 생성 중...
-
- 데이터베이스에서 스키마를 가져오고 있습니다. 잠시만 기다려주세요.
-
- >
- ) : (
- <>
-
-
ERD 캔버스가 비어 있습니다
-
- 작업 패널에서 스냅샷을 생성하거나 상단의 테이블 추가 버튼을 눌러 시작하세요.
-
-
- + 테이블 추가
-
- >
- )}
-
- )}
-
- 0}
- hasDictionaryExport={nodes.length > 0}
- hasDiagramExport={nodes.length > 0}
- shareLinkUrl={shareLinkUrl}
- isCreatingShareLink={isCreatingShareLink}
- isShareLinkCopied={isShareLinkCopied}
- shareLinkError={shareLinkError}
- canCreateShareLink={Boolean(selectedProjectId)}
- onCloseExport={onCloseExport}
- onCopyExportDdl={onCopyExportDdl}
- onDownloadSvg={onDownloadSvg}
- onDownloadUml={onDownloadUml}
- onDownloadMermaid={onDownloadMermaid}
- onExportDictionaryCsv={onExportDictionaryCsv}
- onExportDictionaryMarkdown={onExportDictionaryMarkdown}
- onDownloadDbml={onDownloadDbml}
- onDownloadPrisma={onDownloadPrisma}
- onCreateShareLink={onCreateShareLink}
- onCopyShareLink={onCopyShareLink}
- />
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
)}
@@ -1734,11 +1850,17 @@ export function DiagramTable({
동작
{rows.map(({ item, name }) => (
-
+
{name}
{selectedProjectName || "현재 프로젝트"}
-
+
{sanitizeHtml(item.status)}