• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

anthonypdawson / vector-inspector / 22329517074

23 Feb 2026 11:33PM UTC coverage: 59.86% (+2.6%) from 57.258%
22329517074

Pull #20

github

anthonypdawson
feat(tests): add unit tests for cluster runner, search runner, and connection manager panel functionality
Pull Request #20: Version 0.5.0 - Add Histogram visualization, update sample data generation, connection info panel, tests

320 of 544 new or added lines in 12 files covered. (58.82%)

26 existing lines in 8 files now uncovered.

8287 of 13844 relevant lines covered (59.86%)

0.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

19.47
/src/vector_inspector/ui/controllers/connection_controller.py
1
"""Controller for managing connection lifecycle and threading."""
2

3
import hashlib
1✔
4
import time
1✔
5
import uuid
1✔
6
from typing import Optional
1✔
7

8
from PySide6.QtCore import QObject, QThread, Signal
1✔
9
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget
1✔
10

11
from vector_inspector import get_version
1✔
12
from vector_inspector.core.connection_manager import ConnectionManager, ConnectionState
1✔
13
from vector_inspector.core.connections.base_connection import VectorDBConnection
1✔
14
from vector_inspector.core.provider_factory import ProviderFactory
1✔
15
from vector_inspector.services.collection_service import CollectionService
1✔
16
from vector_inspector.services.profile_service import ProfileService
1✔
17
from vector_inspector.services.telemetry_service import TelemetryService
1✔
18
from vector_inspector.ui.components.create_collection_dialog import CreateCollectionDialog
1✔
19
from vector_inspector.ui.components.loading_dialog import LoadingDialog
1✔
20
from vector_inspector.ui.workers.collection_worker import CollectionCreationWorker
1✔
21

22

23
class ConnectionThread(QThread):
1✔
24
    """Background thread for connecting to database."""
25

26
    finished = Signal(bool, list, str, float, str)  # success, collections, error_message, duration_ms, correlation_id
1✔
27

28
    def __init__(self, connection: VectorDBConnection, correlation_id: str, provider: str):
1✔
29
        super().__init__()
×
30
        self.connection = connection
×
31
        self.correlation_id = correlation_id
×
32
        self.provider = provider
×
33

34
    def run(self):
1✔
35
        """Connect to database and get collections."""
36
        start_time = time.time()
×
37
        try:
×
38
            success = self.connection.connect()
×
39
            duration_ms = int((time.time() - start_time) * 1000)
×
40
            if success:
×
41
                collections = self.connection.list_collections()
×
42
                self.finished.emit(True, collections, "", duration_ms, self.correlation_id)
×
43
            else:
44
                self.finished.emit(False, [], "Connection failed", duration_ms, self.correlation_id)
×
45
        except Exception as e:
×
46
            duration_ms = int((time.time() - start_time) * 1000)
×
47
            self.finished.emit(False, [], str(e), duration_ms, self.correlation_id)
×
48

49

50
class ModelMetadataLoadThread(QThread):
1✔
51
    """Background thread for loading embedding model metadata."""
52

53
    finished = Signal(int)  # dimension
1✔
54
    error = Signal(str)  # error_message
1✔
55

56
    def __init__(self, embedder_name: str, embedder_type: str, parent=None):
1✔
57
        """
58
        Initialize model metadata load thread.
59

60
        Args:
61
            embedder_name: Name of the embedding model
62
            embedder_type: Type of the embedding provider
63
            parent: Parent QObject
64
        """
65
        super().__init__(parent)
×
66
        self.embedder_name = embedder_name
×
67
        self.embedder_type = embedder_type
×
68

69
    def run(self):
1✔
70
        """Load model metadata in background."""
71
        try:
×
72
            from vector_inspector.core.embedding_providers import ProviderFactory
×
73

74
            provider = ProviderFactory.create(self.embedder_name, self.embedder_type)
×
75
            metadata = provider.get_metadata()
×
76
            self.finished.emit(metadata.dimension)
