Skip to content

Commit fcd475c

Browse files
committed
Add in-process unit tests for foundry and ingestion CLIs
Foundry config/orchestrator/CLI and the ingestion CLI were only exercised by the subprocess end-to-end test, so coverage was not attributed to their packages. Add in-process tests covering config loading/validation, the full pipeline run with artifact assertions, and CLI dry-run/full-run paths. Foundry coverage 50% -> 90%, ingestion 76% -> 81%.
1 parent 85a5002 commit fcd475c

5 files changed

Lines changed: 386 additions & 0 deletions

File tree

tests/test_foundry_cli.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from llm_dataset_foundry.cli import main as cli
8+
9+
EXAMPLES = Path(__file__).resolve().parent.parent / "examples"
10+
11+
12+
def _write_config(tmp_path: Path) -> Path:
13+
docs = (EXAMPLES / "normalized-documents" / "documents.jsonl").as_posix()
14+
interactions = (EXAMPLES / "traces" / "interactions.jsonl").as_posix()
15+
retrieval = (EXAMPLES / "traces" / "retrieval.jsonl").as_posix()
16+
out = tmp_path / "out"
17+
config_path = tmp_path / "foundry.yaml"
18+
config_path.write_text(
19+
f"""
20+
ingest:
21+
normalized_documents_file: {docs}
22+
interaction_events_file: {interactions}
23+
retrieval_events_file: {retrieval}
24+
dataset:
25+
dataset_name: example-dataset
26+
dataset_id: example-ds
27+
dataset_version: v1
28+
quality:
29+
dedup_enabled: true
30+
min_text_length: 5
31+
splits:
32+
seed: 42
33+
train_ratio: 0.8
34+
validation_ratio: 0.1
35+
test_ratio: 0.1
36+
output:
37+
curated_dataset_path: {(out / "curated").as_posix()}
38+
reports_path: {(out / "reports").as_posix()}
39+
manifests_path: {(out / "manifests").as_posix()}
40+
""",
41+
encoding="utf-8",
42+
)
43+
return config_path
44+
45+
46+
def test_build_parser_defaults() -> None:
47+
args = cli.build_parser().parse_args([])
48+
assert args.config == "configs/foundry.yaml"
49+
assert args.dry_run is False
50+
51+
52+
def test_cli_dry_run_validates_without_writing(
53+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
54+
) -> None:
55+
config_path = _write_config(tmp_path)
56+
monkeypatch.setattr("sys.argv", ["prog", "--config", str(config_path), "--dry-run"])
57+
58+
assert cli.main() == 0
59+
out = capsys.readouterr().out
60+
assert "Dry run successful" in out
61+
assert "example-ds@v1" in out
62+
# Dry run must not produce any artifacts.
63+
assert not (tmp_path / "out").exists()
64+
65+
66+
def test_cli_full_run_builds_dataset(
67+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
68+
) -> None:
69+
config_path = _write_config(tmp_path)
70+
monkeypatch.setattr("sys.argv", ["prog", "--config", str(config_path)])
71+
72+
assert cli.main() == 0
73+
out = capsys.readouterr().out
74+
assert "Dataset build completed" in out
75+
assert (tmp_path / "out" / "curated" / "prompt_response.jsonl").exists()

