"""Unit tests for experiment import functionality.""" import json import sys import types from pathlib import Path from typing import Dict, Any from unittest.mock import Mock, MagicMock, patch import pytest # Mock the problematic imports before importing # Mock the prompt import that's causing issues sys.modules["opik.api_objects.prompt.prompt"] = MagicMock() # Now we can import normally from opik.cli.imports.experiment import ( # noqa: E402 ExperimentData, load_experiment_data, recreate_experiment, _import_traces_for_project, ) from opik.cli.imports.utils import ( # noqa: E402 translate_trace_id as utils_translate_trace_id, sort_spans_topologically, ) class TestExperimentData: """Test ExperimentData dataclass.""" def test_experiment_data_from_dict(self) -> None: """Test creating ExperimentData from dictionary.""" data = { "experiment": { "id": "exp-123", "name": "test-experiment", "dataset_name": "test-dataset", }, "items": [ {"id": "item-1", "trace_id": "trace-1"}, {"id": "item-2", "trace_id": "trace-2"}, ], "downloaded_at": "2024-01-01T00:00:00", } exp_data = ExperimentData.from_dict(data) assert exp_data.experiment["id"] == "exp-123" assert exp_data.experiment["name"] == "test-experiment" assert len(exp_data.items) == 2 assert exp_data.downloaded_at == "2024-01-01T00:00:00" def test_experiment_data_from_dict_minimal(self) -> None: """Test creating ExperimentData with minimal data.""" data = { "experiment": {"id": "exp-123", "dataset_name": "test-dataset"}, "items": [], } exp_data = ExperimentData.from_dict(data) assert exp_data.experiment["id"] == "exp-123" assert exp_data.items == [] assert exp_data.downloaded_at is None def test_load_experiment_data_from_file(self, tmp_path: Path) -> None: """Test loading experiment data from JSON file.""" experiment_file = tmp_path / "experiment_test.json" data = { "experiment": { "id": "exp-123", "name": "test-experiment", "dataset_name": "test-dataset", }, "items": [{"id": "item-1"}], } with open(experiment_file, "w") as f: json.dump(data, f) exp_data = load_experiment_data(experiment_file) assert isinstance(exp_data, ExperimentData) assert exp_data.experiment["id"] == "exp-123" assert len(exp_data.items) == 1 class TestTranslateTraceId: """Test translate_trace_id function.""" def test_translate_trace_id_found(self) -> None: """Test translating trace ID when mapping exists.""" trace_id_map = {"old-trace-1": "new-trace-1", "old-trace-2": "new-trace-2"} result = utils_translate_trace_id("old-trace-1", trace_id_map) assert result == "new-trace-1" def test_translate_trace_id_not_found(self) -> None: """Test translating trace ID when mapping doesn't exist.""" trace_id_map = {"old-trace-1": "new-trace-1"} result = utils_translate_trace_id("old-trace-2", trace_id_map) assert result is None def test_translate_trace_id_empty_map(self) -> None: """Test translating trace ID with empty map.""" trace_id_map: Dict[str, str] = {} result = utils_translate_trace_id("old-trace-1", trace_id_map) assert result is None def test_translate_trace_id_requires_dict(self) -> None: """Test that translate_trace_id requires Dict, not Optional.""" # This test verifies the type signature is correct # If someone tries to pass None, type checker should catch it trace_id_map: Dict[str, str] = {} # Required, not Optional result = utils_translate_trace_id("trace-1", trace_id_map) assert result is None class TestRecreateExperiment: """Test recreate_experiment function.""" @staticmethod def _extract_items_arg_from_call_args(call_args: Any) -> Any: """Helper to extract the items argument from call_args. Handles both positional and keyword arguments. """ if hasattr(call_args, "args") and call_args.args: return call_args.args[0] if hasattr(call_args, "kwargs"): if "items" in call_args.kwargs: return call_args.kwargs["items"] for value in call_args.kwargs.values(): if isinstance(value, list) and len(value) > 0: return value return None @pytest.fixture def mock_client(self) -> Mock: """Create a mock Opik client.""" client = Mock() # Ensure flush returns True to indicate success client.flush = Mock(return_value=True) # Mock dataset mock_dataset = Mock() mock_dataset.name = "test-dataset" mock_dataset.__internal_api__insert_items_as_dataclasses__ = Mock() # Mock experiment mock_experiment = Mock() mock_experiment.insert = Mock() mock_experiment.id = "exp-123" client.get_or_create_dataset = Mock(return_value=mock_dataset) client.create_experiment = Mock(return_value=mock_experiment) # Mock REST client for experiment items creation client._rest_client = Mock() client._rest_client.experiments = Mock() client._rest_client.experiments.create_experiment_items = Mock() return client @pytest.fixture def experiment_data(self) -> ExperimentData: """Create sample experiment data.""" return ExperimentData( experiment={ "id": "exp-123", "name": "test-experiment", "dataset_name": "test-dataset", "type": "regular", }, items=[ { "trace_id": "trace-1", "dataset_item_id": "ds-item-1", "dataset_item_data": { "input": "test input", "expected_output": "test output", }, }, { "trace_id": "trace-2", "dataset_item_id": "ds-item-2", "dataset_item_data": {"input": "test input 2"}, }, ], ) def test_recreate_experiment_requires_trace_id_map( self, mock_client: Mock, experiment_data: ExperimentData ) -> None: """Test that recreate_experiment requires trace_id_map (not Optional).""" # This test verifies the type signature trace_id_map: Dict[str, str] = { "trace-1": "new-trace-1", "trace-2": "new-trace-2", } dataset_item_id_map: Dict[str, str] = { "ds-item-1": "new-ds-item-1", "ds-item-2": "new-ds-item-2", } # Should not accept None - type checker would catch this # We test that it works with a dict with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers: mock_id_helpers.generate_id = Mock(return_value="generated-id") recreate_experiment( mock_client, experiment_data, "test-project", trace_id_map, # Required, not Optional dataset_item_id_map, # Required for mapping dataset items dry_run=False, debug=False, ) # Verify experiment items were created via REST API assert mock_client._rest_client.experiments.create_experiment_items.called def test_recreate_experiment_batches_dataset_items( self, mock_client: Mock, experiment_data: ExperimentData ) -> None: """Test that experiment items are created in batch, not one at a time.""" with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers: mock_id_helpers.generate_id = Mock(side_effect=["exp-item-1", "exp-item-2"]) trace_id_map = {"trace-1": "new-trace-1", "trace-2": "new-trace-2"} dataset_item_id_map = { "ds-item-1": "new-ds-item-1", "ds-item-2": "new-ds-item-2", } recreate_experiment( mock_client, experiment_data, "test-project", trace_id_map, dataset_item_id_map, dry_run=False, debug=False, ) # Verify batch insert was called ONCE with all items assert ( mock_client._rest_client.experiments.create_experiment_items.call_count == 1 ) # Verify it was called with a list of items (batch) call_args = ( mock_client._rest_client.experiments.create_experiment_items.call_args ) assert call_args is not None # Extract experiment_items argument from call_args experiment_items_arg = None if hasattr(call_args, "kwargs") and "experiment_items" in call_args.kwargs: experiment_items_arg = call_args.kwargs["experiment_items"] elif hasattr(call_args, "args") and call_args.args: experiment_items_arg = call_args.args[0] assert experiment_items_arg is not None, ( f"Could not find experiment_items in call_args: {call_args}" ) assert len(experiment_items_arg) == 2, ( f"Expected 2 items in batch, got {len(experiment_items_arg)}" ) def test_recreate_experiment_uses_module_names_correctly( self, mock_client: Mock, experiment_data: ExperimentData ) -> None: """Test that module names (id_helpers_module) are used correctly.""" with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers: mock_id_helpers.generate_id = Mock(return_value="generated-id") trace_id_map = {"trace-1": "new-trace-1"} dataset_item_id_map = {"ds-item-1": "new-ds-item-1"} recreate_experiment( mock_client, experiment_data, "test-project", trace_id_map, dataset_item_id_map, dry_run=False, debug=False, ) # Verify id_helpers module is used (not checked for None) assert mock_id_helpers.generate_id.called def test_recreate_experiment_handles_empty_trace_id_map( self, mock_client: Mock, experiment_data: ExperimentData ) -> None: """Test that empty trace_id_map is handled correctly.""" trace_id_map: Dict[str, str] = {} # Empty but valid dataset_item_id_map: Dict[str, str] = {} # Empty but valid recreate_experiment( mock_client, experiment_data, "test-project", trace_id_map, dataset_item_id_map, dry_run=False, debug=False, ) # Should still create experiment and dataset, but skip items assert mock_client.get_or_create_dataset.called assert mock_client.create_experiment.called def test_recreate_experiment_dry_run( self, mock_client: Mock, experiment_data: ExperimentData ) -> None: """Test dry run mode.""" trace_id_map = {"trace-1": "new-trace-1"} dataset_item_id_map = {"ds-item-1": "new-ds-item-1"} result = recreate_experiment( mock_client, experiment_data, "test-project", trace_id_map, dataset_item_id_map, dry_run=True, debug=False, ) assert result is True # Should not create anything in dry run assert not mock_client.get_or_create_dataset.called assert not mock_client.create_experiment.called def test_recreate_experiment_chunks_items_within_be_cap( self, mock_client: Mock ) -> None: # BE rejects a single ``create_experiment_items`` POST whose item # count exceeds ``ExperimentItemsBatch``'s ``@Size(max=…)``. The # actual cap value is BE-configured and may change over time, so # we read it from the module-level constant the chunker uses and # assert behavior against that, not against a hardcoded literal. # Contract: every batch ``<= cap``, total across batches == # full item count, multiple batches when items > cap. from opik.cli.imports.experiment import _EXPERIMENT_ITEMS_INSERT_BATCH_SIZE cap = _EXPERIMENT_ITEMS_INSERT_BATCH_SIZE # 2.5 × cap exercises the multi-batch path with a partial-last- # batch remainder; any value above ``cap`` works for the contract. n_items = cap * 5 // 2 experiment_data = ExperimentData( experiment={ "id": "exp-big", "name": "big-experiment", "dataset_name": "test-dataset", "type": "regular", }, items=[ { "trace_id": f"trace-{i}", "dataset_item_id": f"ds-item-{i}", "dataset_item_data": {"input": f"test input {i}"}, } for i in range(n_items) ], ) trace_id_map = {f"trace-{i}": f"new-trace-{i}" for i in range(n_items)} dataset_item_id_map = { f"ds-item-{i}": f"new-ds-item-{i}" for i in range(n_items) } with patch("opik.cli.imports.experiment.id_helpers_module") as mock_id_helpers: mock_id_helpers.generate_id = Mock( side_effect=[f"exp-item-{i}" for i in range(n_items)] ) recreate_experiment( mock_client, experiment_data, "test-project", trace_id_map, dataset_item_id_map, dry_run=False, debug=False, ) create_calls = mock_client._rest_client.experiments.create_experiment_items.call_args_list # ceil(n_items / cap) batched POSTs. expected_batches = (n_items + cap - 1) // cap assert len(create_calls) == expected_batches, ( f"expected {expected_batches} chunked create_experiment_items " f"calls for {n_items} items at cap={cap}, got {len(create_calls)}" ) # Every batch must respect the BE cap; total must equal the # full item count (nothing dropped, nothing duplicated). batch_sizes = [ len(call.kwargs.get("experiment_items", [])) if call.kwargs.get("experiment_items") is not None else len(call.args[0]) for call in create_calls ] assert all(size <= cap for size in batch_sizes), ( f"every batch must respect the BE cap (={cap}), got {batch_sizes}" ) assert sum(batch_sizes) == n_items, ( f"all {n_items} items must end up across the chunked batches, " f"got sum={sum(batch_sizes)}" ) class TestImportTracesWithSpans: """Test trace import with span parent_span_id preservation.""" @pytest.fixture def mock_client(self) -> Mock: """Create a mock Opik client.""" client = Mock() client.flush = Mock() # Mock trace creation mock_trace = Mock() mock_trace.id = "new-trace-1" client.trace = Mock(return_value=mock_trace) # Mock span creation mock_spans = [] for i in range(3): mock_span = Mock() mock_span.id = f"new-span-{i + 1}" mock_spans.append(mock_span) client.span = Mock(side_effect=mock_spans) return client def test_import_traces_preserves_span_hierarchy( self, mock_client: Mock, tmp_path: Path ) -> None: """Test that span parent_span_id relationships are preserved.""" # Create test trace file with spans projects_dir = tmp_path / "projects" / "test-project" projects_dir.mkdir(parents=True) trace_data = { "trace": { "id": "original-trace-1", "name": "test-trace", "input": {}, "output": {}, }, "spans": [ { "id": "span-1", "name": "root-span", "parent_span_id": None, # Root span "input": {}, "output": {}, }, { "id": "span-2", "name": "child-span", "parent_span_id": "span-1", # Child of span-1 "input": {}, "output": {}, }, { "id": "span-3", "name": "grandchild-span", "parent_span_id": "span-2", # Child of span-2 "input": {}, "output": {}, }, ], } trace_file = projects_dir / "trace_original-trace-1.json" with open(trace_file, "w") as f: json.dump(trace_data, f) # Import traces trace_id_map, _ = _import_traces_for_project( mock_client, projects_dir, "test-project", dry_run=False, debug=False ) # Verify spans were created assert mock_client.span.call_count == 3 # Verify spans were created in correct order (root first, then children) span_calls = mock_client.span.call_args_list # First span should be root (no parent_span_id) first_call = span_calls[0] assert first_call.kwargs.get("parent_span_id") is None # Second span should have parent_span_id set to first span's new ID # Note: We can't easily verify the exact ID mapping without more complex mocking, # but we can verify that parent_span_id is being passed second_call = span_calls[1] # The parent_span_id should be set (not None) since span-1 was created first # and its new ID should be in span_id_map assert "parent_span_id" in second_call.kwargs # Third span (grandchild) should have parent_span_id set to second span's new ID third_call = span_calls[2] assert "parent_span_id" in third_call.kwargs assert third_call.kwargs.get("parent_span_id") is not None # Verify trace was created assert mock_client.trace.called assert "original-trace-1" in trace_id_map def test_import_traces_preserves_deep_hierarchy( self, mock_client: Mock, tmp_path: Path ) -> None: """Test that deep hierarchies (4+ levels) are preserved correctly.""" projects_dir = tmp_path / "projects" / "test-project" projects_dir.mkdir(parents=True) # Create a 4-level hierarchy: root -> child -> grandchild -> great-grandchild # Spans are intentionally in wrong order to test sorting trace_data = { "trace": { "id": "original-trace-1", "name": "test-trace", "input": {}, "output": {}, }, "spans": [ { "id": "span-4", "name": "great-grandchild", "parent_span_id": "span-3", "input": {}, "output": {}, }, { "id": "span-2", "name": "child", "parent_span_id": "span-1", "input": {}, "output": {}, }, { "id": "span-1", "name": "root", "parent_span_id": None, "input": {}, "output": {}, }, { "id": "span-3", "name": "grandchild", "parent_span_id": "span-2", "input": {}, "output": {}, }, ], } trace_file = projects_dir / "trace_original-trace-1.json" with open(trace_file, "w") as f: json.dump(trace_data, f) # Import traces trace_id_map, _ = _import_traces_for_project( mock_client, projects_dir, "test-project", dry_run=False, debug=False ) # Verify all spans were created assert mock_client.span.call_count == 4 span_calls = mock_client.span.call_args_list # Verify order: root -> child -> grandchild -> great-grandchild # First span should be root (no parent) assert span_calls[0].kwargs.get("parent_span_id") is None # Second span should be child (has parent) assert span_calls[1].kwargs.get("parent_span_id") is not None # Third span should be grandchild (has parent) assert span_calls[2].kwargs.get("parent_span_id") is not None # Fourth span should be great-grandchild (has parent) assert span_calls[3].kwargs.get("parent_span_id") is not None # Verify trace was created assert mock_client.trace.called assert "original-trace-1" in trace_id_map def test_import_traces_sorts_spans_correctly( self, mock_client: Mock, tmp_path: Path ) -> None: """Test that spans are sorted (root spans first, then children).""" projects_dir = tmp_path / "projects" / "test-project" projects_dir.mkdir(parents=True) trace_data = { "trace": { "id": "original-trace-1", "name": "test-trace", "input": {}, "output": {}, }, "spans": [ { "id": "span-2", "name": "child-span", "parent_span_id": "span-1", # Child - should come after root "input": {}, "output": {}, }, { "id": "span-1", "name": "root-span", "parent_span_id": None, # Root - should come first "input": {}, "output": {}, }, ], } trace_file = projects_dir / "trace_original-trace-1.json" with open(trace_file, "w") as f: json.dump(trace_data, f) # Import traces _, _ = _import_traces_for_project( mock_client, projects_dir, "test-project", dry_run=False, debug=False ) # Returns (trace_id_map, stats), but we don't need them for this test # Verify spans were created in correct order span_calls = mock_client.span.call_args_list # First span should be root (no parent) assert span_calls[0].kwargs.get("parent_span_id") is None # Second span should have parent_span_id assert span_calls[1].kwargs.get("parent_span_id") is not None class TestTopologicalSort: """Test the topological sort function for spans.""" def test_sort_spans_simple_hierarchy(self) -> None: """Test sorting with a simple 2-level hierarchy.""" spans = [ {"id": "span-2", "name": "child", "parent_span_id": "span-1"}, {"id": "span-1", "name": "root", "parent_span_id": None}, ] sorted_spans = sort_spans_topologically(spans) # Root should come first assert sorted_spans[0]["id"] == "span-1" assert sorted_spans[0]["parent_span_id"] is None # Child should come second assert sorted_spans[1]["id"] == "span-2" assert sorted_spans[1]["parent_span_id"] == "span-1" def test_sort_spans_multi_level_hierarchy(self) -> None: """Test sorting with a 4-level hierarchy (root -> child -> grandchild -> great-grandchild).""" spans = [ {"id": "span-4", "name": "great-grandchild", "parent_span_id": "span-3"}, {"id": "span-2", "name": "child", "parent_span_id": "span-1"}, {"id": "span-1", "name": "root", "parent_span_id": None}, {"id": "span-3", "name": "grandchild", "parent_span_id": "span-2"}, ] sorted_spans = sort_spans_topologically(spans) # Verify order: root -> child -> grandchild -> great-grandchild assert sorted_spans[0]["id"] == "span-1" assert sorted_spans[0]["parent_span_id"] is None assert sorted_spans[1]["id"] == "span-2" assert sorted_spans[1]["parent_span_id"] == "span-1" assert sorted_spans[2]["id"] == "span-3" assert sorted_spans[2]["parent_span_id"] == "span-2" assert sorted_spans[3]["id"] == "span-4" assert sorted_spans[3]["parent_span_id"] == "span-3" def test_sort_spans_multiple_roots(self) -> None: """Test sorting with multiple root spans.""" spans = [ {"id": "span-3", "name": "child-of-2", "parent_span_id": "span-2"}, {"id": "span-1", "name": "root-1", "parent_span_id": None}, {"id": "span-2", "name": "root-2", "parent_span_id": None}, ] sorted_spans = sort_spans_topologically(spans) # Both roots should come before the child root_ids = {sorted_spans[0]["id"], sorted_spans[1]["id"]} assert root_ids == {"span-1", "span-2"} assert sorted_spans[0]["parent_span_id"] is None assert sorted_spans[1]["parent_span_id"] is None # Child should come last assert sorted_spans[2]["id"] == "span-3" assert sorted_spans[2]["parent_span_id"] == "span-2" def test_sort_spans_multiple_children(self) -> None: """Test sorting with a root that has multiple children.""" spans = [ {"id": "span-3", "name": "child-2", "parent_span_id": "span-1"}, {"id": "span-1", "name": "root", "parent_span_id": None}, {"id": "span-2", "name": "child-1", "parent_span_id": "span-1"}, ] sorted_spans = sort_spans_topologically(spans) # Root should come first assert sorted_spans[0]["id"] == "span-1" assert sorted_spans[0]["parent_span_id"] is None # Both children should come after root (order doesn't matter for siblings) child_ids = {sorted_spans[1]["id"], sorted_spans[2]["id"]} assert child_ids == {"span-2", "span-3"} assert sorted_spans[1]["parent_span_id"] == "span-1" assert sorted_spans[2]["parent_span_id"] == "span-1" def test_sort_spans_missing_parent(self) -> None: """Test sorting when a span references a non-existent parent.""" spans = [ {"id": "span-1", "name": "root", "parent_span_id": None}, {"id": "span-2", "name": "orphan", "parent_span_id": "nonexistent"}, ] sorted_spans = sort_spans_topologically(spans) # Both should be treated as roots (orphan becomes root) assert len(sorted_spans) == 2 # Both should have no parent or invalid parent for span in sorted_spans: assert ( span["parent_span_id"] is None or span["parent_span_id"] == "nonexistent" ) def test_sort_spans_empty_list(self) -> None: """Test sorting with empty list.""" spans: list = [] sorted_spans = sort_spans_topologically(spans) assert sorted_spans == [] def test_sort_spans_single_root(self) -> None: """Test sorting with single root span.""" spans = [{"id": "span-1", "name": "root", "parent_span_id": None}] sorted_spans = sort_spans_topologically(spans) assert len(sorted_spans) == 1 assert sorted_spans[0]["id"] == "span-1" assert sorted_spans[0]["parent_span_id"] is None def test_sort_spans_all_have_parents(self) -> None: """Test sorting when all spans have parents (no explicit root). This tests the fix for the bug where empty root_spans would cause the function to return an empty list, silently dropping all spans. """ spans = [ {"id": "span-1", "name": "child-1", "parent_span_id": "span-2"}, {"id": "span-2", "name": "child-2", "parent_span_id": "span-1"}, ] sorted_spans = sort_spans_topologically(spans) # Should not return empty list - all spans should be included assert len(sorted_spans) == 2 # Verify all spans are present span_ids = {span["id"] for span in sorted_spans} assert span_ids == {"span-1", "span-2"} def test_sort_spans_cycle(self) -> None: """Test sorting with a cycle in the span graph. This tests that cycles don't cause infinite loops and all spans are still included in the result. """ spans = [ {"id": "span-1", "name": "span-1", "parent_span_id": "span-2"}, {"id": "span-2", "name": "span-2", "parent_span_id": "span-3"}, {"id": "span-3", "name": "span-3", "parent_span_id": "span-1"}, ] sorted_spans = sort_spans_topologically(spans) # Should not return empty list - all spans should be included assert len(sorted_spans) == 3 # Verify all spans are present span_ids = {span["id"] for span in sorted_spans} assert span_ids == {"span-1", "span-2", "span-3"} def test_sort_spans_disconnected_components(self) -> None: """Test sorting with disconnected components (multiple separate graphs). This tests that spans not reachable from root spans are still included. """ spans = [ {"id": "span-1", "name": "root-1", "parent_span_id": None}, {"id": "span-2", "name": "child-1", "parent_span_id": "span-1"}, {"id": "span-3", "name": "disconnected-1", "parent_span_id": "span-4"}, {"id": "span-4", "name": "disconnected-2", "parent_span_id": "span-3"}, ] sorted_spans = sort_spans_topologically(spans) # All spans should be included assert len(sorted_spans) == 4 # Verify all spans are present span_ids = {span["id"] for span in sorted_spans} assert span_ids == {"span-1", "span-2", "span-3", "span-4"} # Root span should come first assert sorted_spans[0]["id"] == "span-1" assert sorted_spans[0]["parent_span_id"] is None class TestModuleNameUsage: """Test that module names are used correctly (not checked for None).""" def test_module_names_are_modules_not_variables(self) -> None: """Test that dataset_item_module and id_helpers_module are modules.""" from opik.cli.imports.experiment import dataset_item_module, id_helpers_module # Modules should exist and be importable assert dataset_item_module is not None assert id_helpers_module is not None # They should be modules, not None assert isinstance(dataset_item_module, types.ModuleType) assert isinstance(id_helpers_module, types.ModuleType) class TestProjectTraceImport: """Test importing a project's traces from its project directory.""" @pytest.fixture def mock_client(self) -> Mock: """Create a minimal mock Opik client.""" client = Mock() client.flush = Mock() mock_trace = Mock() mock_trace.id = "new-trace-id" client.trace = Mock(return_value=mock_trace) client.span = Mock() return client def test_trace_files_in_project_dir_are_imported( self, mock_client: Mock, tmp_path: Path ) -> None: """trace_{id}.json files directly under the project dir are imported and appear in the returned trace_id_map, and the trace is created in the named project.""" project_dir = tmp_path / "projects" / "my-project" project_dir.mkdir(parents=True) trace_id = "abc123" trace_data: Dict[str, Any] = { "trace": {"id": trace_id, "name": "t", "input": {}, "output": {}}, "spans": [], } trace_file = project_dir / f"trace_{trace_id}.json" with open(trace_file, "w") as f: json.dump(trace_data, f) trace_id_map, _ = _import_traces_for_project( mock_client, project_dir, "my-project", dry_run=False, debug=False ) # The original trace ID must appear in the returned map assert trace_id in trace_id_map # The trace must be created in the named project (no "default" fallback) assert mock_client.trace.call_args.kwargs.get("project_name") == "my-project" def test_empty_project_dir_returns_empty_map( self, mock_client: Mock, tmp_path: Path ) -> None: """A project dir with no trace files yields an empty map without error.""" project_dir = tmp_path / "projects" / "empty-project" project_dir.mkdir(parents=True) trace_id_map, stats = _import_traces_for_project( mock_client, project_dir, "empty-project", dry_run=False, debug=False ) assert trace_id_map == {} assert stats["traces"] == 0 assert not mock_client.trace.called