×
77
        except Exception as e:
×
78
            self.error.emit(str(e))
×
79

80

81
class ConnectionController(QObject):
1✔
82
    """Controller for managing connection operations and lifecycle.
83

84
    This handles:
85
    - Creating connections from profiles
86
    - Starting connection threads
87
    - Handling connection results
88
    - Managing loading dialogs
89
    - Emitting signals for UI updates
90
    """
91

92
    connection_completed = Signal(str, bool, list, str)  # connection_id, success, collections, error
1✔
93

94
    def __init__(
1✔
95
        self,
96
        connection_manager: ConnectionManager,
97
        profile_service: ProfileService,
98
        parent: Optional[QWidget] = None,
99
    ):
100
        super().__init__(parent)
1✔
101
        self.connection_manager = connection_manager
1✔
102
        self.profile_service = profile_service
1✔
103
        self.parent_widget = parent
1✔
104

105
        # State
106
        self._connection_threads: dict[str, ConnectionThread] = {}
1✔
107
        self._active_worker = None
1✔
108
        self.model_metadata_thread: Optional[ModelMetadataLoadThread] = None
1✔
109
        self.loading_dialog = LoadingDialog("Loading...", parent)
1✔
110
        self.collection_service = CollectionService(parent)
1✔
111

112
    def connect_to_profile(self, profile_id: str) -> bool:
1✔
113
        """Connect to a profile.
114

115
        Args:
116
            profile_id: ID of the profile to connect to
117

118
        Returns:
119
            True if connection initiated successfully, False otherwise
120
        """
121
        profile_data = self.profile_service.get_profile_with_credentials(profile_id)
×
122
        if not profile_data:
×
123
            QMessageBox.warning(self.parent_widget, "Error", "Profile not found.")
×
124
            return False
×
125

126
        # Check connection limit
127
        if self.connection_manager.get_connection_count() >= ConnectionManager.MAX_CONNECTIONS:
×
128
            QMessageBox.warning(
×
129
                self.parent_widget,
130
                "Connection Limit",
131
                f"Maximum number of connections ({ConnectionManager.MAX_CONNECTIONS}) reached. "
132
                "Please close a connection first.",
133
            )
134
            return False
×
135

136
        # Create connection
137
        provider = profile_data["provider"]
×
138
        config = profile_data["config"]
×
139
        credentials = profile_data.get("credentials", {})
×
140

141
        try:
×
142
            # Create connection object using factory
143
            connection = ProviderFactory.create(provider, config, credentials)
×
144

145
            # Register with connection manager, using profile_id as connection_id for persistence
146
            connection_id = self.connection_manager.create_connection(
×
147
                name=profile_data["name"],
148
                provider=provider,
149
                connection=connection,
150
                config=config,
151
                connection_id=profile_data["id"],
152
            )
153

154
            # Update state to connecting
NEW
155
            self.connection_manager.update_connection_state(connection_id, ConnectionState.CONNECTING)
×
156

157
            # Generate correlation ID for telemetry
158
            correlation_id = str(uuid.uuid4())
×
159

160
            # Send connection attempt telemetry
161
            try:
×
162
                telemetry = TelemetryService()
×
163
                # Hash host/path for privacy
164
                host_value = config.get("host") or config.get("path") or "unknown"
×
165
                host_hash = hashlib.sha256(host_value.encode()).hexdigest()[:16]
×
166
                telemetry.queue_event(
×
167
                    {
168
                        "event_name": "db.connection_attempt",
169
                        "app_version": get_version(),
170
                        "metadata": {
171
                            "db_type": provider,
172
                            "host_hash": host_hash,
173
                            "connection_id": connection_id,
174
                            "correlation_id": correlation_id,
175
                        },
176
                    }
177
                )
178
            except Exception:
×
179
                pass  # Best effort telemetry
×
180

181
            # Connect in background thread
182
            thread = ConnectionThread(connection, correlation_id, provider)
