mirror of
https://github.com/NVIDIA-NeMo/DataDesigner
synced 2026-05-24 09:48:29 +00:00
* fix: suppress stdout when saving report and sample records to file Console(record=True) still prints to stdout by default. Use file=io.StringIO() to redirect output so save-path calls only write to disk. * refactor: --save-results skips terminal display When --save-results is used, records and the analysis report are no longer printed to the terminal. Extracted save logic into a dedicated _save_preview_results method and updated option help text accordingly. * feat: wrap-around navigation in sample records browser Prev/next buttons and arrow keys now cycle back to the beginning/end instead of clamping at boundaries. * test: reuse record_series fixture in visualization tests * feat: thread --theme through to sample records pager The pager shell was hardcoded dark, so --theme light produced light records inside a dark frame. Extract CSS variables into dark/light constants and pass the theme from the controller. * fix: cap terminal display width at display_width The module-level Console() had no width limit, so tables with expand=True stretched to the full terminal width. Cap terminal output at min(terminal_width, display_width) and thread the display_width parameter through the controller's display methods. * docs: update --display-width and --theme help text Remove "Only applies when --save-results is used" from --display-width since it now also affects terminal output. * fix: update generation controller tests to match display_width and save_results behavior
774 lines
30 KiB
Python
774 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
|
|
from data_designer.config.utils.constants import DEFAULT_DISPLAY_WIDTH
|
|
|
|
_CTRL = "data_designer.cli.controllers.generation_controller"
|
|
_DW = DEFAULT_DISPLAY_WIDTH
|
|
|
|
|
|
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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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, display_width=_DW),
|
|
call(index=1, display_width=_DW),
|
|
call(index=2, display_width=_DW),
|
|
]
|
|
)
|
|
|
|
|
|
@patch(f"{_CTRL}.sys")
|
|
@patch(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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, display_width=_DW)
|
|
|
|
|
|
@patch(f"{_CTRL}.wait_for_navigation_key", side_effect=["n", "q"])
|
|
@patch(f"{_CTRL}.sys")
|
|
@patch(f"{_CTRL}.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(f"{_CTRL}.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 only for file save (not console) 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)
|
|
)
|
|
|
|
mock_analysis.to_report.assert_called_once()
|
|
assert mock_analysis.to_report.call_args.kwargs["save_path"].name == "report.html"
|
|
|
|
|
|
@patch(f"{_CTRL}.create_sample_records_pager")
|
|
@patch(f"{_CTRL}.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 saved to file only (no console display when save_results=True)
|
|
mock_analysis.to_report.assert_called_once()
|
|
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
|
|
|
|
assert mock_results.display_sample_record.call_count == 2
|
|
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(f"{_CTRL}.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,
|
|
)
|
|
|
|
mock_analysis.to_report.assert_called_once()
|
|
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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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, display_width=_DW),
|
|
call(index=1, display_width=_DW),
|
|
call(index=2, display_width=_DW),
|
|
]
|
|
)
|
|
|
|
|
|
@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, display_width=_DW)
|
|
|
|
|
|
@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, display_width=_DW),
|
|
call(index=1, display_width=_DW),
|
|
call(index=0, display_width=_DW),
|
|
]
|
|
)
|
|
|
|
|
|
@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, display_width=_DW),
|
|
call(index=2, display_width=_DW),
|
|
]
|
|
)
|
|
|
|
|
|
@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, display_width=_DW),
|
|
call(index=1, display_width=_DW),
|
|
call(index=2, display_width=_DW),
|
|
call(index=0, display_width=_DW),
|
|
]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _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, display_width=_DW),
|
|
call(index=1, display_width=_DW),
|
|
call(index=2, display_width=_DW),
|
|
]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_validate tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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(f"{_CTRL}.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()
|