tests/test_foundry_config.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from llm_dataset_foundry.pipeline.config import (
8+
DatasetSettings,
9+
IngestSettings,
10+
QualitySettings,
11+
SplitSettings,
12+
load_config,
13+
)
14+
15+
VALID_CONFIG = """
16+
ingest:
17+
normalized_documents_file: docs.jsonl
18+
interaction_events_file: interactions.jsonl
19+
retrieval_events_file: retrieval.jsonl
20+
dataset:
21+
dataset_name: example-dataset
22+
dataset_id: example-ds
23+
dataset_version: v1
24+
quality:
25+
dedup_enabled: true
26+
min_text_length: 5
27+
splits:
28+
seed: 7
29+
train_ratio: 0.8
30+
validation_ratio: 0.1
31+
test_ratio: 0.1
32+
output:
33+
curated_dataset_path: out/curated
34+
reports_path: out/reports
35+
manifests_path: out/manifests
36+
"""
37+
38+
39+
def _write(tmp_path: Path, text: str) -> Path:
40+
config_path = tmp_path / "foundry.yaml"
41+
config_path.write_text(text, encoding="utf-8")
42+
return config_path
43+
44+
45+
def test_load_config_resolves_relative_paths_and_defaults(tmp_path: Path) -> None:
46+
config = load_config(_write(tmp_path, VALID_CONFIG))
47+
48+
# Relative paths resolve against the config file's directory.
49+
assert config.ingest.normalized_documents_file == tmp_path / "docs.jsonl"
50+
assert config.output.curated_dataset_path == tmp_path / "out" / "curated"
51+
# Unspecified keys fall back to documented defaults.
52+
assert config.ingest.max_records == 10000
53+
assert config.dataset.schema_version == "1.0"
54+
assert config.dataset.model_version == "unknown-model"
55+
56+
57+
def test_load_config_keeps_absolute_paths(tmp_path: Path) -> None:
58+
absolute = (tmp_path / "elsewhere" / "docs.jsonl").as_posix()
59+
text = VALID_CONFIG.replace("normalized_documents_file: docs.jsonl", f"normalized_documents_file: {absolute}")
60+
config = load_config(_write(tmp_path, text))
61+
assert config.ingest.normalized_documents_file == Path(absolute)
62+
63+
64+
def test_load_config_rejects_non_mapping_payload(tmp_path: Path) -> None:
65+
with pytest.raises(ValueError, match="must be a mapping"):
66+
load_config(_write(tmp_path, "- just\n- a\n- list\n"))
67+
68+
69+
def test_load_config_rejects_non_mapping_section(tmp_path: Path) -> None:
70+
text = VALID_CONFIG.replace("quality:\n dedup_enabled: true\n min_text_length: 5", "quality: not-a-mapping")
71+
with pytest.raises(ValueError, match="Config key 'quality' must be a mapping"):
72+
load_config(_write(tmp_path, text))
73+
74+
75+
def test_ingest_settings_rejects_non_positive_max_records(tmp_path: Path) -> None:
76+
with pytest.raises(ValueError, match="max_records must be > 0"):
77+
IngestSettings(
78+
normalized_documents_file=tmp_path / "a",
79+
interaction_events_file=tmp_path / "b",
80+
retrieval_events_file=tmp_path / "c",
81+
max_records=0,
82+
)
83+
84+
85+
def test_dataset_settings_rejects_empty_name() -> None:
86+
with pytest.raises(ValueError, match="dataset_name must not be empty"):
87+
DatasetSettings(
88+
dataset_name=" ",
89+
dataset_id="id",
90+
dataset_version="v1",
91+
schema_version="1.0",
92+
model_version="m",
93+
)
94+
95+
96+
def test_quality_settings_rejects_non_positive_min_length() -> None:
97+
with pytest.raises(ValueError, match="min_text_length must be > 0"):
98+
QualitySettings(dedup_enabled=True, min_text_length=0)
99+
100+
101+
def test_split_settings_rejects_ratios_not_summing_to_one() -> None:
102+
with pytest.raises(ValueError, match="must sum to 1.0"):
103+
SplitSettings(seed=1, train_ratio=0.5, validation_ratio=0.1, test_ratio=0.1)
104+
105+
106+
def test_split_settings_rejects_non_positive_ratio() -> None:
107+
with pytest.raises(ValueError, match="must be > 0"):
108+
SplitSettings(seed=1, train_ratio=0.9, validation_ratio=0.1, test_ratio=0.0)

