diff --git a/frontend/.gitignore b/frontend/.gitignore index dacf346f2..7200bfbb4 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -27,3 +27,6 @@ coverage test-results/ playwright-report/ + +# DuckDB WASM files (auto-generated during build) +public/duckdb/ diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 5b28e2ebe..4ec5a53c6 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index c3e5594fe..5eb808ce1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@duckdb/duckdb-wasm": "^1.28.0", "@floating-ui/dom": "^1.6.3", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", @@ -23,6 +24,7 @@ "@vueuse/core": "^10.9.0", "apexcharts": "^3.46.0", "body-scroll-lock": "4.0.0-beta.0", + "echarts": "^6.0.0", "lodash": "^4.17.21", "micromark": "^4.0.0", "normalize.css": "^8.0.1", diff --git a/frontend/src/api/kg-version.ts b/frontend/src/api/kg-version.ts index 0faae552b..4ab813bec 100644 --- a/frontend/src/api/kg-version.ts +++ b/frontend/src/api/kg-version.ts @@ -16,3 +16,13 @@ export const getLatestKGReleaseDate = async (): Promise => { } return versionInfo.monarch_kg_version; }; + +// Fetches the Knowledge Graph source URL +export const getKGSourceUrl = async (): Promise => { + const url = `${apiUrl}/version`; + const versionInfo = await request(url); + if (!versionInfo || !versionInfo.monarch_kg_source) { + throw new Error("No KG source URL found"); + } + return versionInfo.monarch_kg_source; +}; diff --git a/frontend/src/components/dashboard/BarChart.vue b/frontend/src/components/dashboard/BarChart.vue new file mode 100644 index 000000000..b18631758 --- /dev/null +++ b/frontend/src/components/dashboard/BarChart.vue @@ -0,0 +1,609 @@ + + + + + diff --git a/frontend/src/components/dashboard/BaseChart.vue b/frontend/src/components/dashboard/BaseChart.vue new file mode 100644 index 000000000..2406732de --- /dev/null +++ b/frontend/src/components/dashboard/BaseChart.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/frontend/src/components/dashboard/ChordChart.vue b/frontend/src/components/dashboard/ChordChart.vue new file mode 100644 index 000000000..df8362851 --- /dev/null +++ b/frontend/src/components/dashboard/ChordChart.vue @@ -0,0 +1,784 @@ + + + + + diff --git a/frontend/src/components/dashboard/DataSource.vue b/frontend/src/components/dashboard/DataSource.vue new file mode 100644 index 000000000..ae3a52123 --- /dev/null +++ b/frontend/src/components/dashboard/DataSource.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/components/dashboard/KGDashboard.vue b/frontend/src/components/dashboard/KGDashboard.vue new file mode 100644 index 000000000..99fa9e845 --- /dev/null +++ b/frontend/src/components/dashboard/KGDashboard.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/frontend/src/components/dashboard/KGMetricCard.vue b/frontend/src/components/dashboard/KGMetricCard.vue new file mode 100644 index 000000000..78da0c97c --- /dev/null +++ b/frontend/src/components/dashboard/KGMetricCard.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/frontend/src/components/dashboard/SankeyChart.vue b/frontend/src/components/dashboard/SankeyChart.vue new file mode 100644 index 000000000..9d38405ba --- /dev/null +++ b/frontend/src/components/dashboard/SankeyChart.vue @@ -0,0 +1,587 @@ + + + + + diff --git a/frontend/src/composables/use-duckdb.ts b/frontend/src/composables/use-duckdb.ts new file mode 100644 index 000000000..71b282585 --- /dev/null +++ b/frontend/src/composables/use-duckdb.ts @@ -0,0 +1,235 @@ +import { ref, type Ref } from "vue"; +import * as duckdb from "@duckdb/duckdb-wasm"; + +export interface DuckDBConfig { + wasmUrl?: string; + workerUrl?: string; +} + +export interface QueryResult { + data: Record[]; + columns: string[]; + rowCount: number; +} + +/** + * Composable for DuckDB WASM integration Provides basic database initialization + * and parquet file loading + */ +export function useDuckDB(config: DuckDBConfig = {}) { + const db: Ref = ref(null); + const connection: Ref = ref(null); + const isInitialized = ref(false); + const isLoading = ref(false); + const error: Ref = ref(null); + + /** Process DuckDB values to convert to proper JavaScript types */ + const processValue = (value: any): any => { + // Handle DuckDB-specific data types first + if (value instanceof Uint32Array) { + // For Uint32Array, take the first element which contains the actual value + return value[0]; + } + + if (typeof value === "bigint") { + // Convert BigInt to regular number (may lose precision for very large numbers) + return Number(value); + } + + if ( + value instanceof Int32Array || + value instanceof Float64Array || + value instanceof Float32Array + ) { + // Handle other typed arrays + return value[0]; + } + + // Handle JSON-serialized strings (original logic) + if (typeof value !== "string") { + return value; + } + + // Handle empty string case - don't convert to number + if (value === "") { + return value; + } + + let processedValue = value; + + // Try multiple rounds of JSON parsing for deeply nested quotes + try { + // Keep parsing until we can't parse anymore or get a non-string + while ( + typeof processedValue === "string" && + processedValue.startsWith('"') && + processedValue.endsWith('"') + ) { + processedValue = JSON.parse(processedValue); + } + } catch { + // If JSON parsing fails at any point, continue with what we have + } + + // Now try to convert to number if it looks numeric + if (typeof processedValue === "string" && processedValue.trim() !== "") { + const numValue = Number(processedValue); + if (!isNaN(numValue)) { + return numValue; + } + } + + return processedValue; + }; + + /** Initialize DuckDB instance */ + const initDB = async (): Promise => { + if (isInitialized.value) return; + + try { + isLoading.value = true; + error.value = null; + + // Use local WASM files served by Vite instead of CDN to avoid CORS issues + const baseUrl = import.meta.env.BASE_URL || "/"; + const wasmUrl = config.wasmUrl || `${baseUrl}duckdb/duckdb-mvp.wasm`; + const workerUrl = + config.workerUrl || `${baseUrl}duckdb/duckdb-browser-mvp.worker.js`; + + const bundle = await duckdb.selectBundle({ + mvp: { + mainModule: wasmUrl, + mainWorker: workerUrl, + }, + eh: { + mainModule: wasmUrl, + mainWorker: workerUrl, + }, + }); + + const worker = new Worker(bundle.mainWorker!); + const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING); + db.value = new duckdb.AsyncDuckDB(logger, worker); + await db.value.instantiate(bundle.mainModule); + + connection.value = await db.value.connect(); + isInitialized.value = true; + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to initialize DuckDB"; + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Load a parquet file from URL */ + const loadParquet = async (url: string, tableName: string): Promise => { + if (!connection.value || !isInitialized.value) { + throw new Error("DuckDB not initialized"); + } + + try { + isLoading.value = true; + error.value = null; + + // Create table from parquet file + const sql = `CREATE OR REPLACE TABLE ${tableName} AS SELECT * FROM read_parquet('${url}')`; + await connection.value.query(sql); + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to load parquet file"; + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Load a CSV file from URL */ + const loadCSV = async (url: string, tableName: string): Promise => { + if (!connection.value || !isInitialized.value) { + throw new Error("DuckDB not initialized"); + } + + try { + isLoading.value = true; + error.value = null; + + // Create table from CSV file with header detection + const sql = `CREATE OR REPLACE TABLE ${tableName} AS SELECT * FROM read_csv_auto('${url}')`; + await connection.value.query(sql); + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to load CSV file"; + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Execute a SQL query */ + const query = async (sql: string): Promise => { + if (!connection.value || !isInitialized.value) { + throw new Error("DuckDB not initialized"); + } + + try { + isLoading.value = true; + error.value = null; + + const result = await connection.value.query(sql); + const columns = result.schema.fields.map((field: any) => field.name); + + // Get raw data and process to ensure proper data types + const rawData = result.toArray().map((row: any) => row.toJSON()); + + // Post-process to convert JSON-serialized values to proper types + const data = rawData.map((row: any) => { + const processedRow: Record = {}; + Object.entries(row).forEach(([key, value]) => { + processedRow[key] = processValue(value); + }); + return processedRow; + }); + + return { + data, + columns, + rowCount: data.length, + }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Query execution failed"; + error.value = errorMessage; + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Clean up resources */ + const cleanup = async (): Promise => { + if (connection.value) { + await connection.value.close(); + connection.value = null; + } + if (db.value) { + await db.value.terminate(); + db.value = null; + } + isInitialized.value = false; + }; + + return { + db, + connection, + isInitialized, + isLoading, + error, + initDB, + loadParquet, + loadCSV, + query, + cleanup, + }; +} diff --git a/frontend/src/composables/use-kg-data.ts b/frontend/src/composables/use-kg-data.ts new file mode 100644 index 000000000..33652ebbe --- /dev/null +++ b/frontend/src/composables/use-kg-data.ts @@ -0,0 +1,222 @@ +import { computed, ref, type Ref } from "vue"; +import { getKGSourceUrl, getLatestKGReleaseDate } from "@/api/kg-version"; +import { useDuckDB } from "./use-duckdb"; + +export interface DataSourceConfig { + name: string; + url: string; + description?: string; + baseUrl?: string; + format?: "parquet" | "csv"; +} + +export interface DataSource extends DataSourceConfig { + isLoaded: boolean; + loadError?: string; + lastLoaded?: Date; +} + +/** + * Composable for managing KG data sources and parquet files Provides + * registration, loading, and querying of data sources + */ +export function useKGData() { + const dataSources: Ref> = ref(new Map()); + const isLoading = ref(false); + const error: Ref = ref(null); + const kgVersion = ref(""); + const kgSourceUrl = ref(""); + + // Initialize DuckDB instance + const duckDB = useDuckDB(); + + /** Initialize the KG data system */ + const init = async (): Promise => { + try { + isLoading.value = true; + error.value = null; + + // Initialize DuckDB + await duckDB.initDB(); + + // Get current KG version and source URL + await fetchKGVersion(); + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to initialize KG data"; + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Fetch current KG version and source URL */ + const fetchKGVersion = async (): Promise => { + try { + // Get KG version and source URL from API + const version = await getLatestKGReleaseDate(); + const sourceUrl = await getKGSourceUrl(); + + // Check if API returned "unknown" or invalid data + if ( + version === "unknown" || + sourceUrl === "unknown" || + !version || + !sourceUrl + ) { + throw new Error("API returned unknown or invalid data"); + } + + kgVersion.value = version; + // Use the source URL directly from the API (includes full path) + kgSourceUrl.value = sourceUrl.endsWith("/") + ? sourceUrl.slice(0, -1) + : sourceUrl; + } catch (err) { + // Fallback to latest dev version if API fails or returns "unknown" + kgVersion.value = "latest"; + kgSourceUrl.value = + "https://data.monarchinitiative.org/monarch-kg-dev/latest"; + } + }; + + /** Register a data source */ + const registerDataSource = (config: DataSourceConfig): void => { + const source: DataSource = { + ...config, + baseUrl: config.baseUrl || kgSourceUrl.value, + isLoaded: false, + }; + + dataSources.value.set(config.name, source); + }; + + /** Get a registered data source */ + const getDataSource = (name: string): DataSource | undefined => { + return dataSources.value.get(name); + }; + + /** Load a data source (parquet file) into DuckDB */ + const loadDataSource = async (name: string): Promise => { + const source = dataSources.value.get(name); + if (!source) { + throw new Error(`Data source '${name}' not found`); + } + + if (source.isLoaded) { + return; // Already loaded + } + + try { + isLoading.value = true; + source.loadError = undefined; + + // Construct full URL + const fullUrl = source.baseUrl + ? `${source.baseUrl}/${source.url}` + : source.url; + + // Load file into DuckDB based on format + const format = source.format || "parquet"; // Default to parquet + if (format === "csv") { + await duckDB.loadCSV(fullUrl, name); + } else { + await duckDB.loadParquet(fullUrl, name); + } + + // Update source status + source.isLoaded = true; + source.lastLoaded = new Date(); + dataSources.value.set(name, source); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to load data source"; + source.loadError = errorMessage; + dataSources.value.set(name, source); + + // Don't throw DuckDB initialization errors - let them be handled silently + if (errorMessage.includes("DuckDB not initialized")) { + return; + } + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Execute SQL query on loaded data sources */ + const executeQuery = async ( + sql: string, + requiredSources: string[] = [], + ): Promise => { + try { + isLoading.value = true; + error.value = null; + + // Ensure required data sources are loaded + for (const sourceName of requiredSources) { + await loadDataSource(sourceName); + } + + // Execute query + const result = await duckDB.query(sql); + return result.data; + } catch (err) { + error.value = + err instanceof Error ? err.message : "Query execution failed"; + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Get all registered data sources */ + const getAllDataSources = computed(() => { + return Array.from(dataSources.value.values()); + }); + + /** Get loaded data sources */ + const getLoadedDataSources = computed(() => { + return getAllDataSources.value.filter((source) => source.isLoaded); + }); + + /** Check if a data source is loaded */ + const isDataSourceLoaded = (name: string): boolean => { + const source = dataSources.value.get(name); + return source?.isLoaded ?? false; + }; + + /** Clear all data sources */ + const clearDataSources = (): void => { + dataSources.value.clear(); + }; + + /** Cleanup resources */ + const cleanup = async (): Promise => { + clearDataSources(); + await duckDB.cleanup(); + }; + + return { + // State + dataSources, + isLoading, + error, + kgVersion, + kgSourceUrl, + + // Actions + init, + registerDataSource, + getDataSource, + loadDataSource, + executeQuery, + clearDataSources, + cleanup, + + // Computed + getAllDataSources, + getLoadedDataSources, + isDataSourceLoaded, + }; +} diff --git a/frontend/src/composables/use-sql-query.ts b/frontend/src/composables/use-sql-query.ts new file mode 100644 index 000000000..1a14d9c95 --- /dev/null +++ b/frontend/src/composables/use-sql-query.ts @@ -0,0 +1,245 @@ +import { computed, inject, ref, type Ref } from "vue"; +import { useKGData } from "./use-kg-data"; + +export interface SqlQueryConfig { + sql: string; + dataSources: string[]; + autoExecute?: boolean; + pollInterval?: number; +} + +export interface SqlQueryResult { + data: Record[]; + columns: string[]; + rowCount: number; + executionTime: number; + isFromCache: boolean; +} + +/** + * Composable for executing SQL queries against KG data sources Provides + * caching, validation, and reactive query execution + */ +export function useSqlQuery(config: SqlQueryConfig) { + // Try to inject KG data from dashboard context first, fallback to creating new instance + const injectedKgData = inject | null>( + "kg-data", + null, + ); + const kgData = injectedKgData || useKGData(); + + const isLoading = ref(false); + const error: Ref = ref(null); + const result: Ref = ref(null); + const lastExecuted = ref(null); + + // Simple cache for query results + const queryCache = ref(new Map()); + + const { sql, dataSources, autoExecute = false, pollInterval } = config; + + /** Generate cache key for query */ + const getCacheKey = (query: string, sources: string[]): string => { + return `${query}|${sources.sort().join(",")}`; + }; + + /** Validate SQL query for basic security */ + const validateSQL = ( + query: string, + ): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + const upperQuery = query.toUpperCase().trim(); + + // Check for dangerous operations + const dangerousKeywords = [ + "DROP", + "DELETE", + "INSERT", + "UPDATE", + "ALTER", + "CREATE", + "TRUNCATE", + ]; + for (const keyword of dangerousKeywords) { + if (upperQuery.includes(keyword)) { + errors.push(`Query contains potentially dangerous keyword: ${keyword}`); + } + } + + // Check for basic SQL structure + if (!upperQuery.startsWith("SELECT") && !upperQuery.startsWith("WITH")) { + errors.push("Query must start with SELECT or WITH"); + } + + // Check for minimum length + if (query.trim().length < 10) { + errors.push("Query appears to be too short"); + } + + return { + isValid: errors.length === 0, + errors, + }; + }; + + /** Execute the SQL query */ + const executeQuery = async ( + overrideSQL?: string, + overrideSources?: string[], + ): Promise => { + const queryToExecute = overrideSQL || sql; + const sourcesToUse = overrideSources || dataSources; + + try { + isLoading.value = true; + error.value = null; + + // Validate query + const validation = validateSQL(queryToExecute); + if (!validation.isValid) { + throw new Error( + `SQL validation failed: ${validation.errors.join(", ")}`, + ); + } + + // Check cache first + const cacheKey = getCacheKey(queryToExecute, sourcesToUse); + const cached = queryCache.value.get(cacheKey); + if (cached) { + result.value = { ...cached, isFromCache: true }; + return result.value; + } + + // Execute query + const startTime = performance.now(); + const data = await kgData.executeQuery(queryToExecute, sourcesToUse); + const endTime = performance.now(); + + // Process results + const columns = data.length > 0 ? Object.keys(data[0]) : []; + const queryResult: SqlQueryResult = { + data, + columns, + rowCount: data.length, + executionTime: endTime - startTime, + isFromCache: false, + }; + + // Cache result + queryCache.value.set(cacheKey, queryResult); + + result.value = queryResult; + lastExecuted.value = new Date(); + + return queryResult; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Query execution failed"; + error.value = errorMessage; + throw err; + } finally { + isLoading.value = false; + } + }; + + /** Clear query cache */ + const clearCache = (): void => { + queryCache.value.clear(); + }; + + /** Get a single value from query result (useful for metrics) */ + const getSingleValue = computed(() => { + if (!result.value || result.value.data.length === 0) return null; + + const firstRow = result.value.data[0]; + const firstColumn = result.value.columns[0]; + const rawValue = firstRow[firstColumn]; + + // Process the value to handle DuckDB data types + const processValue = (value: any): any => { + if (value instanceof Uint32Array) return value[0]; + if (typeof value === "bigint") return Number(value); + if ( + value instanceof Int32Array || + value instanceof Float64Array || + value instanceof Float32Array + ) + return value[0]; + return value; + }; + + return processValue(rawValue); + }); + + /** Check if query should be automatically executed */ + const shouldAutoExecute = computed(() => { + return autoExecute && !isLoading.value && !result.value; + }); + + /** Retry the last query */ + const retry = async (): Promise => { + if (result.value) { + // Clear the cached result and re-execute + const cacheKey = getCacheKey(sql, dataSources); + queryCache.value.delete(cacheKey); + } + await executeQuery(); + }; + + // Auto-execute if configured + if (shouldAutoExecute.value) { + executeQuery().catch((err) => { + // Suppress DuckDB initialization errors during startup + if (err.message?.includes("DuckDB not initialized")) { + // Silently handle initialization errors - they'll resolve once DuckDB is ready + return; + } + console.error("Auto-execute failed:", err); + }); + } + + // Set up polling if configured + let pollTimer: number | undefined; + if (pollInterval && pollInterval > 0) { + pollTimer = window.setInterval(() => { + if (!isLoading.value) { + // Clear cache and re-execute + const cacheKey = getCacheKey(sql, dataSources); + queryCache.value.delete(cacheKey); + executeQuery().catch((err) => { + // Suppress DuckDB initialization errors during polling + if (err.message?.includes("DuckDB not initialized")) { + return; + } + console.error("Polling execution failed:", err); + }); + } + }, pollInterval); + } + + // Cleanup polling on unmount + const cleanup = (): void => { + if (pollTimer) { + clearInterval(pollTimer); + } + }; + + return { + // State + isLoading, + error, + result, + lastExecuted, + + // Computed + getSingleValue, + shouldAutoExecute, + + // Actions + executeQuery, + retry, + clearCache, + cleanup, + validateSQL, + }; +} diff --git a/frontend/src/pages/knowledgeGraph/PageKGDashboard.vue b/frontend/src/pages/knowledgeGraph/PageKGDashboard.vue new file mode 100644 index 000000000..767faa987 --- /dev/null +++ b/frontend/src/pages/knowledgeGraph/PageKGDashboard.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index cb5522db2..01016f4f4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -164,6 +164,12 @@ export const routes: RouteRecordRaw[] = [ component: asyncRoute("knowledgeGraph/PageStatus"), meta: { breadcrumb: "Status & QC" }, }, + { + path: "/kg/dashboard", + name: "KG Dashboard", + component: asyncRoute("knowledgeGraph/PageKGDashboard"), + meta: { breadcrumb: "KG Dashboard" }, + }, { path: "/results", name: "Search Results", diff --git a/frontend/src/util/dom.ts b/frontend/src/util/dom.ts index 271ea007c..0397dfb35 100644 --- a/frontend/src/util/dom.ts +++ b/frontend/src/util/dom.ts @@ -50,8 +50,30 @@ export const screenToSvgCoords = (svg: SVGSVGElement, x: number, y: number) => { return point; }; -const canvas = document.createElement("canvas"); -const ctx = canvas?.getContext("2d"); +let canvas: HTMLCanvasElement | null = null; +let ctx: CanvasRenderingContext2D | null = null; + +// Skip canvas creation in test environment +if ( + typeof document !== "undefined" && + typeof process !== "undefined" && + process.env.NODE_ENV !== "test" +) { + try { + canvas = document.createElement("canvas"); + ctx = canvas.getContext("2d"); + } catch (error) { + ctx = null; + } +} else if (typeof document !== "undefined" && typeof process === "undefined") { + // Browser environment + try { + canvas = document.createElement("canvas"); + ctx = canvas.getContext("2d"); + } catch (error) { + ctx = null; + } +} /** calculate dimensions of given font */ export const getTextSize = (text: string, font: string) => { diff --git a/frontend/unit/BaseChart.test.ts b/frontend/unit/BaseChart.test.ts new file mode 100644 index 000000000..3ee4e19ca --- /dev/null +++ b/frontend/unit/BaseChart.test.ts @@ -0,0 +1,416 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mount, type VueWrapper } from "@vue/test-utils"; +import BaseChart from "@/components/dashboard/BaseChart.vue"; + +// Mock ECharts +const mockChart = { + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), +}; + +vi.mock("echarts", () => ({ + init: vi.fn(() => mockChart), +})); + +// Mock components +vi.mock("@/components/AppButton.vue", () => ({ + default: { + name: "AppButton", + template: "", + props: ["text", "design", "color"], + emits: ["click"], + }, +})); + +vi.mock("@/components/AppIcon.vue", () => ({ + default: { + name: "AppIcon", + template: "{{ name }}", + props: ["name"], + }, +})); + +describe("BaseChart", () => { + let wrapper: VueWrapper; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock window resize + global.addEventListener = vi.fn(); + global.removeEventListener = vi.fn(); + }); + + afterEach(() => { + if (wrapper && wrapper.vm) { + wrapper.unmount(); + } + wrapper = null as any; + vi.clearAllMocks(); + }); + + describe("rendering", () => { + it("should render without title", () => { + wrapper = mount(BaseChart); + + expect(wrapper.find(".chart-title").exists()).toBe(false); + expect(wrapper.find(".chart-content").exists()).toBe(true); + }); + + it("should render with title", () => { + wrapper = mount(BaseChart, { + props: { + title: "Test Chart", + }, + }); + + expect(wrapper.find(".chart-title").exists()).toBe(true); + expect(wrapper.find(".chart-title").text()).toBe("Test Chart"); + }); + + it("should render with custom height", () => { + wrapper = mount(BaseChart, { + props: { + height: "500px", + }, + }); + + const chartContent = wrapper.find(".chart-content"); + expect(chartContent.attributes("style")).toContain("height: 500px"); + }); + + it("should show controls when enabled", () => { + wrapper = mount(BaseChart, { + props: { + title: "Test Chart", + sql: "SELECT * FROM test", + showControls: true, + }, + }); + + expect(wrapper.find(".chart-controls").exists()).toBe(true); + expect(wrapper.findAll("button")).toHaveLength(2); // SQL and Export buttons + }); + + it("should hide controls when disabled", () => { + wrapper = mount(BaseChart, { + props: { + title: "Test Chart", + sql: "SELECT * FROM test", + showControls: false, + }, + }); + + expect(wrapper.find(".chart-controls").exists()).toBe(false); + }); + }); + + describe("loading state", () => { + it("should show loading state", () => { + wrapper = mount(BaseChart, { + props: { + isLoading: true, + loadingText: "Loading data...", + }, + }); + + expect(wrapper.find(".chart-loading").exists()).toBe(true); + expect(wrapper.find(".chart-loading").text()).toContain( + "Loading data...", + ); + expect(wrapper.classes()).toContain("loading"); + }); + + it("should hide loading state when not loading", () => { + wrapper = mount(BaseChart, { + props: { + isLoading: false, + }, + }); + + expect(wrapper.find(".chart-loading").exists()).toBe(false); + expect(wrapper.classes()).not.toContain("loading"); + }); + + it("should use default loading text", () => { + wrapper = mount(BaseChart, { + props: { + isLoading: true, + }, + }); + + expect(wrapper.find(".chart-loading").text()).toContain( + "Loading chart data...", + ); + }); + }); + + describe("error state", () => { + it("should show error state", () => { + wrapper = mount(BaseChart, { + props: { + error: "Failed to load data", + }, + }); + + expect(wrapper.find(".chart-error").exists()).toBe(true); + expect(wrapper.find(".chart-error").text()).toContain( + "Failed to load data", + ); + expect(wrapper.classes()).toContain("error"); + }); + + it("should hide error state when no error", () => { + wrapper = mount(BaseChart, { + props: { + error: null, + }, + }); + + expect(wrapper.find(".chart-error").exists()).toBe(false); + expect(wrapper.classes()).not.toContain("error"); + }); + + it("should show retry button when allowed", () => { + wrapper = mount(BaseChart, { + props: { + error: "Failed to load data", + allowRetry: true, + }, + }); + + const retryButton = wrapper.find(".chart-error button"); + expect(retryButton.exists()).toBe(true); + expect(retryButton.text()).toBe("Retry"); + }); + + it("should emit retry event when retry button is clicked", async () => { + wrapper = mount(BaseChart, { + props: { + error: "Failed to load data", + allowRetry: true, + }, + }); + + const retryButton = wrapper.find(".chart-error button"); + await retryButton.trigger("click"); + + expect(wrapper.emitted("retry")).toBeTruthy(); + }); + }); + + describe("SQL display", () => { + it("should show SQL button when SQL is provided", () => { + wrapper = mount(BaseChart, { + props: { + title: "Test Chart", + sql: "SELECT * FROM test", + }, + }); + + const buttons = wrapper.findAll("button"); + const sqlButton = buttons.find((button) => + button.text().includes("Show SQL"), + ); + expect(sqlButton).toBeDefined(); + }); + + it("should toggle SQL display", async () => { + wrapper = mount(BaseChart, { + props: { + title: "Test Chart", + sql: "SELECT * FROM test", + }, + }); + + // Initially hidden + expect(wrapper.find(".chart-sql").exists()).toBe(false); + + // Click to show + const buttons = wrapper.findAll("button"); + const sqlButton = buttons.find((button) => + button.text().includes("Show SQL"), + ); + expect(sqlButton).toBeDefined(); + + if (sqlButton) { + await sqlButton.trigger("click"); + + expect(wrapper.find(".chart-sql").exists()).toBe(true); + expect(wrapper.find(".chart-sql pre").text()).toBe( + "SELECT * FROM test", + ); + + // Click to hide + await sqlButton.trigger("click"); + expect(wrapper.find(".chart-sql").exists()).toBe(false); + } + }); + }); + + describe("data preview", () => { + const sampleData = [ + { id: 1, name: "Alice", age: 25 }, + { id: 2, name: "Bob", age: 30 }, + { id: 3, name: "Charlie", age: 35 }, + ]; + + it("should show data preview when enabled", () => { + wrapper = mount(BaseChart, { + props: { + data: sampleData, + showDataPreview: true, + }, + }); + + expect(wrapper.find(".chart-data-preview").exists()).toBe(true); + expect(wrapper.find(".data-table").exists()).toBe(true); + }); + + it("should hide data preview when disabled", () => { + wrapper = mount(BaseChart, { + props: { + data: sampleData, + showDataPreview: false, + }, + }); + + expect(wrapper.find(".chart-data-preview").exists()).toBe(false); + }); + + it("should display correct data in preview table", () => { + wrapper = mount(BaseChart, { + props: { + data: sampleData, + showDataPreview: true, + }, + }); + + const table = wrapper.find(".data-table"); + const headers = table.findAll("th"); + const rows = table.findAll("tbody tr"); + + // Check headers + expect(headers).toHaveLength(3); + expect(headers[0].text()).toBe("id"); + expect(headers[1].text()).toBe("name"); + expect(headers[2].text()).toBe("age"); + + // Check data rows + expect(rows).toHaveLength(3); + expect(rows[0].findAll("td")[1].text()).toBe("Alice"); + }); + + it("should limit preview to 5 rows", () => { + const largeData = Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + name: `User ${i + 1}`, + })); + + wrapper = mount(BaseChart, { + props: { + data: largeData, + showDataPreview: true, + }, + }); + + const rows = wrapper.findAll("tbody tr"); + expect(rows).toHaveLength(5); + }); + }); + + describe("chart initialization", () => { + it("should initialize ECharts on mount", async () => { + const { init } = await import("echarts"); + wrapper = mount(BaseChart); + + // Wait for next tick to ensure onMounted is called + await wrapper.vm.$nextTick(); + + expect(init).toHaveBeenCalled(); + }); + + it("should cleanup chart on unmount", async () => { + wrapper = mount(BaseChart); + await wrapper.vm.$nextTick(); + + wrapper.unmount(); + + expect(mockChart.dispose).toHaveBeenCalled(); + }); + + it("should handle window resize", async () => { + wrapper = mount(BaseChart); + await wrapper.vm.$nextTick(); + + // Simulate window resize + const resizeHandler = (global.addEventListener as any).mock.calls.find( + (call: any) => call[0] === "resize", + )?.[1]; + + if (resizeHandler) { + resizeHandler(); + expect(mockChart.resize).toHaveBeenCalled(); + } + }); + }); + + describe("export functionality", () => { + it("should emit export event when export button is clicked", async () => { + wrapper = mount(BaseChart, { + props: { + title: "Test Chart", + allowExport: true, + }, + }); + + const buttons = wrapper.findAll("button"); + const exportButton = buttons.find((button) => + button.text().includes("Export"), + ); + expect(exportButton).toBeDefined(); + + if (exportButton) { + await exportButton.trigger("click"); + expect(wrapper.emitted("export")).toBeTruthy(); + expect(wrapper.emitted("export")?.[0][0]).toEqual(mockChart); + } + }); + + it("should not show export button when disabled", () => { + wrapper = mount(BaseChart, { + props: { + title: "Test Chart", + allowExport: false, + }, + }); + + const buttons = wrapper.findAll("button"); + expect(buttons).toHaveLength(0); // No SQL provided, no export allowed + }); + }); + + describe("exposed methods", () => { + it("should expose chart methods", async () => { + wrapper = mount(BaseChart); + await wrapper.vm.$nextTick(); + + const vm = wrapper.vm as any; + expect(vm.chart).toBeDefined(); + expect(vm.updateChart).toBeDefined(); + expect(vm.handleResize).toBeDefined(); + expect(vm.cleanup).toBeDefined(); + }); + + it("should update chart with options", async () => { + wrapper = mount(BaseChart); + await wrapper.vm.$nextTick(); + + const options = { title: { text: "Test" } }; + const vm = wrapper.vm as any; + vm.updateChart(options); + + expect(mockChart.setOption).toHaveBeenCalledWith(options, true); + }); + }); +}); diff --git a/frontend/unit/KGMetricCard.test.ts b/frontend/unit/KGMetricCard.test.ts new file mode 100644 index 000000000..6cc737447 --- /dev/null +++ b/frontend/unit/KGMetricCard.test.ts @@ -0,0 +1,536 @@ +import { computed, ref } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mount, type VueWrapper } from "@vue/test-utils"; +import KGMetricCard from "@/components/dashboard/KGMetricCard.vue"; + +// Mock the composables and components +const mockExecuteQuery = vi.fn(); +const mockRetry = vi.fn(); +const mockCleanup = vi.fn(); + +vi.mock("@/composables/use-sql-query", () => ({ + useSqlQuery: vi.fn(() => ({ + isLoading: ref(false), + error: ref(null), + result: ref({ + data: [{ count: 1234 }], + columns: ["count"], + rowCount: 1, + executionTime: 45.2, + isFromCache: false, + }), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => 1234), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + })), +})); + +vi.mock("@/components/dashboard/BaseChart.vue", () => ({ + default: { + name: "BaseChart", + template: ` +
+
{{ title }}
+ +
+ `, + props: [ + "title", + "isLoading", + "error", + "data", + "sql", + "showControls", + "allowExport", + "showDataPreview", + "height", + ], + emits: ["retry", "export"], + }, +})); + +vi.mock("@/components/AppIcon.vue", () => ({ + default: { + name: "AppIcon", + template: '{{ icon }}', + props: ["icon"], + }, +})); + +describe("KGMetricCard", () => { + let wrapper: VueWrapper; + + beforeEach(async () => { + vi.clearAllMocks(); + // Reset the mock to ensure clean state + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValue({ + isLoading: ref(false), + error: ref(null), + result: ref({ + data: [{ count: 1234 }], + columns: ["count"], + rowCount: 1, + executionTime: 45.2, + isFromCache: false, + }), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => 1234), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + mockExecuteQuery.mockReset(); + mockRetry.mockReset(); + mockCleanup.mockReset(); + }); + + afterEach(() => { + if (wrapper) { + try { + wrapper.unmount(); + } catch { + // Ignore unmount errors + } + } + wrapper = null as any; + vi.resetAllMocks(); + }); + + describe("basic rendering", () => { + it("should render with basic props", () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Total Nodes", + dataSource: "nodes", + sql: "SELECT COUNT(*) as count FROM nodes", + }, + }); + + expect(wrapper.find(".chart-title").text()).toBe("Total Nodes"); + expect(wrapper.find(".value").text()).toBe("1,234"); + }); + + it("should render with subtitle and description", () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Total Nodes", + dataSource: "nodes", + sql: "SELECT COUNT(*) as count FROM nodes", + subtitle: "in knowledge graph", + description: "Total number of entities in the graph", + }, + }); + + expect(wrapper.find(".metric-subtitle").text()).toBe( + "in knowledge graph", + ); + expect(wrapper.find(".metric-description").text()).toBe( + "Total number of entities in the graph", + ); + }); + }); + + describe("value formatting", () => { + it("should format numbers correctly", () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Total Count", + dataSource: "test", + sql: "SELECT 1234567 as count", + format: "number", + }, + }); + + expect(wrapper.find(".value").text()).toBe("1,234"); + }); + + it("should format percentages correctly", async () => { + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValueOnce({ + isLoading: ref(false), + error: ref(null), + result: ref({ + data: [{ rate: 0.856 }], + columns: ["rate"], + rowCount: 1, + executionTime: 45.2, + isFromCache: false, + }), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => 0.856), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + + wrapper = mount(KGMetricCard, { + props: { + title: "Success Rate", + dataSource: "test", + sql: "SELECT 0.856 as rate", + format: "percentage", + }, + }); + + expect(wrapper.find(".value").text()).toBe("85.6%"); + }); + + it("should format currency correctly", async () => { + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValueOnce({ + isLoading: ref(false), + error: ref(null), + result: ref({ + data: [{ amount: 12345.67 }], + columns: ["amount"], + rowCount: 1, + executionTime: 45.2, + isFromCache: false, + }), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => 12345.67), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + + wrapper = mount(KGMetricCard, { + props: { + title: "Cost", + dataSource: "test", + sql: "SELECT 12345.67 as amount", + format: "currency", + }, + }); + + expect(wrapper.find(".value").text()).toBe("$12,345.67"); + }); + + it("should show dash for null values", async () => { + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValueOnce({ + isLoading: ref(false), + error: ref(null), + result: ref({ + data: [], + columns: [], + rowCount: 0, + executionTime: 45.2, + isFromCache: false, + }), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => null), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + + wrapper = mount(KGMetricCard, { + props: { + title: "Empty Result", + dataSource: "test", + sql: "SELECT NULL as value", + }, + }); + + expect(wrapper.find(".value").text()).toBe("—"); + }); + }); + + describe("trend indicators", () => { + it("should show positive trend", () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Growth", + dataSource: "test", + sql: "SELECT 100 as value", + showTrend: true, + trendValue: 15.5, + }, + }); + + const trend = wrapper.find(".metric-trend"); + expect(trend.exists()).toBe(true); + expect(trend.classes()).toContain("positive"); + expect(trend.find(".icon").text()).toBe("trending-up"); + expect(trend.text()).toContain("15.5%"); + }); + + it("should show negative trend", () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Decline", + dataSource: "test", + sql: "SELECT 100 as value", + showTrend: true, + trendValue: -8.2, + }, + }); + + const trend = wrapper.find(".metric-trend"); + expect(trend.classes()).toContain("negative"); + expect(trend.find(".icon").text()).toBe("trending-down"); + expect(trend.text()).toContain("8.2%"); + }); + + it("should show neutral trend", () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Stable", + dataSource: "test", + sql: "SELECT 100 as value", + showTrend: true, + trendValue: 0, + }, + }); + + const trend = wrapper.find(".metric-trend"); + expect(trend.classes()).toContain("neutral"); + expect(trend.find(".icon").text()).toBe("minus"); + expect(trend.text()).toContain("0%"); + }); + + it("should use custom trend suffix", () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Change", + dataSource: "test", + sql: "SELECT 100 as value", + showTrend: true, + trendValue: 5, + trendSuffix: " pts", + }, + }); + + const trend = wrapper.find(".metric-trend"); + expect(trend.text()).toContain("5 pts"); + }); + }); + + describe("loading and error states", () => { + it("should show loading state", async () => { + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValueOnce({ + isLoading: ref(true), + error: ref(null), + result: ref(null), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => null), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + + wrapper = mount(KGMetricCard, { + props: { + title: "Loading Test", + dataSource: "test", + sql: "SELECT COUNT(*) as count", + }, + }); + + expect(wrapper.find(".metric-value").classes()).toContain("loading"); + }); + + it("should show error state", async () => { + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValueOnce({ + isLoading: ref(false), + error: ref("Database connection failed"), + result: ref(null), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => null), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + + wrapper = mount(KGMetricCard, { + props: { + title: "Error Test", + dataSource: "test", + sql: "SELECT COUNT(*) as count", + }, + }); + + expect(wrapper.find(".metric-value").classes()).toContain("error"); + }); + }); + + describe("interactions", () => { + it("should call retry when handleRetry is invoked", async () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Retry Test", + dataSource: "test", + sql: "SELECT COUNT(*) as count", + }, + }); + + // Call handleRetry directly since there's no retry button + await wrapper.vm.handleRetry(); + + expect(mockRetry).toHaveBeenCalled(); + }); + + it("should emit error event on error", async () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Error Test", + dataSource: "test", + sql: "SELECT COUNT(*) as count", + }, + }); + + // Trigger error by making retry fail + mockRetry.mockRejectedValueOnce(new Error("Test error")); + await wrapper.vm.handleRetry(); + + expect(wrapper.emitted("error")).toBeTruthy(); + expect(wrapper.emitted("error")?.[0][0]).toBe("Test error"); + }); + + it("should have correct raw value", async () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Value Change Test", + dataSource: "test", + sql: "SELECT 100 as value", + }, + }); + + // Check the computed value directly + expect(wrapper.vm.rawValue).toBe(1234); + }); + }); + + describe("lifecycle", () => { + it("should execute query on mount when autoExecute is false", async () => { + // Wait for mount to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + wrapper = mount(KGMetricCard, { + props: { + title: "Manual Execute", + dataSource: "test", + sql: "SELECT COUNT(*) as count", + autoExecute: false, + }, + }); + + // Wait for mounted hook + await wrapper.vm.$nextTick(); + + expect(mockExecuteQuery).toHaveBeenCalled(); + }); + + it("should cleanup on unmount", async () => { + wrapper = mount(KGMetricCard, { + props: { + title: "Cleanup Test", + dataSource: "test", + sql: "SELECT COUNT(*) as count", + }, + }); + + await wrapper.vm.$nextTick(); + wrapper.unmount(); + + expect(mockCleanup).toHaveBeenCalled(); + }); + }); + + describe("bytes and duration formatting", () => { + it("should format bytes correctly", async () => { + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValueOnce({ + isLoading: ref(false), + error: ref(null), + result: ref({ + data: [{ size: 1048576 }], + columns: ["size"], + rowCount: 1, + executionTime: 45.2, + isFromCache: false, + }), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => 1048576), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + + wrapper = mount(KGMetricCard, { + props: { + title: "File Size", + dataSource: "test", + sql: "SELECT 1048576 as size", + format: "bytes", + }, + }); + + expect(wrapper.find(".value").text()).toBe("1 MB"); + }); + + it("should format duration correctly", async () => { + const { useSqlQuery } = await import("@/composables/use-sql-query"); + vi.mocked(useSqlQuery).mockReturnValueOnce({ + isLoading: ref(false), + error: ref(null), + result: ref({ + data: [{ duration: 2500 }], + columns: ["duration"], + rowCount: 1, + executionTime: 45.2, + isFromCache: false, + }), + lastExecuted: ref(new Date()), + getSingleValue: computed(() => 2500), + shouldAutoExecute: computed(() => true), + executeQuery: mockExecuteQuery, + retry: mockRetry, + clearCache: vi.fn(), + cleanup: mockCleanup, + validateSQL: vi.fn().mockReturnValue({ isValid: true, errors: [] }), + } as any); + + wrapper = mount(KGMetricCard, { + props: { + title: "Processing Time", + dataSource: "test", + sql: "SELECT 2500 as duration", + format: "duration", + }, + }); + + expect(wrapper.find(".value").text()).toBe("2.5s"); + }); + }); +}); diff --git a/frontend/unit/duckdb-data-processing.test.ts b/frontend/unit/duckdb-data-processing.test.ts new file mode 100644 index 000000000..8d00a4ce1 --- /dev/null +++ b/frontend/unit/duckdb-data-processing.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +/** + * Test the data processing logic for DuckDB query results This isolates the + * value conversion logic to understand and fix the issue + */ +describe("DuckDB Data Processing", () => { + // Helper function to simulate what we're doing in use-duckdb.ts + const processValue = (value: any): any => { + // Handle DuckDB-specific data types first + if (value instanceof Uint32Array) { + return value[0]; + } + + if (typeof value === "bigint") { + return Number(value); + } + + if ( + value instanceof Int32Array || + value instanceof Float64Array || + value instanceof Float32Array + ) { + return value[0]; + } + + // Handle JSON-serialized strings + if (typeof value !== "string") { + return value; + } + + // Handle empty string case - don't convert to number + if (value === "") { + return value; + } + + let processedValue = value; + + // Try multiple rounds of JSON parsing for deeply nested quotes + try { + // Keep parsing until we can't parse anymore or get a non-string + while ( + typeof processedValue === "string" && + processedValue.startsWith('"') && + processedValue.endsWith('"') + ) { + processedValue = JSON.parse(processedValue); + } + } catch { + // If JSON parsing fails at any point, continue with what we have + } + + // Now try to convert to number if it looks numeric + if (typeof processedValue === "string" && processedValue.trim() !== "") { + const numValue = Number(processedValue); + if (!isNaN(numValue)) { + return numValue; + } + } + + return processedValue; + }; + + describe("processValue function", () => { + it("should handle regular numbers", () => { + expect(processValue(123)).toBe(123); + expect(processValue(0)).toBe(0); + expect(processValue(-456)).toBe(-456); + }); + + it("should handle DuckDB Uint32Array values", () => { + const uint32Array = new Uint32Array([3398, 0, 0, 0]); + expect(processValue(uint32Array)).toBe(3398); + }); + + it("should handle BigInt values", () => { + expect(processValue(17n)).toBe(17); + expect(processValue(12345n)).toBe(12345); + }); + + it("should handle other typed arrays", () => { + const int32Array = new Int32Array([42, 0]); + const float64Array = new Float64Array([3.14, 0]); + expect(processValue(int32Array)).toBe(42); + expect(processValue(float64Array)).toBe(3.14); + }); + + it("should handle regular strings", () => { + expect(processValue("hello")).toBe("hello"); + expect(processValue("")).toBe(""); + }); + + it("should handle numeric strings", () => { + expect(processValue("123")).toBe(123); + expect(processValue("0")).toBe(0); + expect(processValue("-456")).toBe(-456); + }); + + it("should handle JSON-encoded strings with quotes", () => { + // This is what we're seeing: "\"3398\"" + expect(processValue('"3398"')).toBe(3398); + expect(processValue('"0"')).toBe(0); + expect(processValue('"-456"')).toBe(-456); + }); + + it("should handle double-encoded JSON strings", () => { + // In case DuckDB is doing multiple levels of encoding + expect(processValue('"\\"123\\""')).toBe(123); + }); + + it("should handle non-numeric JSON-encoded strings", () => { + expect(processValue('"hello"')).toBe("hello"); + expect(processValue('"world"')).toBe("world"); + }); + + it("should handle null and undefined", () => { + expect(processValue(null)).toBe(null); + expect(processValue(undefined)).toBe(undefined); + }); + + it("should handle boolean values", () => { + expect(processValue(true)).toBe(true); + expect(processValue(false)).toBe(false); + }); + + it("should handle arrays", () => { + const arr = [1, 2, 3]; + expect(processValue(arr)).toBe(arr); + }); + + it("should handle objects", () => { + const obj = { a: 1, b: 2 }; + expect(processValue(obj)).toBe(obj); + }); + }); + + describe("real DuckDB scenarios", () => { + it("should process typical query result", () => { + // Simulate what DuckDB might return + const mockDuckDBResult = [ + { total_nodes: '"3398"' }, + { human_genes: '"243"' }, + { total_edges: '"14040235"' }, + ]; + + const processed = mockDuckDBResult.map((row) => { + const processedRow: Record = {}; + Object.entries(row).forEach(([key, value]) => { + processedRow[key] = processValue(value); + }); + return processedRow; + }); + + expect(processed).toEqual([ + { total_nodes: 3398 }, + { human_genes: 243 }, + { total_edges: 14040235 }, + ]); + }); + + it("should handle mixed data types", () => { + const mockResult = [ + { + count: '"123"', + name: '"gene_name"', + score: '"0.95"', + category: '"biolink:Gene"', + }, + ]; + + const processed = mockResult.map((row) => { + const processedRow: Record = {}; + Object.entries(row).forEach(([key, value]) => { + processedRow[key] = processValue(value); + }); + return processedRow; + }); + + expect(processed).toEqual([ + { + count: 123, + name: "gene_name", + score: 0.95, + category: "biolink:Gene", + }, + ]); + }); + }); +}); diff --git a/frontend/unit/setup.ts b/frontend/unit/setup.ts index e8bd5f9e1..39ebd3306 100644 --- a/frontend/unit/setup.ts +++ b/frontend/unit/setup.ts @@ -79,4 +79,24 @@ export const emitted = ( }; /** mock functions that will throw error vitest environment */ -window.scrollTo = vi.fn(() => null); +Object.defineProperty(window, "scrollTo", { + value: vi.fn(() => null), + writable: true, +}); + +// Mock for JSDOM virtual console to suppress scrollTo errors +const originalConsoleError = console.error; +console.error = (message, ...args) => { + if (typeof message === "string" && message.includes("window.scrollTo")) { + return; // Suppress scrollTo errors + } + originalConsoleError(message, ...args); +}; + +/** ensure components are properly cleaned up between tests */ +afterEach(() => { + // Force cleanup of any remaining Vue instances + if (global.gc) { + global.gc(); + } +}); diff --git a/frontend/unit/use-duckdb.test.ts b/frontend/unit/use-duckdb.test.ts new file mode 100644 index 000000000..f273de164 --- /dev/null +++ b/frontend/unit/use-duckdb.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDuckDB } from "@/composables/use-duckdb"; + +// Mock DuckDB WASM module +vi.mock("@duckdb/duckdb-wasm", () => ({ + selectBundle: vi.fn().mockResolvedValue({ + mainModule: "mock-module", + mainWorker: "mock-worker", + }), + AsyncDuckDB: vi.fn().mockImplementation(() => ({ + instantiate: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue({ + query: vi.fn().mockResolvedValue({ + toArray: vi + .fn() + .mockReturnValue([ + { toJSON: () => ({ id: 1, name: "test" }) }, + { toJSON: () => ({ id: 2, name: "test2" }) }, + ]), + schema: { + fields: [{ name: "id" }, { name: "name" }], + }, + }), + close: vi.fn().mockResolvedValue(undefined), + }), + terminate: vi.fn().mockResolvedValue(undefined), + })), + ConsoleLogger: vi.fn().mockImplementation(() => ({})), + LogLevel: { + DEBUG: 0, + INFO: 1, + WARNING: 2, + ERROR: 3, + }, +})); + +// Mock Worker +global.Worker = vi.fn().mockImplementation(() => ({ + postMessage: vi.fn(), + terminate: vi.fn(), +})); + +describe("useDuckDB", () => { + let duckDB: ReturnType; + + beforeEach(() => { + duckDB = useDuckDB(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (duckDB.isInitialized.value) { + await duckDB.cleanup(); + } + }); + + describe("initialization", () => { + it("should initialize DuckDB instance successfully", async () => { + expect(duckDB.isInitialized.value).toBe(false); + expect(duckDB.db.value).toBe(null); + expect(duckDB.connection.value).toBe(null); + + await duckDB.initDB(); + + expect(duckDB.isInitialized.value).toBe(true); + expect(duckDB.db.value).toBeDefined(); + expect(duckDB.connection.value).toBeDefined(); + expect(duckDB.error.value).toBe(null); + }); + + it("should not reinitialize if already initialized", async () => { + await duckDB.initDB(); + const firstDB = duckDB.db.value; + + await duckDB.initDB(); + expect(duckDB.db.value).toBe(firstDB); + }); + + it("should handle initialization errors", async () => { + // Create a new instance to test error handling + const errorDB = useDuckDB(); + const mockError = new Error("Init failed"); + + // Mock the AsyncDuckDB constructor to throw an error + const { AsyncDuckDB } = await import("@duckdb/duckdb-wasm"); + vi.mocked(AsyncDuckDB).mockImplementationOnce(() => { + throw mockError; + }) as any; + + await expect(errorDB.initDB()).rejects.toThrow("Init failed"); + expect(errorDB.error.value).toBe("Init failed"); + expect(errorDB.isInitialized.value).toBe(false); + }); + + it("should set loading state during initialization", async () => { + expect(duckDB.isLoading.value).toBe(false); + + const initPromise = duckDB.initDB(); + expect(duckDB.isLoading.value).toBe(true); + + await initPromise; + expect(duckDB.isLoading.value).toBe(false); + }); + }); + + describe("parquet loading", () => { + beforeEach(async () => { + await duckDB.initDB(); + }); + + it("should load parquet file successfully", async () => { + const url = "https://example.com/test.parquet"; + const tableName = "test_table"; + + await expect(duckDB.loadParquet(url, tableName)).resolves.not.toThrow(); + expect(duckDB.error.value).toBe(null); + }); + + it("should fail if not initialized", async () => { + const uninitializedDB = useDuckDB(); + await expect( + uninitializedDB.loadParquet("test.parquet", "test"), + ).rejects.toThrow("DuckDB not initialized"); + }); + + it("should handle parquet loading errors", async () => { + const mockError = new Error("Parquet load failed"); + vi.mocked(duckDB.connection.value!.query).mockRejectedValueOnce( + mockError, + ); + + await expect( + duckDB.loadParquet("invalid.parquet", "test"), + ).rejects.toThrow("Parquet load failed"); + expect(duckDB.error.value).toBe("Parquet load failed"); + }); + }); + + describe("query execution", () => { + beforeEach(async () => { + await duckDB.initDB(); + }); + + it("should execute SQL query successfully", async () => { + const result = await duckDB.query("SELECT 1 as test"); + + expect(result).toEqual({ + data: [ + { id: 1, name: "test" }, + { id: 2, name: "test2" }, + ], + columns: ["id", "name"], + rowCount: 2, + }); + expect(duckDB.error.value).toBe(null); + }); + + it("should fail if not initialized", async () => { + const uninitializedDB = useDuckDB(); + await expect(uninitializedDB.query("SELECT 1")).rejects.toThrow( + "DuckDB not initialized", + ); + }); + + it("should handle query errors", async () => { + const mockError = new Error("Invalid SQL"); + vi.mocked(duckDB.connection.value!.query).mockRejectedValueOnce( + mockError, + ); + + await expect(duckDB.query("INVALID SQL")).rejects.toThrow("Invalid SQL"); + expect(duckDB.error.value).toBe("Invalid SQL"); + }); + + it("should set loading state during query execution", async () => { + expect(duckDB.isLoading.value).toBe(false); + + const queryPromise = duckDB.query("SELECT 1"); + expect(duckDB.isLoading.value).toBe(true); + + await queryPromise; + expect(duckDB.isLoading.value).toBe(false); + }); + }); + + describe("cleanup", () => { + it("should clean up resources properly", async () => { + await duckDB.initDB(); + expect(duckDB.isInitialized.value).toBe(true); + + await duckDB.cleanup(); + + expect(duckDB.connection.value).toBe(null); + expect(duckDB.db.value).toBe(null); + expect(duckDB.isInitialized.value).toBe(false); + }); + + it("should handle cleanup when not initialized", async () => { + await expect(duckDB.cleanup()).resolves.not.toThrow(); + }); + }); + + describe("configuration", () => { + it("should use custom URLs when provided", async () => { + const customConfig = { + wasmUrl: "https://custom.com/duckdb.wasm", + workerUrl: "https://custom.com/worker.js", + }; + + const customDB = useDuckDB(customConfig); + await customDB.initDB(); + + expect(customDB.isInitialized.value).toBe(true); + }); + }); +}); diff --git a/frontend/unit/use-kg-data.test.ts b/frontend/unit/use-kg-data.test.ts new file mode 100644 index 000000000..715c0bf3d --- /dev/null +++ b/frontend/unit/use-kg-data.test.ts @@ -0,0 +1,337 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useKGData } from "@/composables/use-kg-data"; + +// Create mock functions that can be accessed in tests +const mockInitDB = vi.fn().mockResolvedValue(undefined); +const mockLoadParquet = vi.fn().mockResolvedValue(undefined); +const mockQuery = vi.fn().mockResolvedValue({ + data: [{ count: 100 }], + columns: ["count"], + rowCount: 1, +}); +const mockCleanup = vi.fn().mockResolvedValue(undefined); + +// Mock the dependencies +vi.mock("@/composables/use-duckdb", () => ({ + useDuckDB: vi.fn(() => ({ + initDB: mockInitDB, + loadParquet: mockLoadParquet, + query: mockQuery, + cleanup: mockCleanup, + isInitialized: { value: false }, + isLoading: { value: false }, + error: { value: null }, + })), +})); + +vi.mock("@/api/kg-version", () => ({ + getLatestKGReleaseDate: vi.fn().mockResolvedValue("2025-07-09"), + getKGSourceUrl: vi + .fn() + .mockResolvedValue( + "https://data.monarchinitiative.org/monarch-kg/2025-07-09", + ), +})); + +describe("useKGData", () => { + let kgData: ReturnType; + + beforeEach(() => { + kgData = useKGData(); + vi.clearAllMocks(); + // Reset mocks to their default behavior + mockInitDB.mockResolvedValue(undefined); + mockLoadParquet.mockResolvedValue(undefined); + mockQuery.mockResolvedValue({ + data: [{ count: 100 }], + columns: ["count"], + rowCount: 1, + }); + mockCleanup.mockResolvedValue(undefined); + }); + + afterEach(async () => { + await kgData.cleanup(); + }); + + describe("initialization", () => { + it("should initialize successfully", async () => { + expect(kgData.kgVersion.value).toBe(""); + expect(kgData.kgSourceUrl.value).toBe(""); + + await kgData.init(); + + expect(kgData.kgVersion.value).toBe("2025-07-09"); + expect(kgData.kgSourceUrl.value).toBe( + "https://data.monarchinitiative.org/monarch-kg/2025-07-09", + ); + expect(kgData.error.value).toBe(null); + }); + + it("should handle initialization errors", async () => { + mockInitDB.mockRejectedValueOnce(new Error("Init failed")); + + await expect(kgData.init()).rejects.toThrow("Init failed"); + expect(kgData.error.value).toBe("Init failed"); + }); + + it("should fallback to default version on API failure", async () => { + const { getLatestKGReleaseDate, getKGSourceUrl } = await import( + "@/api/kg-version" + ); + vi.mocked(getLatestKGReleaseDate).mockRejectedValueOnce( + new Error("API failed"), + ); + vi.mocked(getKGSourceUrl).mockRejectedValueOnce(new Error("API failed")); + + await kgData.init(); + + expect(kgData.kgVersion.value).toBe("latest"); + expect(kgData.kgSourceUrl.value).toBe( + "https://data.monarchinitiative.org/monarch-kg-dev/latest", + ); + }); + + it("should set loading state during initialization", async () => { + expect(kgData.isLoading.value).toBe(false); + + const initPromise = kgData.init(); + expect(kgData.isLoading.value).toBe(true); + + await initPromise; + expect(kgData.isLoading.value).toBe(false); + }); + }); + + describe("data source management", () => { + beforeEach(async () => { + await kgData.init(); + }); + + it("should register data source correctly", () => { + const config = { + name: "nodes", + url: "qc/node_report.parquet", + description: "Node statistics", + }; + + kgData.registerDataSource(config); + + const source = kgData.getDataSource("nodes"); + expect(source).toBeDefined(); + expect(source?.name).toBe("nodes"); + expect(source?.url).toBe("qc/node_report.parquet"); + expect(source?.description).toBe("Node statistics"); + expect(source?.isLoaded).toBe(false); + expect(source?.baseUrl).toBe( + "https://data.monarchinitiative.org/monarch-kg/2025-07-09", + ); + }); + + it("should register data source with custom base URL", () => { + const config = { + name: "custom", + url: "custom.parquet", + baseUrl: "https://custom.com", + }; + + kgData.registerDataSource(config); + + const source = kgData.getDataSource("custom"); + expect(source?.baseUrl).toBe("https://custom.com"); + }); + + it("should return undefined for non-existent data source", () => { + const source = kgData.getDataSource("nonexistent"); + expect(source).toBeUndefined(); + }); + + it("should track all registered data sources", () => { + kgData.registerDataSource({ name: "source1", url: "file1.parquet" }); + kgData.registerDataSource({ name: "source2", url: "file2.parquet" }); + + const allSources = kgData.getAllDataSources.value; + expect(allSources).toHaveLength(2); + expect(allSources.map((s) => s.name)).toContain("source1"); + expect(allSources.map((s) => s.name)).toContain("source2"); + }); + + it("should clear all data sources", () => { + kgData.registerDataSource({ name: "source1", url: "file1.parquet" }); + kgData.registerDataSource({ name: "source2", url: "file2.parquet" }); + + expect(kgData.getAllDataSources.value).toHaveLength(2); + + kgData.clearDataSources(); + + expect(kgData.getAllDataSources.value).toHaveLength(0); + }); + }); + + describe("data source loading", () => { + beforeEach(async () => { + await kgData.init(); + }); + + it("should load data source successfully", async () => { + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + + expect(kgData.isDataSourceLoaded("nodes")).toBe(false); + + await kgData.loadDataSource("nodes"); + + expect(kgData.isDataSourceLoaded("nodes")).toBe(true); + const source = kgData.getDataSource("nodes"); + expect(source?.lastLoaded).toBeDefined(); + expect(source?.loadError).toBeUndefined(); + }); + + it("should not reload already loaded data source", async () => { + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + + await kgData.loadDataSource("nodes"); + expect(mockLoadParquet).toHaveBeenCalledTimes(1); + + await kgData.loadDataSource("nodes"); + expect(mockLoadParquet).toHaveBeenCalledTimes(1); // Should not be called again + }); + + it("should handle loading errors", async () => { + mockLoadParquet.mockRejectedValueOnce(new Error("Load failed")); + + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + + await expect(kgData.loadDataSource("nodes")).rejects.toThrow( + "Load failed", + ); + + const source = kgData.getDataSource("nodes"); + expect(source?.isLoaded).toBe(false); + expect(source?.loadError).toBe("Load failed"); + }); + + it("should fail to load non-existent data source", async () => { + await expect(kgData.loadDataSource("nonexistent")).rejects.toThrow( + "Data source 'nonexistent' not found", + ); + }); + + it("should construct correct URLs", async () => { + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + await kgData.loadDataSource("nodes"); + + expect(mockLoadParquet).toHaveBeenCalledWith( + "https://data.monarchinitiative.org/monarch-kg/2025-07-09/qc/node_report.parquet", + "nodes", + ); + }); + }); + + describe("query execution", () => { + beforeEach(async () => { + await kgData.init(); + }); + + it("should execute query successfully", async () => { + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + + const result = await kgData.executeQuery( + "SELECT COUNT(*) as count FROM nodes", + ["nodes"], + ); + + expect(result).toEqual([{ count: 100 }]); + expect(kgData.isDataSourceLoaded("nodes")).toBe(true); + }); + + it("should execute query without required sources", async () => { + const result = await kgData.executeQuery("SELECT 1 as test"); + + expect(result).toEqual([{ count: 100 }]); + }); + + it("should handle query errors", async () => { + mockQuery.mockRejectedValueOnce(new Error("Invalid SQL")); + + await expect(kgData.executeQuery("INVALID SQL")).rejects.toThrow( + "Invalid SQL", + ); + expect(kgData.error.value).toBe("Invalid SQL"); + }); + + it("should load required sources before query", async () => { + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + kgData.registerDataSource({ + name: "edges", + url: "qc/edge_report.parquet", + }); + + await kgData.executeQuery("SELECT * FROM nodes JOIN edges", [ + "nodes", + "edges", + ]); + + expect(kgData.isDataSourceLoaded("nodes")).toBe(true); + expect(kgData.isDataSourceLoaded("edges")).toBe(true); + }); + }); + + describe("computed properties", () => { + beforeEach(async () => { + await kgData.init(); + }); + + it("should track loaded data sources", async () => { + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + kgData.registerDataSource({ + name: "edges", + url: "qc/edge_report.parquet", + }); + + expect(kgData.getLoadedDataSources.value).toHaveLength(0); + + await kgData.loadDataSource("nodes"); + + expect(kgData.getLoadedDataSources.value).toHaveLength(1); + expect(kgData.getLoadedDataSources.value[0].name).toBe("nodes"); + }); + }); + + describe("cleanup", () => { + it("should cleanup resources properly", async () => { + await kgData.init(); + kgData.registerDataSource({ + name: "nodes", + url: "qc/node_report.parquet", + }); + + expect(kgData.getAllDataSources.value).toHaveLength(1); + + await kgData.cleanup(); + + expect(kgData.getAllDataSources.value).toHaveLength(0); + expect(mockCleanup).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/unit/use-sql-query.test.ts b/frontend/unit/use-sql-query.test.ts new file mode 100644 index 000000000..77276bfcb --- /dev/null +++ b/frontend/unit/use-sql-query.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useSqlQuery } from "@/composables/use-sql-query"; + +// Mock the useKGData composable +const mockExecuteQuery = vi.fn(); +vi.mock("@/composables/use-kg-data", () => ({ + useKGData: vi.fn(() => ({ + executeQuery: mockExecuteQuery, + })), +})); + +describe("useSqlQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExecuteQuery.mockResolvedValue([ + { count: 100, category: "test" }, + { count: 200, category: "test2" }, + ]); + }); + + describe("SQL validation", () => { + it("should validate safe SELECT queries", () => { + const sqlQuery = useSqlQuery({ + sql: "SELECT * FROM nodes", + dataSources: ["nodes"], + }); + + const result = sqlQuery.validateSQL("SELECT * FROM nodes WHERE id = 1"); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject dangerous queries", () => { + const sqlQuery = useSqlQuery({ + sql: "SELECT * FROM nodes", + dataSources: ["nodes"], + }); + + const result = sqlQuery.validateSQL("DROP TABLE nodes"); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Query contains potentially dangerous keyword: DROP", + ); + }); + + it("should reject non-SELECT queries", () => { + const sqlQuery = useSqlQuery({ + sql: "SELECT * FROM nodes", + dataSources: ["nodes"], + }); + + const result = sqlQuery.validateSQL("UPDATE nodes SET count = 0"); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Query must start with SELECT or WITH"); + }); + }); + + describe("query execution", () => { + it("should execute query successfully", async () => { + const sqlQuery = useSqlQuery({ + sql: "SELECT COUNT(*) as count FROM nodes", + dataSources: ["nodes"], + }); + + const result = await sqlQuery.executeQuery(); + + expect(mockExecuteQuery).toHaveBeenCalledWith( + "SELECT COUNT(*) as count FROM nodes", + ["nodes"], + ); + expect(result.data).toEqual([ + { count: 100, category: "test" }, + { count: 200, category: "test2" }, + ]); + expect(result.columns).toEqual(["count", "category"]); + expect(result.rowCount).toBe(2); + expect(result.isFromCache).toBe(false); + }); + + it("should cache query results", async () => { + const sqlQuery = useSqlQuery({ + sql: "SELECT COUNT(*) as count FROM nodes", + dataSources: ["nodes"], + }); + + // First execution + const result1 = await sqlQuery.executeQuery(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + expect(result1.isFromCache).toBe(false); + + // Second execution should use cache + const result2 = await sqlQuery.executeQuery(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); // Still only called once + expect(result2.isFromCache).toBe(true); + }); + + it("should handle query errors", async () => { + mockExecuteQuery.mockRejectedValueOnce(new Error("Database error")); + + const sqlQuery = useSqlQuery({ + sql: "SELECT * FROM invalid_table", + dataSources: ["nodes"], + }); + + await expect(sqlQuery.executeQuery()).rejects.toThrow("Database error"); + expect(sqlQuery.error.value).toBe("Database error"); + }); + }); + + describe("single value extraction", () => { + it("should extract single value from result", async () => { + mockExecuteQuery.mockResolvedValueOnce([{ total: 500 }]); + + const sqlQuery = useSqlQuery({ + sql: "SELECT COUNT(*) as total FROM nodes", + dataSources: ["nodes"], + }); + + await sqlQuery.executeQuery(); + + expect(sqlQuery.getSingleValue.value).toBe(500); + }); + + it("should return null for empty results", async () => { + mockExecuteQuery.mockResolvedValueOnce([]); + + const sqlQuery = useSqlQuery({ + sql: "SELECT COUNT(*) as total FROM nodes", + dataSources: ["nodes"], + }); + + await sqlQuery.executeQuery(); + + expect(sqlQuery.getSingleValue.value).toBeNull(); + }); + }); + + describe("cache management", () => { + it("should clear cache", async () => { + const sqlQuery = useSqlQuery({ + sql: "SELECT COUNT(*) as count FROM nodes", + dataSources: ["nodes"], + }); + + // Execute query to populate cache + await sqlQuery.executeQuery(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + + // Execute again - should use cache + await sqlQuery.executeQuery(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + + // Clear cache and execute again + sqlQuery.clearCache(); + await sqlQuery.executeQuery(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(2); + }); + }); + + describe("retry functionality", () => { + it("should retry query and clear cache", async () => { + const sqlQuery = useSqlQuery({ + sql: "SELECT COUNT(*) as count FROM nodes", + dataSources: ["nodes"], + }); + + // Initial execution + await sqlQuery.executeQuery(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + + // Retry should clear cache and re-execute + await sqlQuery.retry(); + expect(mockExecuteQuery).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index d4fd56f9a..a5d437782 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -1,11 +1,55 @@ +import { copyFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vite"; import svgLoader from "vite-svg-loader"; import vue from "@vitejs/plugin-vue"; +// Plugin to copy DuckDB WASM files to public directory +function duckdbWasmPlugin() { + return { + name: "duckdb-wasm-copy", + buildStart() { + // Copy DuckDB WASM files to public directory during build + try { + const publicDir = "public"; + const duckdbDir = join(publicDir, "duckdb"); + + // Create directory if it doesn't exist + mkdirSync(duckdbDir, { recursive: true }); + + // Find the DuckDB module path + const duckdbPath = join( + "node_modules", + "@duckdb", + "duckdb-wasm", + "dist", + ); + + // Copy WASM files + copyFileSync( + join(duckdbPath, "duckdb-mvp.wasm"), + join(duckdbDir, "duckdb-mvp.wasm"), + ); + copyFileSync( + join(duckdbPath, "duckdb-browser-mvp.worker.js"), + join(duckdbDir, "duckdb-browser-mvp.worker.js"), + ); + + console.log("✓ DuckDB WASM files copied to public/duckdb/"); + } catch (error) { + console.warn( + "Warning: Could not copy DuckDB WASM files:", + error.message, + ); + } + }, + }; +} + // https://vitejs.dev/config/ export default defineConfig({ - plugins: [vue(), svgLoader({ svgo: false })], + plugins: [vue(), svgLoader({ svgo: false }), duckdbWasmPlugin()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), @@ -18,4 +62,7 @@ export default defineConfig({ }, }, }, + optimizeDeps: { + exclude: ["@duckdb/duckdb-wasm"], + }, }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index dfa2b5484..c9ac38f5f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -11,6 +11,10 @@ export default mergeConfig( environment: "jsdom", include: ["./unit/**/*.test.ts"], root: fileURLToPath(new URL("./", import.meta.url)), + globals: true, + env: { + NODE_ENV: "test", + }, }, }), );