diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cde160eb..92240cd0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1491,8 +1491,8 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "data-studio-agent" -version = "0.1.3" -source = "git+https://github.com/geek-fun/data-studio-agent.git?tag=v0.1.3#596b43292a4768f987f1c44628c006a7a71145a9" +version = "0.1.4" +source = "git+https://github.com/geek-fun/data-studio-agent.git?tag=v0.1.4#a126632c55362ca99d2bbd44d0ff88524e0a5856" dependencies = [ "async-openai", "async-trait", @@ -1718,7 +1718,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2010,7 +2010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2978,7 +2978,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -2998,7 +2998,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -5018,7 +5018,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.40", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5055,9 +5055,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5679,7 +5679,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5780,7 +5780,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7183,7 +7183,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8272,7 +8272,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3494b4a9..e9566c51 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -88,7 +88,7 @@ http = "1" log = "0.4" futures = "0.3" rand = "0.8" -data-studio-agent = { git = "https://github.com/geek-fun/data-studio-agent.git", tag = "v0.1.3" } +data-studio-agent = { git = "https://github.com/geek-fun/data-studio-agent.git", tag = "v0.1.4" } # Archive extraction (JRE downloads) flate2 = "1.0" diff --git a/src/__tests__/graph-layout.test.ts b/src/__tests__/graph-layout.test.ts new file mode 100644 index 00000000..4bdb10e4 --- /dev/null +++ b/src/__tests__/graph-layout.test.ts @@ -0,0 +1,99 @@ +import type { DagreRel, DagreTable } from '@/components/er-diagram/graph-layout' +/** + * @jest-environment node + */ +import { + computeCanvasSize, + computeDagreLayout, + fitToScreen, +} from '@/components/er-diagram/graph-layout' + +describe('computeDagreLayout', () => { + it('returns positions for A→B with finite coordinates, B below A', () => { + const tables: DagreTable[] = [ + { name: 'A', width: 220, height: 100 }, + { name: 'B', width: 220, height: 100 }, + ] + const rels: DagreRel[] = [ + { sourceTable: 'A', targetTable: 'B' }, + ] + + const positions = computeDagreLayout(tables, rels) + const keys = Object.keys(positions) + + expect(keys).toContain('A') + expect(keys).toContain('B') + expect(Number.isFinite(positions.A.x)).toBe(true) + expect(Number.isFinite(positions.A.y)).toBe(true) + expect(Number.isFinite(positions.B.x)).toBe(true) + expect(Number.isFinite(positions.B.y)).toBe(true) + expect(positions.B.y).toBeGreaterThan(positions.A.y) + }) +}) + +describe('computeCanvasSize', () => { + it('computes size from table positions with padding', () => { + const positions: Record = { + A: { x: 110, y: 80 }, + B: { x: 410, y: 160 }, + } + const getHeight = (name: string) => name === 'A' ? 100 : 100 + const result = computeCanvasSize(positions, 220, getHeight, 80) + + // A right edge = 110 + 110 + 80 = 300 + // B right edge = 410 + 110 + 80 = 600 + // B bottom edge = 160 + 50 + 80 = 290 + // min 960 x 540 + expect(result.width).toBe(960) + expect(result.height).toBe(540) + }) + + it('expands beyond minimum when tables are large', () => { + const positions: Record = { + Big: { x: 1000, y: 800 }, + } + const getHeight = () => 200 + const result = computeCanvasSize(positions, 220, getHeight, 50) + + // right edge = 1000 + 110 + 50 = 1160 + // bottom edge = 800 + 100 + 50 = 950 + expect(result.width).toBe(1160) + expect(result.height).toBe(950) + }) + + it('defaults to 960x540 for empty positions', () => { + const result = computeCanvasSize({}, 220, () => 0, 80) + expect(result.width).toBe(960) + expect(result.height).toBe(540) + }) +}) + +describe('fitToScreen', () => { + it('zooms in (zoom >= 1) when content is smaller than viewport', () => { + const result = fitToScreen({ width: 100, height: 100 }, 800, 600) + expect(result.zoom).toBe(3) + expect(Number.isFinite(result.scrollX)).toBe(true) + expect(Number.isFinite(result.scrollY)).toBe(true) + }) + + it('zooms out (zoom < 1) when content is larger than viewport', () => { + const result = fitToScreen({ width: 2000, height: 1500 }, 800, 600) + expect(result.zoom).toBeCloseTo(0.4, 5) + expect(Number.isFinite(result.scrollX)).toBe(true) + expect(Number.isFinite(result.scrollY)).toBe(true) + }) + + it('centers content in viewport', () => { + const result = fitToScreen({ width: 400, height: 300 }, 1000, 800) + expect(result.zoom).toBe(2.5) + expect(result.scrollX).toBe(0) + expect(result.scrollY).toBe(25) + }) + + it('caps zoom between 0.1 and 3', () => { + const r1 = fitToScreen({ width: 10, height: 10 }, 10000, 10000) + expect(r1.zoom).toBe(3) + const r2 = fitToScreen({ width: 100000, height: 100000 }, 100, 100) + expect(r2.zoom).toBe(0.1) + }) +}) diff --git a/src/__tests__/graph-routing.test.ts b/src/__tests__/graph-routing.test.ts new file mode 100644 index 00000000..3cdc8a57 --- /dev/null +++ b/src/__tests__/graph-routing.test.ts @@ -0,0 +1,141 @@ +import type { TableRect } from '@/components/er-diagram/types' +/** + * @jest-environment node + */ +import { + buildTableRectMap, + candidateRouteXs, + computeRelationshipPath, + rangesOverlap, + routeSideX, +} from '@/components/er-diagram/graph-routing' + +const rectA: TableRect = { name: 'A', x: 0, y: 0, width: 220, height: 100 } +const rectB: TableRect = { name: 'B', x: 300, y: 0, width: 220, height: 100 } +const rectBlocker: TableRect = { name: 'Blocker', x: 150, y: 50, width: 220, height: 100 } + +describe('rangesOverlap', () => { + it('returns true when ranges overlap', () => { + expect(rangesOverlap(0, 10, 5, 15)).toBe(true) + }) + + it('returns true when one range is inside another', () => { + expect(rangesOverlap(0, 20, 5, 10)).toBe(true) + }) + + it('returns true when ranges touch at a point', () => { + expect(rangesOverlap(0, 10, 10, 20)).toBe(true) + }) + + it('returns false when ranges do not overlap', () => { + expect(rangesOverlap(0, 10, 20, 30)).toBe(false) + }) + + it('returns false when ranges are adjacent but not touching', () => { + expect(rangesOverlap(0, 10, 11, 20)).toBe(false) + }) +}) + +describe('routeSideX', () => { + it('returns rect.x - offset when routeX is left of rect', () => { + expect(routeSideX(rectA, -50)).toBe(0) + }) + + it('returns rect.x when routeX is left of rect with default offset 0', () => { + expect(routeSideX(rectA, -50)).toBe(0) + }) + + it('returns rect.x + width + offset when routeX is right of rect', () => { + expect(routeSideX(rectA, 300)).toBe(220) + }) + + it('uses provided offset when routeX is left of rect', () => { + expect(routeSideX(rectA, -50, 5)).toBe(-5) + }) + + it('uses provided offset when routeX is right of rect', () => { + expect(routeSideX(rectA, 300, 10)).toBe(230) + }) +}) + +describe('candidateRouteXs', () => { + it('returns sorted candidates by proximity to source and target', () => { + const candidates = candidateRouteXs(rectA, rectB, [rectA, rectB]) + expect(candidates.length).toBeGreaterThan(0) + // First candidate should be the best (closest to both rects) + expect(candidates[0]).toBeGreaterThan(rectA.x) + expect(candidates[0]).toBeLessThan(rectB.x + rectB.width) + }) + + it('includes the midpoint gap when rects are side by side', () => { + const candidates = candidateRouteXs(rectA, rectB, [rectA, rectB]) + // rectA ends at 220, rectB starts at 300, gap is 80 >= 56 (ROUTE_PADDING) + // So midpoint should be (220 + 300) / 2 = 260 + expect(candidates).toContain(260) + }) + + it('includes the right edge padding candidate', () => { + const candidates = candidateRouteXs(rectA, rectB, [rectA, rectB]) + // maxRight is 300 + 220 = 520, so 520 + 56 = 576 + expect(candidates).toContain(576) + }) +}) + +describe('computeRelationshipPath', () => { + it('returns empty string when source table is not in rectMap', () => { + const rectMap: Record = { B: rectB } + expect(computeRelationshipPath('A', 'B', rectMap)).toBe('') + }) + + it('returns empty string when target table is not in rectMap', () => { + const rectMap: Record = { A: rectA } + expect(computeRelationshipPath('A', 'B', rectMap)).toBe('') + }) + + it('returns an orthogonal SVG path for two side-by-side rects', () => { + const rectMap: Record = { A: rectA, B: rectB } + const path = computeRelationshipPath('A', 'B', rectMap) + expect(path).toMatch(/^M /) + expect(path).toContain(' L ') + }) + + it('returns an orthogonal path even with a blocker between source and target', () => { + const rectMap: Record = { + A: rectA, + B: rectB, + Blocker: rectBlocker, + } + const path = computeRelationshipPath('A', 'B', rectMap) + expect(path).toMatch(/^M /) + expect(path).toContain(' L ') + }) +}) + +describe('buildTableRectMap', () => { + it('builds a rect map from table names and positions', () => { + const nodePositions: Record = { + A: { x: 110, y: 50 }, + B: { x: 410, y: 50 }, + } + const getHeight = (_name: string) => 100 + const rectMap = buildTableRectMap(['A', 'B'], nodePositions, getHeight) + const keys = Object.keys(rectMap) + expect(keys).toHaveLength(2) + expect(keys).toContain('A') + expect(keys).toContain('B') + const rectA = rectMap.A + expect(rectA.x).toBe(0) // 110 - 220/2 + expect(rectA.y).toBe(0) // 50 - 100/2 + expect(rectA.width).toBe(220) + expect(rectA.height).toBe(100) + }) + + it('skips tables not found in nodePositions', () => { + const nodePositions: Record = { + A: { x: 110, y: 50 }, + } + const getHeight = (_name: string) => 100 + const rectMap = buildTableRectMap(['A', 'B'], nodePositions, getHeight) + expect(Object.keys(rectMap)).toHaveLength(1) + }) +}) diff --git a/src/components/database-browser/QueryTabs.vue b/src/components/database-browser/QueryTabs.vue index 7cd85d94..54df92b8 100644 --- a/src/components/database-browser/QueryTabs.vue +++ b/src/components/database-browser/QueryTabs.vue @@ -105,13 +105,13 @@ const isActiveTab = (tabId: string) => props.activeTabId === tabId