test(pipeline): complete automated test suite for the pipeline feature
Adds ~115 tests pinning the Automated Workflows feature end to end: - tests/test_pipeline.py (+43): per-adapter summary correctness on known inputs, multi-step data flow, error stop/continue contract, empty / single-column / all-disabled edges, dict+file serialization round-trips, recommended_pipeline(include=…), and a synthesized demo integration run. - tests/test_cli_pipeline.py (new, 21): --recommend, dry-run-by-default, --apply output CSV + audit JSON, --steps, --strict abort, arg validation, --continue-on-error vs halt, and a save→load round-trip. Invokes the Typer app directly to bypass the license guard (house pattern). - tests/gui/test_pipeline_builder.py (+9): reorder ▲/▼, disabled edge buttons, disabled-step persistence across reorder, restore-recommended, Advanced JSON export/import, and per-tool Configure panels emitting the correct option dicts (AppTest). - tests/gui/test_pipeline_phrasing.py (new, 30): step_phrase/step_status and the adapter-key→friendly-name bridge as pure functions, incl. pluralization, column prose, and warn/error status derivation. Full suite: 2565 passed, 91 skipped. No product bugs surfaced. Documents the coverage in docs/DEVELOPER.md (test tree + a pipeline-coverage note). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ files is covered separately in ``tests/test_junk_corpus_tool_pages.py``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -118,3 +119,163 @@ def test_step_phrase_is_plain_english_not_json():
|
||||
|
||||
# a clean step is "ok" with no detail
|
||||
assert step_status("text_clean", {"cells_changed": 5})[1] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for the reorder / config tests below
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ids(at) -> dict:
|
||||
"""Map tool name → that step's stable id (assumes unique tools)."""
|
||||
return {s["tool"]: s["id"] for s in at.session_state["pipeline_steps"]}
|
||||
|
||||
|
||||
def _tools(at) -> list:
|
||||
return [s["tool"] for s in at.session_state["pipeline_steps"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reorder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reorder_down_swaps_with_next_step():
|
||||
at = _app()
|
||||
sid = _ids(at)["text_clean"]
|
||||
before = _tools(at)
|
||||
assert before == ["text_clean", "format_standardize", "missing", "dedup"]
|
||||
[b for b in at.button if b.key == f"text_clean_{sid}_down"][0].click().run()
|
||||
assert not at.exception
|
||||
assert _tools(at) == ["format_standardize", "text_clean", "missing", "dedup"]
|
||||
|
||||
|
||||
def test_reorder_up_swaps_with_previous_step():
|
||||
at = _app()
|
||||
sid = _ids(at)["missing"]
|
||||
[b for b in at.button if b.key == f"missing_{sid}_up"][0].click().run()
|
||||
assert not at.exception
|
||||
assert _tools(at) == ["text_clean", "missing", "format_standardize", "dedup"]
|
||||
|
||||
|
||||
def test_first_up_and_last_down_buttons_are_disabled():
|
||||
at = _app()
|
||||
ids = _ids(at)
|
||||
first_up = [b for b in at.button if b.key == f"text_clean_{ids['text_clean']}_up"][0]
|
||||
last_down = [b for b in at.button if b.key == f"dedup_{ids['dedup']}_down"][0]
|
||||
assert first_up.disabled is True
|
||||
assert last_down.disabled is True
|
||||
# interior steps are freely movable
|
||||
mid_up = [b for b in at.button if b.key == f"missing_{ids['missing']}_up"][0]
|
||||
assert mid_up.disabled is False
|
||||
|
||||
|
||||
def test_disabled_step_stays_disabled_after_reorder():
|
||||
at = _app()
|
||||
sid = _ids(at)["text_clean"]
|
||||
at.toggle[0].set_value(False).run()
|
||||
assert at.session_state["pipeline_steps"][0]["enabled"] is False
|
||||
# move the now-disabled first step down one slot
|
||||
[b for b in at.button if b.key == f"text_clean_{sid}_down"][0].click().run()
|
||||
assert not at.exception
|
||||
steps = at.session_state["pipeline_steps"]
|
||||
moved = [s for s in steps if s["tool"] == "text_clean"][0]
|
||||
assert steps.index(moved) == 1 # it moved
|
||||
assert moved["enabled"] is False # ...and stayed disabled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore recommended steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_recommended_steps_button():
|
||||
at = _app()
|
||||
# Diverge from the recommended default by removing a step.
|
||||
[b for b in at.button if b.label == "✕"][0].click().run()
|
||||
assert _tools(at) == ["format_standardize", "missing", "dedup"]
|
||||
restore = [b for b in at.button if "Restore recommended steps" in b.label]
|
||||
assert len(restore) == 1
|
||||
restore[0].click().run()
|
||||
assert not at.exception
|
||||
assert _tools(at) == ["text_clean", "format_standardize", "missing", "dedup"]
|
||||
|
||||
|
||||
def test_restore_button_absent_when_steps_match_default():
|
||||
at = _app()
|
||||
# Untouched recommended steps → no restore prompt.
|
||||
assert not [b for b in at.button if "Restore recommended steps" in b.label]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advanced JSON export / import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_advanced_json_export_reflects_current_steps():
|
||||
at = _app()
|
||||
exported = json.loads(at.code[0].value)
|
||||
assert [s["tool"] for s in exported["steps"]] == \
|
||||
["text_clean", "format_standardize", "missing", "dedup"]
|
||||
# Remove a step and confirm the exported JSON drops it too.
|
||||
[b for b in at.button if b.label == "✕"][0].click().run()
|
||||
exported = json.loads(at.code[0].value)
|
||||
assert [s["tool"] for s in exported["steps"]] == \
|
||||
["format_standardize", "missing", "dedup"]
|
||||
|
||||
|
||||
def test_load_pasted_json_replaces_the_step_list():
|
||||
at = _app()
|
||||
one_step = json.dumps(
|
||||
{"steps": [{"tool": "dedup", "options": {}, "enabled": True}]}
|
||||
)
|
||||
[t for t in at.text_area if t.key == "pipeline_json_paste"][0].set_value(
|
||||
one_step
|
||||
).run()
|
||||
[b for b in at.button if b.label == "Load pasted JSON"][0].click().run()
|
||||
assert not at.exception
|
||||
assert _tools(at) == ["dedup"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config renderers emit the right options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_standardize_config_emits_column_types():
|
||||
at = _app()
|
||||
fid = _ids(at)["format_standardize"]
|
||||
[s for s in at.selectbox if s.key == f"format_standardize_{fid}_fmt__phone"][0] \
|
||||
.set_value("Phone number").run()
|
||||
[b for b in at.button if b.label == "Run Pipeline"][0].click().run()
|
||||
assert not at.exception
|
||||
step = [s for s in at.session_state["pipeline_steps"]
|
||||
if s["tool"] == "format_standardize"][0]
|
||||
assert step["options"]["column_types"].get("phone") == "phone"
|
||||
|
||||
|
||||
def test_missing_config_drop_radio_emits_drop_row_strategy():
|
||||
at = _app()
|
||||
mid = _ids(at)["missing"]
|
||||
[r for r in at.radio if r.key == f"missing_{mid}_strategy"][0] \
|
||||
.set_value("Drop rows that have any blank").run()
|
||||
[b for b in at.button if b.label == "Run Pipeline"][0].click().run()
|
||||
assert not at.exception
|
||||
step = [s for s in at.session_state["pipeline_steps"]
|
||||
if s["tool"] == "missing"][0]
|
||||
assert step["options"]["strategy"] == "drop_row"
|
||||
|
||||
|
||||
def test_dedup_config_multiselect_builds_strategies():
|
||||
at = _app()
|
||||
did = _ids(at)["dedup"]
|
||||
[m for m in at.multiselect if m.key == f"dedup_{did}_matchcols"][0] \
|
||||
.set_value(["email"]).run()
|
||||
[b for b in at.button if b.label == "Run Pipeline"][0].click().run()
|
||||
assert not at.exception
|
||||
step = [s for s in at.session_state["pipeline_steps"]
|
||||
if s["tool"] == "dedup"][0]
|
||||
strategies = step["options"]["strategies"]
|
||||
cols = [c["column"] for c in strategies[0]["columns"]]
|
||||
assert cols == ["email"]
|
||||
assert strategies[0]["columns"][0]["algorithm"] == "exact"
|
||||
|
||||
254
tests/gui/test_pipeline_phrasing.py
Normal file
254
tests/gui/test_pipeline_phrasing.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Pure-function tests for pipeline_modules phrasing helpers.
|
||||
|
||||
These cover the adapter-key → tool bridge, the plain-English ``step_phrase``
|
||||
wording, ``step_status`` pill levels, and the column-prose / pluralization
|
||||
helpers (``_fmt_cols`` / ``_n``). No Streamlit / AppTest needed — every symbol
|
||||
under test is a pure function over plain dicts/lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.pipeline import TOOL_NAMES
|
||||
from src.gui.components.pipeline_modules import (
|
||||
CONFIG_RENDERERS,
|
||||
PIPELINE_TOOL_META,
|
||||
_fmt_cols,
|
||||
_n,
|
||||
step_label,
|
||||
step_phrase,
|
||||
step_status,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bridge completeness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", TOOL_NAMES)
|
||||
def test_pipeline_tool_meta_covers_every_tool(tool):
|
||||
assert tool in PIPELINE_TOOL_META
|
||||
assert PIPELINE_TOOL_META[tool] # non-empty tool_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", TOOL_NAMES)
|
||||
def test_step_label_is_friendly_and_not_the_raw_key(tool):
|
||||
label = step_label(tool)
|
||||
assert isinstance(label, str)
|
||||
assert label
|
||||
assert label != tool
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", TOOL_NAMES)
|
||||
def test_every_tool_has_a_config_renderer(tool):
|
||||
assert tool in CONFIG_RENDERERS
|
||||
assert callable(CONFIG_RENDERERS[tool])
|
||||
|
||||
|
||||
def test_step_label_falls_back_to_raw_key_for_unknown_tool():
|
||||
assert step_label("not_a_tool") == "not_a_tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# step_phrase — populated + no-op cases for all five tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_step_phrase_text_clean_populated_and_noop():
|
||||
assert step_phrase("text_clean", {
|
||||
"cells_changed": 1204, "columns_processed": ["name", "city"],
|
||||
}) == "1,204 cells cleaned in name & city"
|
||||
assert step_phrase("text_clean", {"cells_changed": 0}) == "No changes needed."
|
||||
assert step_phrase("text_clean", {}) == "No changes needed."
|
||||
|
||||
|
||||
def test_step_phrase_format_standardize_populated_and_noop():
|
||||
assert step_phrase("format_standardize", {
|
||||
"cells_changed": 50, "columns_processed": ["phone"],
|
||||
}) == "50 cells standardized in phone"
|
||||
# unparseable cells append a "left unchanged" tail
|
||||
assert step_phrase("format_standardize", {
|
||||
"cells_changed": 50, "cells_unparseable": 3, "columns_processed": ["phone"],
|
||||
}) == "50 cells standardized in phone (3 left unchanged)"
|
||||
assert step_phrase("format_standardize", {}) == "Nothing to standardize."
|
||||
assert step_phrase("format_standardize", {
|
||||
"cells_changed": 0, "cells_unparseable": 0,
|
||||
}) == "Nothing to standardize."
|
||||
|
||||
|
||||
def test_step_phrase_missing_populated_and_noop():
|
||||
assert step_phrase("missing", {
|
||||
"cells_filled": 12, "rows_dropped": 4, "columns_dropped": ["x", "y"],
|
||||
}) == "12 cells filled, 4 rows dropped, 2 columns dropped"
|
||||
assert step_phrase("missing", {}) == "No missing values to handle."
|
||||
# sentinel-only flagging path
|
||||
assert step_phrase("missing", {
|
||||
"sentinels_standardized": 7,
|
||||
}) == "7 blank cells flagged"
|
||||
|
||||
|
||||
def test_step_phrase_column_map_populated_and_noop():
|
||||
assert step_phrase("column_map", {
|
||||
"columns_renamed": 3, "columns_added": ["new"], "columns_dropped": ["old", "gone"],
|
||||
}) == "3 columns renamed, 1 column added, 2 columns dropped"
|
||||
assert step_phrase("column_map", {}) == "Columns already aligned."
|
||||
|
||||
|
||||
def test_step_phrase_dedup_mockup_case():
|
||||
assert step_phrase("dedup", {
|
||||
"input_rows": 18442, "output_rows": 18130,
|
||||
"duplicates_removed": 312, "groups": 147,
|
||||
}) == "312 duplicates removed across 147 groups (18,442 → 18,130 rows)"
|
||||
|
||||
|
||||
def test_step_phrase_dedup_noop():
|
||||
assert step_phrase("dedup", {"duplicates_removed": 0}) == "No duplicates found."
|
||||
assert step_phrase("dedup", {}) == "No duplicates found."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pluralization (_n) through step_phrase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_step_phrase_dedup_singular():
|
||||
assert step_phrase("dedup", {
|
||||
"input_rows": 10, "output_rows": 9,
|
||||
"duplicates_removed": 1, "groups": 1,
|
||||
}) == "1 duplicate removed across 1 group (10 → 9 rows)"
|
||||
|
||||
|
||||
def test_step_phrase_missing_singular():
|
||||
assert step_phrase("missing", {
|
||||
"rows_dropped": 1, "columns_dropped": ["x"],
|
||||
}) == "1 row dropped, 1 column dropped"
|
||||
|
||||
|
||||
def test_n_singular_vs_plural_every_noun():
|
||||
assert _n(1, "cell") == "1 cell"
|
||||
assert _n(2, "cell") == "2 cells"
|
||||
assert _n(1, "row") == "1 row"
|
||||
assert _n(3, "row") == "3 rows"
|
||||
assert _n(1, "column") == "1 column"
|
||||
assert _n(5, "column") == "5 columns"
|
||||
assert _n(1, "duplicate") == "1 duplicate"
|
||||
assert _n(9, "duplicate") == "9 duplicates"
|
||||
assert _n(1, "group") == "1 group"
|
||||
assert _n(4, "group") == "4 groups"
|
||||
|
||||
|
||||
def test_n_thousands_separator():
|
||||
assert _n(1204, "cell") == "1,204 cells"
|
||||
assert _n(18442, "row") == "18,442 rows"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Column prose (_fmt_cols)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fmt_cols_zero():
|
||||
assert _fmt_cols([]) == ""
|
||||
|
||||
|
||||
def test_fmt_cols_one():
|
||||
assert _fmt_cols(["name"]) == "name"
|
||||
|
||||
|
||||
def test_fmt_cols_two():
|
||||
assert _fmt_cols(["name", "city"]) == "name & city"
|
||||
|
||||
|
||||
def test_fmt_cols_three():
|
||||
assert _fmt_cols(["a", "b", "c"]) == "a, b & c"
|
||||
|
||||
|
||||
def test_fmt_cols_four_or_more():
|
||||
assert _fmt_cols(["a", "b", "c", "d"]) == "a, b & 2 more"
|
||||
assert _fmt_cols(["a", "b", "c", "d", "e"]) == "a, b & 3 more"
|
||||
|
||||
|
||||
def test_fmt_cols_coerces_non_strings():
|
||||
assert _fmt_cols([1, 2]) == "1 & 2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# step_status — pill levels + details
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_step_status_clean_is_ok():
|
||||
assert step_status("text_clean", {"cells_changed": 5}) == ("✓ ok", "ok", "")
|
||||
|
||||
|
||||
def test_step_status_skipped():
|
||||
label, level, detail = step_status("text_clean", {"cells_changed": 5}, skipped=True)
|
||||
assert level == "skipped"
|
||||
assert detail == ""
|
||||
assert "skipped" in label
|
||||
|
||||
|
||||
def test_step_status_error_uses_first_line_only():
|
||||
label, level, detail = step_status(
|
||||
"dedup", {}, error="X: msg\nline2\nline3",
|
||||
)
|
||||
assert level == "error"
|
||||
assert detail == "X: msg"
|
||||
assert "error" in label
|
||||
|
||||
|
||||
def test_step_status_error_takes_precedence_over_skipped():
|
||||
label, level, detail = step_status(
|
||||
"text_clean", {}, skipped=True, error="boom\nsecond",
|
||||
)
|
||||
assert level == "error"
|
||||
assert detail == "boom"
|
||||
|
||||
|
||||
def test_step_status_format_standardize_unparseable_warns():
|
||||
label, level, detail = step_status(
|
||||
"format_standardize", {"cells_changed": 100, "cells_unparseable": 141},
|
||||
)
|
||||
assert level == "warn"
|
||||
assert "141 skipped" in label
|
||||
assert detail # non-empty inline detail
|
||||
|
||||
|
||||
def test_step_status_format_standardize_no_unparseable_is_ok():
|
||||
assert step_status(
|
||||
"format_standardize", {"cells_changed": 100},
|
||||
) == ("✓ ok", "ok", "")
|
||||
|
||||
|
||||
def test_step_status_column_map_coercion_failures_warn():
|
||||
label, level, detail = step_status(
|
||||
"column_map", {"coercion_failures": {"age": 4}},
|
||||
)
|
||||
assert level == "warn"
|
||||
assert "4 not coerced" in label
|
||||
assert detail
|
||||
|
||||
|
||||
def test_step_status_column_map_missing_required_targets_warn():
|
||||
label, level, detail = step_status(
|
||||
"column_map", {"missing_required_targets": ["email"]},
|
||||
)
|
||||
assert level == "warn"
|
||||
assert "missing targets" in label
|
||||
assert "email" in detail
|
||||
|
||||
|
||||
def test_step_status_column_map_missing_targets_take_precedence_over_coercion():
|
||||
# both present → missing-targets branch wins
|
||||
label, level, detail = step_status(
|
||||
"column_map",
|
||||
{"missing_required_targets": ["email"], "coercion_failures": {"age": 4}},
|
||||
)
|
||||
assert level == "warn"
|
||||
assert "missing targets" in label
|
||||
|
||||
|
||||
def test_step_status_unknown_tool_is_ok():
|
||||
assert step_status("mystery", {"foo": 1}) == ("✓ ok", "ok", "")
|
||||
Reference in New Issue
Block a user