DataDesigner/packages/data-designer/tests/cli/controllers/test_generation_controller.py
Johnny Greco f2a1657870
feat: add --save-results option to preview command (#333)
* feat: add --save-report option to preview command

* feat: add save_path option to display_sample_record

Allow saving rendered sample records as HTML or SVG files via an
optional save_path parameter on both the standalone function and
the WithRecordSamplerMixin method.

* feat: replace --save-report with --save-results on preview command

Replace the single-file --save-report option with --save-results, which saves all preview artifacts (dataset parquet, analysis report HTML, and per-record sample HTMLs) into a timestamped directory under the artifact path. Add error handling around the save block, improve timestamp precision to microseconds, and expand test coverage for the new behavior.

* feat: add sample records pager with theme toggle, postMessage bridge, and UI polish

* feat: add dataset metadata subtitle to pager and clean up toolbar layout

* fix: address review findings for preview save-results feature

- Split try/except in generation_controller so report display errors
  don't produce misleading "failed to save" messages when not saving
- Add browser HTML path to save success output for discoverability
- Remove 5 unused CSS variables from pager theme constants
- Add "N of M" record counter to pager toolbar
- Add theme/display_width assertions to all preview_command tests
- Add dedicated test for custom theme and display_width passthrough
- Add tests for record counter and CSS variable cleanup

* fix: address code review findings and simplify pager

- Fix critical bug: analysis report now displays to console even when
  --save-results is active (was silently dropped via pass statement)
- Fix latent UnboundLocalError in display_sample_record when index is
  out of bounds (num_records computed before try block)
- Eliminate duplicated dark CSS between constant and theme listener script
- Simplify sample_records_pager: remove dual-theme system, postMessage
  bridge, and responsive media queries; restore GitHub link; reorder
  toolbar to put prev/next buttons on the far left
- Narrow except Exception to except OSError in save-results path
- Use case-insensitive extension check and lambda-based re.sub
- Collapse redundant preview command delegation tests into parametrize
- Add missing type annotations and remove tautological assertions

* style: move record counter to far right of pager toolbar

* refactor: remove dead theme-listener script and inline CSS constant

_THEME_LISTENER_SCRIPT and _SAMPLE_RECORD_DARK_CSS_INLINE became
orphaned after the pager simplification removed the postMessage
bridge. This removes both constants, drops the injection line,
switches the idempotency guard to the viewport meta tag, and
cleans up related test assertions.

* fix: move Path import out of TYPE_CHECKING block in test_visualization

* fix: rename _logger to logger to match codebase convention

* fix: remove unnecessary cast in preview command theme parameter

* refactor: extract DEFAULT_DISPLAY_WIDTH constant and make apply_html_post_processing public

* Update packages/data-designer-config/tests/config/utils/test_visualization.py

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-18 15:58:35 -05:00

741 lines
30 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
import typer
from data_designer.cli.controllers.generation_controller import GenerationController
from data_designer.cli.utils.config_loader import ConfigLoadError
from data_designer.config.config_builder import DataDesignerConfigBuilder
from data_designer.config.errors import InvalidConfigError
_CTRL = "data_designer.cli.controllers.generation_controller"
def _make_mock_preview_results(num_records: int) -> MagicMock:
"""Create a mock PreviewResults with the given number of records."""
mock_results = MagicMock()
mock_results.dataset = MagicMock()
mock_results.dataset.__len__ = MagicMock(return_value=num_records)
return mock_results
def _make_mock_create_results(num_records: int, base_path: str = "/output/artifacts/dataset") -> MagicMock:
"""Create a mock CreateResults with the given number of records."""
mock_results = MagicMock()
mock_dataset = MagicMock()
mock_dataset.__len__ = MagicMock(return_value=num_records)
mock_results.load_dataset.return_value = mock_dataset
mock_results.artifact_storage.base_dataset_path = base_path
return mock_results
# ---------------------------------------------------------------------------
# run_preview tests
# ---------------------------------------------------------------------------
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_success(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test successful preview execution in non-interactive mode."""
mock_builder = MagicMock(spec=DataDesignerConfigBuilder)
mock_load_config.return_value = mock_builder
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.preview.return_value = _make_mock_preview_results(5)
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=5, non_interactive=True)
mock_load_config.assert_called_once_with("config.yaml")
mock_dd_cls.assert_called_once()
mock_dd.preview.assert_called_once_with(mock_builder, num_records=5)
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_custom_num_records(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test preview with a custom number of records."""
mock_builder = MagicMock(spec=DataDesignerConfigBuilder)
mock_load_config.return_value = mock_builder
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.preview.return_value = _make_mock_preview_results(20)
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=20, non_interactive=True)
mock_dd.preview.assert_called_once_with(mock_builder, num_records=20)
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_config_load_error(mock_load_config: MagicMock) -> None:
"""Test preview exits with code 1 when config fails to load."""
mock_load_config.side_effect = ConfigLoadError("File not found")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_preview(config_source="missing.yaml", num_records=10, non_interactive=True)
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_generation_fails(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test preview exits with code 1 when generation fails."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.preview.side_effect = RuntimeError("LLM connection failed")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_preview(config_source="config.yaml", num_records=10, non_interactive=True)
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_no_records_generated(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test preview exits with code 1 when dataset is None."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = MagicMock()
mock_results.dataset = None
mock_dd.preview.return_value = mock_results
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_preview(config_source="config.yaml", num_records=10, non_interactive=True)
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_empty_dataset(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test preview exits with code 1 when dataset is empty."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = MagicMock()
mock_results.dataset = MagicMock()
mock_results.dataset.__len__ = MagicMock(return_value=0)
mock_dd.preview.return_value = mock_results
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_preview(config_source="config.yaml", num_records=10, non_interactive=True)
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_non_interactive_displays_all(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test --non-interactive displays all records without interactive browsing."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(3)
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=3, non_interactive=True)
assert mock_results.display_sample_record.call_count == 3
mock_results.display_sample_record.assert_has_calls([call(index=0), call(index=1), call(index=2)])
@patch(f"{_CTRL}.sys")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_non_tty_stdin_falls_back_to_non_interactive(
mock_load_config: MagicMock,
mock_dd_cls: MagicMock,
mock_sys: MagicMock,
) -> None:
"""Test non-TTY stdin auto-detects and falls back to non-interactive mode."""
mock_sys.stdin.isatty.return_value = False
mock_sys.stdout.isatty.return_value = True
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(3)
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=3, non_interactive=False)
assert mock_results.display_sample_record.call_count == 3
@patch(f"{_CTRL}.sys")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_piped_stdout_falls_back_to_non_interactive(
mock_load_config: MagicMock,
mock_dd_cls: MagicMock,
mock_sys: MagicMock,
) -> None:
"""Test piped stdout (e.g. `preview cfg.yaml | head`) falls back to non-interactive."""
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = False
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(3)
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=3, non_interactive=False)
assert mock_results.display_sample_record.call_count == 3
@patch(f"{_CTRL}.sys")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_single_record_no_interactive(
mock_load_config: MagicMock,
mock_dd_cls: MagicMock,
mock_sys: MagicMock,
) -> None:
"""Test single record is displayed directly without interactive prompt."""
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = True
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(1)
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=1, non_interactive=False)
mock_results.display_sample_record.assert_called_once_with(index=0)
@patch(f"{_CTRL}.wait_for_navigation_key", side_effect=["n", "q"])
@patch(f"{_CTRL}.sys")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_tty_multiple_records_uses_interactive(
mock_load_config: MagicMock,
mock_dd_cls: MagicMock,
mock_sys: MagicMock,
mock_wait: MagicMock,
) -> None:
"""Test TTY with multiple records triggers interactive mode."""
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = True
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(3)
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=3, non_interactive=False)
assert mock_results.display_sample_record.call_count == 2
assert mock_wait.call_count == 2
@patch(f"{_CTRL}.create_sample_records_pager")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_calls_to_report_when_analysis_present(
mock_load_config: MagicMock, mock_dd_cls: MagicMock, mock_create_pager: MagicMock, tmp_path: Path
) -> None:
"""Test that to_report() is called for console display and file save when save_results=True."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(3)
mock_analysis = MagicMock()
mock_results.analysis = mock_analysis
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(
config_source="config.yaml", num_records=3, non_interactive=True, save_results=True, artifact_path=str(tmp_path)
)
assert mock_analysis.to_report.call_count == 2
mock_analysis.to_report.assert_any_call()
save_call = [c for c in mock_analysis.to_report.call_args_list if "save_path" in c.kwargs]
assert len(save_call) == 1
assert save_call[0].kwargs["save_path"].name == "report.html"
@patch(f"{_CTRL}.create_sample_records_pager")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_save_results_creates_directory_structure(
mock_load_config: MagicMock,
mock_dd_cls: MagicMock,
mock_create_pager: MagicMock,
tmp_path: Path,
) -> None:
"""Test --save-results saves dataset, report, sample records, and sample_records_browser.html."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(2)
mock_analysis = MagicMock()
mock_results.analysis = mock_analysis
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(
config_source="config.yaml",
num_records=2,
non_interactive=True,
save_results=True,
artifact_path=str(tmp_path),
)
# Report displayed to console (no args) and saved to file (with save_path)
assert mock_analysis.to_report.call_count == 2
mock_analysis.to_report.assert_any_call()
report_save_path = mock_analysis.to_report.call_args.kwargs["save_path"]
assert report_save_path.parent.parent == tmp_path
assert report_save_path.name == "report.html"
# Dataset saved as parquet
mock_results.dataset.to_parquet.assert_called_once()
parquet_path = mock_results.dataset.to_parquet.call_args[0][0]
assert parquet_path.name == "dataset.parquet"
assert parquet_path.parent == report_save_path.parent
# Sample records saved — 2 display calls + 2 save calls = 4 total
assert mock_results.display_sample_record.call_count == 4
sample_records_dir = report_save_path.parent / "sample_records"
for i in range(2):
mock_results.display_sample_record.assert_any_call(
index=i, save_path=sample_records_dir / f"record_{i}.html", theme="dark", display_width=110
)
# Sample records browser (pager) generated
pager_kwargs = mock_create_pager.call_args.kwargs
assert pager_kwargs["sample_records_dir"] == sample_records_dir
assert pager_kwargs["num_records"] == 2
assert "num_columns" in pager_kwargs
@patch(f"{_CTRL}.create_sample_records_pager")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_save_results_default_artifact_path(
mock_load_config: MagicMock, mock_dd_cls: MagicMock, mock_create_pager: MagicMock
) -> None:
"""Test --save-results with no artifact_path defaults to ./artifacts."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(1)
mock_analysis = MagicMock()
mock_results.analysis = mock_analysis
mock_dd.preview.return_value = mock_results
controller = GenerationController()
with patch.object(Path, "mkdir"):
controller.run_preview(
config_source="config.yaml",
num_records=1,
non_interactive=True,
save_results=True,
)
assert mock_analysis.to_report.call_count == 2
report_save_path = mock_analysis.to_report.call_args.kwargs["save_path"]
assert report_save_path.parent.parent == Path.cwd() / "artifacts"
mock_create_pager.assert_called_once()
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_skips_report_when_analysis_is_none(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test that to_report() is not called when analysis is None."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(3)
mock_results.analysis = None
mock_dd.preview.return_value = mock_results
controller = GenerationController()
# Implicit assertion: analysis is None (not a mock), so the code must not call
# None.to_report(). If it does, an AttributeError propagates and the test fails.
controller.run_preview(config_source="config.yaml", num_records=3, non_interactive=True)
@patch(f"{_CTRL}.create_sample_records_pager")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_save_results_without_analysis(
mock_load_config: MagicMock, mock_dd_cls: MagicMock, mock_create_pager: MagicMock, tmp_path: Path
) -> None:
"""Test --save-results saves dataset and sample records even when analysis is None."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(2)
mock_results.analysis = None
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(
config_source="config.yaml",
num_records=2,
non_interactive=True,
save_results=True,
artifact_path=str(tmp_path),
)
mock_results.dataset.to_parquet.assert_called_once()
save_path_calls = [c for c in mock_results.display_sample_record.call_args_list if "save_path" in c.kwargs]
assert len(save_path_calls) == 2
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_no_save_when_save_results_false(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test that dataset and sample records are not saved when save_results=False."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(3)
mock_dd.preview.return_value = mock_results
controller = GenerationController()
controller.run_preview(config_source="config.yaml", num_records=3, non_interactive=True)
mock_results.dataset.to_parquet.assert_not_called()
for c in mock_results.display_sample_record.call_args_list:
assert "save_path" not in c.kwargs
@patch(f"{_CTRL}.create_sample_records_pager")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_save_results_oserror_exits(
mock_load_config: MagicMock, mock_dd_cls: MagicMock, mock_create_pager: MagicMock, tmp_path: Path
) -> None:
"""Test --save-results exits with code 1 when an OSError occurs."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(2)
mock_results.analysis = None
mock_dd.preview.return_value = mock_results
mock_results.dataset.to_parquet.side_effect = OSError("Disk full")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_preview(
config_source="config.yaml",
num_records=2,
non_interactive=True,
save_results=True,
artifact_path=str(tmp_path),
)
assert exc_info.value.exit_code == 1
@patch(f"{_CTRL}.create_sample_records_pager")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_preview_save_results_non_oserror_propagates(
mock_load_config: MagicMock, mock_dd_cls: MagicMock, mock_create_pager: MagicMock, tmp_path: Path
) -> None:
"""Test --save-results lets non-OSError exceptions propagate."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_preview_results(2)
mock_results.analysis = None
mock_dd.preview.return_value = mock_results
mock_results.dataset.to_parquet.side_effect = ValueError("Unexpected error")
controller = GenerationController()
with pytest.raises(ValueError, match="Unexpected error"):
controller.run_preview(
config_source="config.yaml",
num_records=2,
non_interactive=True,
save_results=True,
artifact_path=str(tmp_path),
)
# ---------------------------------------------------------------------------
# _browse_records_interactively unit tests
# ---------------------------------------------------------------------------
@patch(f"{_CTRL}.wait_for_navigation_key", side_effect=["n", "n", "q"])
def test_browse_interactively_next_advances(mock_wait: MagicMock) -> None:
"""Test pressing n/enter advances to the next record."""
mock_results = _make_mock_preview_results(5)
controller = GenerationController()
controller._browse_records_interactively(mock_results, 5)
assert mock_results.display_sample_record.call_count == 3
mock_results.display_sample_record.assert_has_calls([call(index=0), call(index=1), call(index=2)])
@patch(f"{_CTRL}.wait_for_navigation_key", side_effect=["q"])
def test_browse_interactively_quit_immediately(mock_wait: MagicMock) -> None:
"""Test pressing 'q' quits after showing only the first record."""
mock_results = _make_mock_preview_results(5)
controller = GenerationController()
controller._browse_records_interactively(mock_results, 5)
mock_results.display_sample_record.assert_called_once_with(index=0)
@patch(f"{_CTRL}.wait_for_navigation_key", side_effect=["n", "p", "q"])
def test_browse_interactively_previous(mock_wait: MagicMock) -> None:
"""Test 'p' navigates to the previous record."""
mock_results = _make_mock_preview_results(5)
controller = GenerationController()
controller._browse_records_interactively(mock_results, 5)
assert mock_results.display_sample_record.call_count == 3
mock_results.display_sample_record.assert_has_calls([call(index=0), call(index=1), call(index=0)])
@patch(f"{_CTRL}.wait_for_navigation_key", side_effect=["p", "q"])
def test_browse_interactively_previous_wraps_to_last(mock_wait: MagicMock) -> None:
"""Test 'p' on the first record wraps to the last record."""
mock_results = _make_mock_preview_results(3)
controller = GenerationController()
controller._browse_records_interactively(mock_results, 3)
assert mock_results.display_sample_record.call_count == 2
mock_results.display_sample_record.assert_has_calls([call(index=0), call(index=2)])
@patch(f"{_CTRL}.wait_for_navigation_key", side_effect=["n", "n", "n", "q"])
def test_browse_interactively_next_wraps_past_last(mock_wait: MagicMock) -> None:
"""Test n past the last record wraps back to the first."""
mock_results = _make_mock_preview_results(3)
controller = GenerationController()
controller._browse_records_interactively(mock_results, 3)
assert mock_results.display_sample_record.call_count == 4
mock_results.display_sample_record.assert_has_calls([call(index=0), call(index=1), call(index=2), call(index=0)])
# ---------------------------------------------------------------------------
# _display_all_records unit test
# ---------------------------------------------------------------------------
def test_display_all_records() -> None:
"""Test _display_all_records displays every record."""
mock_results = _make_mock_preview_results(3)
controller = GenerationController()
controller._display_all_records(mock_results, 3)
assert mock_results.display_sample_record.call_count == 3
mock_results.display_sample_record.assert_has_calls([call(index=0), call(index=1), call(index=2)])
# ---------------------------------------------------------------------------
# run_validate tests
# ---------------------------------------------------------------------------
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_validate_success(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test successful validate execution."""
mock_builder = MagicMock(spec=DataDesignerConfigBuilder)
mock_load_config.return_value = mock_builder
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.validate.return_value = None
controller = GenerationController()
controller.run_validate(config_source="config.yaml")
mock_load_config.assert_called_once_with("config.yaml")
mock_dd_cls.assert_called_once()
mock_dd.validate.assert_called_once_with(mock_builder)
@patch(f"{_CTRL}.load_config_builder")
def test_run_validate_config_load_error(mock_load_config: MagicMock) -> None:
"""Test validate exits with code 1 when config fails to load."""
mock_load_config.side_effect = ConfigLoadError("File not found")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_validate(config_source="missing.yaml")
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_validate_invalid_config(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test validate exits with code 1 when config is invalid."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.validate.side_effect = InvalidConfigError("Missing required column")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_validate(config_source="config.yaml")
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_validate_generic_exception(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test validate exits with code 1 on unexpected errors."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.validate.side_effect = RuntimeError("Unexpected error")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_validate(config_source="config.yaml")
assert exc_info.value.exit_code == 1
# ---------------------------------------------------------------------------
# run_create tests
# ---------------------------------------------------------------------------
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_create_success(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test successful create execution with default artifact path."""
mock_builder = MagicMock(spec=DataDesignerConfigBuilder)
mock_load_config.return_value = mock_builder
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.create.return_value = _make_mock_create_results(10)
controller = GenerationController()
controller.run_create(config_source="config.yaml", num_records=10, dataset_name="dataset", artifact_path=None)
mock_load_config.assert_called_once_with("config.yaml")
mock_dd_cls.assert_called_once_with(artifact_path=Path.cwd() / "artifacts")
mock_dd.create.assert_called_once_with(mock_builder, num_records=10, dataset_name="dataset")
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_create_custom_options(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test create with custom --num-records, --dataset-name, and --artifact-path."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.create.return_value = _make_mock_create_results(100, "/custom/output/my_data")
controller = GenerationController()
controller.run_create(
config_source="config.py",
num_records=100,
dataset_name="my_data",
artifact_path="/custom/output",
)
mock_dd_cls.assert_called_once_with(artifact_path=Path("/custom/output"))
mock_dd.create.assert_called_once_with(mock_load_config.return_value, num_records=100, dataset_name="my_data")
@patch(f"{_CTRL}.load_config_builder")
def test_run_create_config_load_error(mock_load_config: MagicMock) -> None:
"""Test create exits with code 1 when config fails to load."""
mock_load_config.side_effect = ConfigLoadError("File not found")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_create(config_source="missing.yaml", num_records=10, dataset_name="dataset", artifact_path=None)
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_create_creation_fails(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test create exits with code 1 when dataset creation fails."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_dd.create.side_effect = RuntimeError("LLM connection failed")
controller = GenerationController()
with pytest.raises(typer.Exit) as exc_info:
controller.run_create(config_source="config.yaml", num_records=10, dataset_name="dataset", artifact_path=None)
assert exc_info.value.exit_code == 1
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_create_calls_to_report_when_analysis_present(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test that analysis.to_report() is called when load_analysis() returns a value."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_create_results(10)
mock_analysis = MagicMock()
mock_results.load_analysis.return_value = mock_analysis
mock_dd.create.return_value = mock_results
controller = GenerationController()
controller.run_create(config_source="config.yaml", num_records=10, dataset_name="dataset", artifact_path=None)
mock_results.load_analysis.assert_called_once()
mock_analysis.to_report.assert_called_once()
@patch("data_designer.interface.DataDesigner")
@patch(f"{_CTRL}.load_config_builder")
def test_run_create_skips_report_when_analysis_is_none(mock_load_config: MagicMock, mock_dd_cls: MagicMock) -> None:
"""Test that to_report() is not called when load_analysis() returns None."""
mock_load_config.return_value = MagicMock(spec=DataDesignerConfigBuilder)
mock_dd = MagicMock()
mock_dd_cls.return_value = mock_dd
mock_results = _make_mock_create_results(10)
mock_results.load_analysis.return_value = None
mock_dd.create.return_value = mock_results
controller = GenerationController()
controller.run_create(config_source="config.yaml", num_records=10, dataset_name="dataset", artifact_path=None)
# load_analysis() returns None, so to_report() must not be called.
# If the code ignores the None check, an AttributeError propagates and the test fails.
mock_results.load_analysis.assert_called_once()