×
183
            thread.finished.connect(
×
184
                lambda success, collections, error, duration_ms, corr_id: self._on_connection_finished(
185
                    connection_id, provider, success, collections, error, duration_ms, corr_id
186
                )
187
            )
188
            self._connection_threads[connection_id] = thread
×
189
            thread.start()
×
190

191
            # Show loading dialog
192
            self.loading_dialog.show_loading(f"Connecting to {profile_data['name']}...")
×
193
            return True
×
194

195
        except Exception as e:
×
NEW
196
            QMessageBox.critical(self.parent_widget, "Connection Error", f"Failed to create connection: {e}")
×
UNCOV
197
            return False
×
198

199
    def _on_connection_finished(
1✔
200
        self,
201
        connection_id: str,
202
        provider: str,
203
        success: bool,
204
        collections: list,
205
        error: str,
206
        duration_ms: float,
207
        correlation_id: str,
208
    ):
209
        """Handle connection thread completion."""
210
        self.loading_dialog.hide_loading()
×
211

212
        # Send connection result telemetry
213
        try:
×
214
            telemetry = TelemetryService()
×
215
            metadata = {
×
216
                "success": success,
217
                "db_type": provider,
218
                "duration_ms": duration_ms,
219
                "correlation_id": correlation_id,
220
            }
221
            if not success:
×
222
                metadata["error_code"] = "CONNECTION_FAILED"
×
223
                metadata["error_class"] = type(error).__name__ if error else "Unknown"
×
224
            telemetry.queue_event({"event_name": "db.connection_result", "metadata": metadata})
×
225
            telemetry.send_batch()
×
226
        except Exception:
×
227
            pass  # Best effort telemetry
×
228

229
        # Clean up thread
230
        thread = self._connection_threads.pop(connection_id, None)
×
231
        if thread:
×
232
            thread.wait()  # Wait for thread to fully finish
×
233
            thread.deleteLater()
×
234

235
        if success:
×
236
            # Update state to connected
NEW
237
            self.connection_manager.update_connection_state(connection_id, ConnectionState.CONNECTED)
×
238

239
            # Mark connection as opened first (will show in UI)
240
            self.connection_manager.mark_connection_opened(connection_id)
×
241

242
            # Then update collections (UI item now exists to receive them)
243
            self.connection_manager.update_collections(connection_id, collections)
×
244
        else:
245
            # Update state to error
NEW
246
            self.connection_manager.update_connection_state(connection_id, ConnectionState.ERROR, error)
×
247

NEW
248
            QMessageBox.warning(self.parent_widget, "Connection Failed", f"Failed to connect: {error}")
×
249

250
            # Remove the failed connection
251
            self.connection_manager.close_connection(connection_id)
×
252

253
        # Emit signal for UI updates
254
        self.connection_completed.emit(connection_id, success, collections, error)
×
255

256
    def create_collection_with_dialog(self, connection_id: str = None) -> bool:
1✔
257
        """Show dialog to create a new collection with optional sample data.
258

259
        Args:
260
            connection_id: ID of the active connection
261

262
        Returns:
263
            True if the collection creation process was initiated successfully,
264
            False if the dialog was cancelled or validation failed. Note that
265
            when True is returned, the actual collection creation happens
266
            asynchronously in a background thread, so True does not indicate
267
            that the collection has been created yet - only that the process
268
            has started without errors.
269
        """
270
        # Get active connection
271
        if connection_id is None:
×
272
            connection_id = self.connection_manager.get_active_connection_id()
×
273

274
        if not connection_id:
×
NEW
275
            QMessageBox.warning(self.parent_widget, "No Connection", "Please connect to a database first.")
×
UNCOV
276
            return False
×
277

278
        connection = self.connection_manager.get_connection(connection_id)
×
279
        if not connection:
×
280
            QMessageBox.warning(self.parent_widget, "Error", "Connection not found.")
×
281
            return False
×
282

283
        # Show dialog
284
        dialog = CreateCollectionDialog(self.parent_widget)