tests/test_foundry_pipeline.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
from llm_dataset_foundry.pipeline.config import load_config
7+
from llm_dataset_foundry.pipeline.orchestrator import DatasetFoundryPipeline
8+
9+
EXAMPLES = Path(__file__).resolve().parent.parent / "examples"
10+
11+
12+
def _config_text(out: Path) -> str:
13+
docs = (EXAMPLES / "normalized-documents" / "documents.jsonl").as_posix()
14+
interactions = (EXAMPLES / "traces" / "interactions.jsonl").as_posix()
15+
retrieval = (EXAMPLES / "traces" / "retrieval.jsonl").as_posix()
16+
return f"""
17+
ingest:
18+
normalized_documents_file: {docs}
19+
interaction_events_file: {interactions}
20+
retrieval_events_file: {retrieval}
21+
max_records: 1000
22+
dataset:
23+
dataset_name: example-dataset
24+
dataset_id: example-ds
25+
dataset_version: v1
26+
schema_version: "1.0"
27+
model_version: example-model-v1
28+
quality:
29+
dedup_enabled: true
30+
min_text_length: 5
31+
splits:
32+
seed: 42
33+
train_ratio: 0.8
34+
validation_ratio: 0.1
35+
test_ratio: 0.1
36+
output:
37+
curated_dataset_path: {(out / "curated").as_posix()}
38+
reports_path: {(out / "reports").as_posix()}
39+
manifests_path: {(out / "manifests").as_posix()}
40+
"""
41+
42+
43+
def _run(tmp_path: Path):
44+
config_path = tmp_path / "foundry.yaml"
45+
config_path.write_text(_config_text(tmp_path / "out"), encoding="utf-8")
46+
config = load_config(config_path)
47+
result = DatasetFoundryPipeline(config=config).run()
48+
return config, result
49+
50+
51+
def test_pipeline_run_loads_example_data(tmp_path: Path) -> None:
52+
_, result = _run(tmp_path)
53+
assert result.documents_loaded > 0
54+
assert result.interaction_traces_loaded > 0
55+
assert result.retrieval_traces_loaded > 0
56+
assert result.prompt_response_records > 0
57+
assert result.retrieval_eval_records > 0
58+
59+
60+
def test_pipeline_writes_all_artifacts(tmp_path: Path) -> None:
61+
config, _ = _run(tmp_path)
62+
curated = config.output.curated_dataset_path
63+
reports = config.output.reports_path
64+
manifests = config.output.manifests_path
65+
66+
for name in ("prompt_response.jsonl", "retrieval_evaluation.jsonl", "split_assignments.jsonl"):
67+
assert (curated / name).exists(), name
68+
assert (reports / "quality_report.json").exists()
69+
assert (manifests / "dataset_manifest.json").exists()
70+
assert (manifests / "dataset_version_metadata.json").exists()
71+
72+
73+
def test_manifest_is_valid_json_with_expected_fields(tmp_path: Path) -> None:
74+
config, result = _run(tmp_path)
75+
manifest = json.loads((config.output.manifests_path / "dataset_manifest.json").read_text(encoding="utf-8"))
76+
assert manifest["dataset_id"] == "example-ds"
77+
assert manifest["dataset_version"] == "v1"
78+
assert manifest["model_version"] == "example-model-v1"
79+
counts = manifest["record_counts"]
80+
assert counts["prompt_response"] == result.prompt_response_records
81+
assert counts["retrieval_evaluation"] == result.retrieval_eval_records
82+
# Split buckets always present, and sum to the total record count.
83+
assert counts["train"] + counts["validation"] + counts["test"] == (
84+
result.prompt_response_records + result.retrieval_eval_records
85+
)
86+
87+
88+
def test_split_assignments_cover_every_record(tmp_path: Path) -> None:
89+
config, result = _run(tmp_path)
90+
lines = (
91+
(config.output.curated_dataset_path / "split_assignments.jsonl")
92+
.read_text(encoding="utf-8")
93+
.splitlines()
94+
)
95+
assert len(lines) == result.prompt_response_records + result.retrieval_eval_records
96+
for line in lines:
97+
row = json.loads(line)
98+
assert row["split"] in {"train", "validation", "test"}
99+
assert row["record_id"]