×
285
        # Inform dialog which connection is active so user knows where collection will be created
NEW
286
        dialog.set_connection(connection)
×
287
        if dialog.exec() != CreateCollectionDialog.DialogCode.Accepted:
×
288
            return False
×
289

290
        config = dialog.get_configuration()
×
291
        collection_name = config["name"]
×
292

293
        # Check if collection already exists
294
        try:
×
295
            existing_collections = connection.list_collections()
×
296
            if collection_name in existing_collections:
×
297
                QMessageBox.warning(
×
298
                    self.parent_widget,
299
                    "Collection Exists",
300
                    f"A collection named '{collection_name}' already exists.",
301
                )
302
                return False
×
303
        except Exception as e:
×
NEW
304
            QMessageBox.warning(self.parent_widget, "Error", f"Could not check existing collections: {e}")
×
UNCOV
305
            return False
×
306

307
        # Create progress dialog immediately
308
        progress_dialog = QProgressDialog(
×
309
            "Preparing...",
310
            None,  # No cancel button label
311
            0,
312
            0,  # Indefinite progress initially
313
            self.parent_widget,
314
        )
315
        progress_dialog.setWindowTitle("Creating Collection")
×
316
        progress_dialog.setModal(True)
×
317
        progress_dialog.setMinimumDuration(0)
×
318
        progress_dialog.setCancelButton(None)
×
319
        progress_dialog.setAutoClose(False)
×
320
        progress_dialog.setAutoReset(False)
×
321
        progress_dialog.setValue(0)
×
322
        progress_dialog.show()
×
323

324
        # Get dimension from model if sample data is requested
325
        if config["add_sample"]:
×
326
            progress_dialog.setLabelText("Loading embedding model...")
×
327

328
            # Cancel any existing model metadata thread
329
            if self.model_metadata_thread and self.model_metadata_thread.isRunning():
×
330
                self.model_metadata_thread.quit()
×
331
                self.model_metadata_thread.wait()
×
332

333
            # Start thread to load model metadata
NEW
334
            self.model_metadata_thread = ModelMetadataLoadThread(config["embedder_name"], config["embedder_type"], self)
×
UNCOV
335
            self.model_metadata_thread.finished.connect(
×
336
                lambda dim: self._create_collection_with_dimension(
337
                    connection, connection_id, collection_name, config, dim, progress_dialog
338
                )
339
            )
NEW
340
            self.model_metadata_thread.error.connect(lambda err: self._on_model_metadata_error(err, progress_dialog))
×
UNCOV
341
            self.model_metadata_thread.start()
×
342
        else:
343
            # No sample data - proceed directly with None dimension
344
            self._create_collection_with_dimension(
×
345
                connection, connection_id, collection_name, config, None, progress_dialog
346
            )
347

348
        return True
×
349

350
    def _on_model_metadata_error(self, error_message: str, progress_dialog: QProgressDialog) -> None:
1✔
351
        """Handle model metadata loading error."""
352
        progress_dialog.close()
×
NEW
353
        QMessageBox.critical(self.parent_widget, "Error", f"Failed to get model dimension: {error_message}")
×
354

355
    def _create_collection_with_dimension(
1✔
356
        self,
357
        connection,
358
        connection_id: str,
359
        collection_name: str,
360
        config: dict,
361
        dimension: Optional[int],
362
        progress_dialog: QProgressDialog,
363
    ) -> None:
364
        """Create collection with the loaded dimension."""
365
        # Now set up for collection creation
366
        progress_dialog.setMaximum(3)
×
367
        progress_dialog.setLabelText("Creating collection...")
×
368
        progress_dialog.setValue(0)
×
369

370
        # Create worker thread
371
        sample_config = None
×
372
        if config["add_sample"]:
×
373
            sample_config = {
×
374
                "count": config["count"],
375
                "data_type": config["data_type"],
376
                "embedder_name": config["embedder_name"],
377
                "embedder_type": config["embedder_type"],
378
                "random_data": config.get("random_data", True),
379
            }