tests/test_foundry_surface.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Smoke coverage for the foundry public surface: re-export modules, the
2+
lightweight value objects, and the source-adapter interface contract."""
3+
4+
from __future__ import annotations
5+
6+
from llm_dataset_foundry.contracts import entities
7+
from llm_dataset_foundry.contracts.models import DatasetManifest
8+
from llm_dataset_foundry.ingest.interfaces import SourceAdapter
9+
from llm_dataset_foundry.quality.models import QualityCheckResult
10+
from llm_dataset_foundry.reports.specs import ReportSpec
11+
from llm_dataset_foundry.versioning import manifest
12+
13+
14+
def test_reexport_modules_expose_expected_names() -> None:
15+
assert "PromptResponseTrainingExample" in entities.__all__
16+
assert manifest.DatasetManifest is DatasetManifest
17+
18+
19+
def test_quality_check_result_holds_fields() -> None:
20+
result = QualityCheckResult(check_name="min_length", passed=False, message="too short")
21+
assert result.check_name == "min_length"
22+
assert result.passed is False
23+
assert result.message == "too short"
24+
25+
26+
def test_report_spec_holds_fields() -> None:
27+
spec = ReportSpec(report_name="quality", generated_for_version="v1")
28+
assert spec.report_name == "quality"
29+
assert spec.generated_for_version == "v1"
30+
31+
32+
def test_source_adapter_subclass_implements_read() -> None:
33+
class StaticAdapter(SourceAdapter):
34+
def read(self) -> list[dict[str, object]]:
35+
return [{"document_id": "doc-1"}]
36+
37+
assert StaticAdapter().read() == [{"document_id": "doc-1"}]

tests/test_ingestion_cli.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from llm_knowledge_ingestion.cli import main as cli
8+
9+
10+
def _write_config(tmp_path: Path) -> Path:
11+
input_dir = tmp_path / "input"
12+
input_dir.mkdir()
13+
(input_dir / "doc1.txt").write_text("A short sample document for ingestion.", encoding="utf-8")
14+
15+
out = tmp_path / "out"
16+
config_path = tmp_path / "ingestion.yaml"
17+
config_path.write_text(
18+
f"""
19+
ingestion:
20+
source_id: test-source
21+
input_path: {input_dir.as_posix()}
22+
max_documents: 5
23+
chunking:
24+
strategy: fixed_tokens
25+
target_tokens: 5
26+
overlap_tokens: 1
27+
output:
28+
normalized_documents_path: {(out / "documents").as_posix()}
29+
chunks_path: {(out / "chunks").as_posix()}
30+
lineage_path: {(out / "lineage").as_posix()}
31+
index_records_path: {(out / "index").as_posix()}
32+
run_result_path: {(out / "run" / "ingestion_result.json").as_posix()}
33+
""",
34+
encoding="utf-8",
35+
)
36+
return config_path
37+
38+
39+
def test_build_parser_defaults() -> None:
40+
args = cli.build_parser().parse_args([])
41+
assert args.config == "configs/ingestion.yaml"
42+
assert args.dry_run is False
43+
44+
45+
def test_cli_dry_run_selects_documents_without_writing(
46+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
47+
) -> None:
48+
config_path = _write_config(tmp_path)
49+
monkeypatch.setattr("sys.argv", ["prog", "--config", str(config_path), "--dry-run"])
50+
51+
assert cli.main() == 0
52+
out = capsys.readouterr().out
53+
assert "Dry run successful" in out
54+
assert "1 document(s) selected" in out
55+
assert not (tmp_path / "out").exists()
56+
57+
58+
def test_cli_full_run_produces_artifacts(
59+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
60+
) -> None:
61+
config_path = _write_config(tmp_path)
62+
monkeypatch.setattr("sys.argv", ["prog", "--config", str(config_path)])
63+
64+
assert cli.main() == 0
65+
out = capsys.readouterr().out
66+
assert "Ingestion completed" in out
67+
assert (tmp_path / "out" / "documents" / "documents.jsonl").exists()

0 commit comments

Comments
 (0)