380

381
        worker = CollectionCreationWorker(
×
382
            connection=connection,
383
            collection_name=collection_name,
384
            dimension=dimension,
385
            add_sample=config["add_sample"],
386
            sample_config=sample_config,
387
            parent=self,
388
        )
389

390
        def on_progress(message: str, current: int, total: int):
×
391
            """Update progress dialog."""
392
            from vector_inspector.core.logging import log_info
×
393

394
            log_info(f"Collection creation progress: {message} ({current}/{total})")
×
395
            progress_dialog.setLabelText(message)
×
396
            progress_dialog.setMaximum(total)
×
397
            progress_dialog.setValue(current)
×
398
            QApplication.processEvents()
×
399

400
        def on_complete(success: bool, message: str):
×
401
            """Handle completion."""
402
            from vector_inspector.core.logging import log_error, log_info
×
403

404
            progress_dialog.setValue(3)
×
405
            progress_dialog.close()
×
406

407
            # Save embedding model information if collection was created successfully with sample data
408
            if success and config["add_sample"]:
×
409
                try:
×
410
                    from vector_inspector.services.settings_service import SettingsService
×
411

412
                    settings = SettingsService()
×
413

414
                    # Get profile name from connection
NEW
415
                    profile_name = connection.name if hasattr(connection, "name") else str(connection_id)
×
416

417
                    # Save the embedding model configuration
418
                    settings.save_embedding_model(
×
419
                        profile_name=profile_name,
420
                        collection_name=collection_name,
421
                        model_name=config["embedder_name"],
422
                        model_type=config["embedder_type"],
423
                    )
NEW
424
                    log_info(f"Saved embedding model config: {config['embedder_name']} for {collection_name}")
×
UNCOV
425
                except Exception as e:
×
426
                    # Log but don't fail - collection is created successfully
427
                    log_error(f"Failed to save embedding model configuration: {e}")
×
428

429
            # Show result
430
            if success:
×
431
                log_info(f"Collection creation successful: {message}")
×
432
                QMessageBox.information(self.parent_widget, "Success", message)
×
433
            else:
434
                log_error(f"Collection creation failed: {message}")
×
435
                QMessageBox.warning(self.parent_widget, "Error", message)
×
436

437
            # Refresh collections
438
            if success:
×
439
                try:
×
440
                    collections = connection.list_collections()
×
441
                    self.connection_manager.update_collections(connection_id, collections)
×
442
                    log_info("Refreshed collection list")
×
443
                except Exception as e:
×
444
                    log_error(f"Failed to refresh collections: {e}")
×
445

446
            # Clean up worker reference
447
            if hasattr(self, "_active_worker"):
×
448
                self._active_worker = None
×
449

450
        def on_error(error: str):
×
451
            """Handle error."""
452
            from vector_inspector.core.logging import log_error
×
453

454
            log_error(f"Collection creation error: {error}")
×
455
            progress_dialog.close()
×
456

457
            error_message = f"Error: {error}" if error else "An unknown error occurred"
×
458
            QMessageBox.critical(self.parent_widget, "Error", error_message)
×
459

460
            # Clean up worker reference
461
            if hasattr(self, "_active_worker"):
×
462
                self._active_worker = None
×
463

464
        # Connect signals
465
        worker.progress_update.connect(on_progress)
×
466
        worker.creation_complete.connect(on_complete)
×
467
        worker.error_occurred.connect(on_error)
×
468

469
        # Store worker reference to prevent garbage collection
470
        self._active_worker = worker
×
471

472
        # Start worker (non-blocking)
473
        worker.start()
×
474

475
        return True  # Successfully started the operation
×
476

477
    def cleanup(self):
1✔
478
        """Clean up connection threads on shutdown."""
479
        for thread in list(self._connection_threads.values()):
1✔
480
            if thread.isRunning():
×
481
                thread.quit()
×
482
                thread.wait(1000)  # Wait up to 1 second
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc