diff --git a/src/datasure/processing/corrections.py b/src/datasure/processing/corrections.py index 7320895a..a3b38a46 100644 --- a/src/datasure/processing/corrections.py +++ b/src/datasure/processing/corrections.py @@ -154,7 +154,8 @@ def add_correction_entry( key_value : str The key value being corrected current_id : str | None - The current ID value + The Survey ID value for this KEY, if a Survey ID column is + configured for the dataset action : str The correction action column : str | None @@ -231,6 +232,7 @@ def apply_correction( current_value: Any | None = None, new_value: Any | None = None, reason: str | None = None, + survey_id_value: Any | None = None, ) -> pl.DataFrame: """Apply a single correction to the data. @@ -252,6 +254,9 @@ def apply_correction( The new value reason : str | None The reason for correction + survey_id_value : Any | None + The Survey ID value for this KEY, if a Survey ID column is + configured, recorded in the log's ID column Returns ------- @@ -278,7 +283,7 @@ def apply_correction( self.add_correction_entry( alias=alias, key_value=key_value, - current_id=None, # Legacy field, not used in new implementation + current_id=survey_id_value, action=action, column=column, current_value=current_value, diff --git a/src/datasure/views/correction_view.py b/src/datasure/views/correction_view.py index 3468e33c..16f958b0 100644 --- a/src/datasure/views/correction_view.py +++ b/src/datasure/views/correction_view.py @@ -40,6 +40,9 @@ class TabConfig(BaseModel): page_name: str = Field(..., description="Name of the page/check") survey_data_name: str = Field(..., description="Name of the survey data alias") survey_key: str = Field(..., description="Name of the survey KEY column") + survey_id: str | None = Field( + None, description="Name of the survey ID column, if configured" + ) class CorrectionFormState(BaseModel): @@ -231,6 +234,7 @@ def load_tab_config(project_id: str, tab_index: int) -> TabConfig | None: page_name=page_config.get("page_name"), survey_data_name=page_config.get("survey_data_name"), survey_key=page_config.get("survey_key"), + survey_id=page_config.get("survey_id"), ) @@ -481,6 +485,7 @@ def render_add_correction_form( key_col: str, alias: str, tab_index: int, + survey_id_col: str | None = None, ) -> None: """ Render the add correction step form. @@ -498,6 +503,10 @@ def render_add_correction_form( The data alias/table name. tab_index : int The tab index for unique widget keys. + survey_id_col : str | None + The name of the configured Survey ID column, if any. When set (and + present in the data), the corresponding Survey ID is shown once a + KEY is selected. """ corrected_data = correction_processor.get_corrected_data(alias) @@ -519,6 +528,13 @@ def render_add_correction_form( if not corr_key_val: return + survey_id_value = None + if survey_id_col and survey_id_col in corrected_data.columns: + survey_id_value = get_current_value( + corrected_data, key_col, corr_key_val, survey_id_col + ) + st.write(f"**Survey ID:** {survey_id_value}") + # Step 2: Select action corr_action = st.selectbox( label="Select Action", @@ -547,6 +563,7 @@ def render_add_correction_form( form_state=form_state, reason=reason, tab_index=tab_index, + survey_id_value=survey_id_value, ) @@ -600,6 +617,7 @@ def _render_apply_button( form_state: CorrectionFormState, reason: str, tab_index: int, + survey_id_value: Any = None, ) -> None: """ Render apply button and handle correction application. @@ -620,6 +638,9 @@ def _render_apply_button( Reason for correction. tab_index : int The tab index for unique widget keys. + survey_id_value : Any + The Survey ID value for the selected KEY, if a Survey ID column is + configured, to record alongside the correction log entry. """ apply_enabled = should_enable_apply_button( form_state.action, reason, form_state.new_value @@ -645,6 +666,7 @@ def _render_apply_button( current_value=form_state.current_value, new_value=form_state.new_value, reason=reason, + survey_id_value=survey_id_value, ) @@ -659,6 +681,7 @@ def _handle_apply_correction( current_value: Any, new_value: Any, reason: str, + survey_id_value: Any = None, ) -> None: """ Handle the application of a correction with validation. @@ -685,6 +708,9 @@ def _handle_apply_correction( The new value (if applicable). reason : str The reason for correction. + survey_id_value : Any + The Survey ID value for this KEY, if a Survey ID column is + configured, to record alongside the correction log entry. """ try: # Validate input @@ -711,6 +737,7 @@ def _handle_apply_correction( current_value=current_value, new_value=new_value, reason=reason, + survey_id_value=survey_id_value, ) st.success("Correction applied successfully!") @@ -725,6 +752,7 @@ def render_correction_input_form( key_col: str, alias: str, tab_index: int, + survey_id_col: str | None = None, ) -> None: """ Render input form for corrections with add and remove functionality. @@ -739,6 +767,8 @@ def render_correction_input_form( The data alias/table name. tab_index : int The tab index for unique widget keys. + survey_id_col : str | None + The name of the configured Survey ID column, if any. """ corrected_data = correction_processor.get_corrected_data(alias) @@ -754,6 +784,7 @@ def render_correction_input_form( key_col=key_col, alias=alias, tab_index=tab_index, + survey_id_col=survey_id_col, ) with fc2: @@ -892,8 +923,9 @@ def _handle_remove_correction( def _build_correction_log_display(correction_log: pl.DataFrame) -> pl.DataFrame: """Prepare a correction log for display in the Correction Log table. - Backfills the status columns for logs saved before they existed, and - orders columns so status/status_reason sit right after action. + Backfills the status columns for logs saved before they existed, orders + columns so status/status_reason sit right after action, and relabels the + "ID" column as "Survey ID" for display. Parameters ---------- @@ -903,7 +935,8 @@ def _build_correction_log_display(correction_log: pl.DataFrame) -> pl.DataFrame: Returns ------- pl.DataFrame - The log with status columns present, in display column order. + The log with status columns present, in display column order, ready + for display. """ if "status" not in correction_log.columns: correction_log = correction_log.with_columns( @@ -926,7 +959,9 @@ def _build_correction_log_display(correction_log: pl.DataFrame) -> pl.DataFrame: "new_value", "reason", ] - return correction_log.select(display_columns) + # "ID" holds the Survey ID value recorded for the KEY, if one was + # configured - rename it for display so the column reads clearly. + return correction_log.select(display_columns).rename({"ID": "Survey ID"}) @st.fragment @@ -1032,6 +1067,7 @@ def render_correction_tab( key_col=config.survey_key, alias=config.survey_data_name, tab_index=tab_index, + survey_id_col=config.survey_id, ) render_correction_log( diff --git a/tests/processing/test_corrections.py b/tests/processing/test_corrections.py index 9d61d937..10ad2e37 100644 --- a/tests/processing/test_corrections.py +++ b/tests/processing/test_corrections.py @@ -469,6 +469,73 @@ def test_apply_correction_without_reason(self, correction_processor, sample_data # Should only save data, not log (since no reason) assert mock_save.call_count == 1 + def test_apply_correction_records_survey_id( + self, correction_processor, sample_data + ): + """The Survey ID for the corrected KEY is recorded in the log's ID column.""" + processor, mock_get, mock_save = correction_processor + empty_log = pl.DataFrame( + { + "date": [], + "KEY": [], + "ID": [], + "action": [], + "column": [], + "current_value": [], + "new_value": [], + "reason": [], + } + ) + mock_get.side_effect = [sample_data, empty_log] + + processor.apply_correction( + alias="test_alias", + key_col="survey_key", + key_value="key2", + action="modify value", + column="name", + current_value="Jane", + new_value="Janet", + reason="Name correction", + survey_id_value="HH002", + ) + + saved_log = mock_save.call_args[1]["table_data"] + assert saved_log["ID"][0] == "HH002" + + def test_apply_correction_without_survey_id_leaves_id_blank( + self, correction_processor, sample_data + ): + """No Survey ID configured/available means the ID column stays blank.""" + processor, mock_get, mock_save = correction_processor + empty_log = pl.DataFrame( + { + "date": [], + "KEY": [], + "ID": [], + "action": [], + "column": [], + "current_value": [], + "new_value": [], + "reason": [], + } + ) + mock_get.side_effect = [sample_data, empty_log] + + processor.apply_correction( + alias="test_alias", + key_col="survey_key", + key_value="key2", + action="modify value", + column="name", + current_value="Jane", + new_value="Janet", + reason="Name correction", + ) + + saved_log = mock_save.call_args[1]["table_data"] + assert saved_log["ID"][0] is None + def test_get_data_summary(self, correction_processor, sample_data): """Test getting data summary.""" processor, _, _ = correction_processor diff --git a/tests/views/test_correction_view.py b/tests/views/test_correction_view.py index 184edc86..811d8cf7 100644 --- a/tests/views/test_correction_view.py +++ b/tests/views/test_correction_view.py @@ -1,8 +1,68 @@ """Tests for correction_view.py logic patterns.""" -import polars as pl +import datetime as dt +import sys +from contextlib import contextmanager +from unittest.mock import MagicMock, patch -from datasure.views.correction_view import _build_correction_log_display +import polars as pl +import pytest + +from datasure.views.correction_view import ( + CorrectionFormState, + _build_correction_log_display, + _display_correction_details, + _handle_apply_correction, + _handle_remove_correction, + _render_action_ui, + _render_column_selector, + _render_modify_value_action, + _render_remove_row_action, + _render_remove_value_action, + get_current_value, + get_key_options, + load_hfc_config, + load_tab_config, + main, + parse_date_value, + render_add_correction_form, + render_correction_input_form, + render_page_header, + render_page_navigation, + render_value_input_widget, + should_enable_apply_button, + validate_numeric_input, + validate_prerequisites, +) + +_st = sys.modules["streamlit"] + + +@contextmanager +def _patched_st(**overrides): + """Temporarily set attributes on the shared streamlit mock. + + `_st` is a single mock shared across every view test file, so leaving + an override bound after a test (e.g. an exhausted list `side_effect`) + can crash unrelated tests elsewhere in the suite. This always restores + whatever was there before, even on failure. + """ + originals = {name: getattr(_st, name) for name in overrides} + try: + for name, value in overrides.items(): + setattr(_st, name, value) + yield + finally: + for name, value in originals.items(): + setattr(_st, name, value) + + +def _mock_context_widget() -> MagicMock: + """A MagicMock usable as a `with ...:` context manager target.""" + widget = MagicMock() + widget.__enter__ = MagicMock(return_value=widget) + widget.__exit__ = MagicMock(return_value=False) + return widget class TestCorrectionInputFormLogic: @@ -398,7 +458,7 @@ def test_status_columns_ordered_right_after_action(self): assert result.columns == [ "date", "KEY", - "ID", + "Survey ID", "action", "status", "status_reason", @@ -407,3 +467,741 @@ def test_status_columns_ordered_right_after_action(self): "new_value", "reason", ] + + +class TestLoadTabConfig: + """Test that load_tab_config threads the configured Survey ID column.""" + + @patch("datasure.views.correction_view.get_check_config_settings") + def test_includes_survey_id_when_configured(self, mock_get_settings): + mock_get_settings.return_value = { + "page_name": "Household Survey", + "survey_data_name": "household_survey", + "survey_key": "KEY", + "survey_id": "hhid", + } + + config = load_tab_config("proj1", 0) + + assert config.survey_id == "hhid" + + @patch("datasure.views.correction_view.get_check_config_settings") + def test_survey_id_none_when_not_configured(self, mock_get_settings): + mock_get_settings.return_value = { + "page_name": "Household Survey", + "survey_data_name": "household_survey", + "survey_key": "KEY", + } + + config = load_tab_config("proj1", 0) + + assert config.survey_id is None + + +class TestRenderAddCorrectionFormSurveyId: + """Test the Survey ID display shown after a KEY is selected.""" + + def _mock_processor(self, data: pl.DataFrame) -> MagicMock: + processor = MagicMock() + processor.get_corrected_data.return_value = data + return processor + + def test_empty_corrected_data_shows_warning(self): + processor = self._mock_processor(pl.DataFrame()) + + with _patched_st(warning=MagicMock()): + render_add_correction_form( + correction_processor=processor, + key_col="KEY", + alias="survey", + tab_index=0, + ) + assert _st.warning.called + + @contextmanager + def _mocked_widgets( + self, selected_key: str, click_apply: bool = False, reason: str = "" + ): + """Mock the widgets this form uses, restoring originals afterward. + + `_st` is a single mock shared across every view test file, so + leaving these bound after the test (e.g. an exhausted list + `side_effect` on `selectbox`) can crash unrelated tests in + test_import_view.py that inherit the same mock later in the run. + """ + originals = { + "popover": _st.popover, + "markdown": _st.markdown, + "warning": _st.warning, + "write": _st.write, + "text_input": _st.text_input, + "button": _st.button, + "selectbox": _st.selectbox, + } + try: + mock_popover = MagicMock() + mock_popover.__enter__ = MagicMock(return_value=None) + mock_popover.__exit__ = MagicMock(return_value=False) + _st.popover = MagicMock(return_value=mock_popover) + _st.markdown = MagicMock() + _st.warning = MagicMock() + _st.write = MagicMock() + _st.text_input = MagicMock(return_value=reason) + _st.button = MagicMock(return_value=click_apply) + # First selectbox call selects the KEY, second selects the action. + _st.selectbox = MagicMock(side_effect=[selected_key, "remove row"]) + yield + finally: + for name, value in originals.items(): + setattr(_st, name, value) + + def test_shows_survey_id_when_configured(self): + """The configured Survey ID column's value is displayed for the KEY.""" + data = pl.DataFrame({"KEY": ["uuid:1", "uuid:2"], "hhid": ["HH001", "HH002"]}) + processor = self._mock_processor(data) + + with self._mocked_widgets(selected_key="uuid:1"): + render_add_correction_form( + correction_processor=processor, + key_col="KEY", + alias="survey", + tab_index=0, + survey_id_col="hhid", + ) + + written = [str(c.args[0]) for c in _st.write.call_args_list] + assert any("Survey ID" in text and "HH001" in text for text in written) + + def test_no_survey_id_display_when_not_configured(self): + """No Survey ID row is shown when no Survey ID column is configured.""" + data = pl.DataFrame({"KEY": ["uuid:1", "uuid:2"], "hhid": ["HH001", "HH002"]}) + processor = self._mock_processor(data) + + with self._mocked_widgets(selected_key="uuid:1"): + render_add_correction_form( + correction_processor=processor, + key_col="KEY", + alias="survey", + tab_index=0, + survey_id_col=None, + ) + + written = [str(c.args[0]) for c in _st.write.call_args_list] + assert not any("Survey ID" in text for text in written) + + def test_no_survey_id_display_when_column_missing_from_data(self): + """A configured but nonexistent Survey ID column is skipped, not an error.""" + data = pl.DataFrame({"KEY": ["uuid:1", "uuid:2"]}) + processor = self._mock_processor(data) + + with self._mocked_widgets(selected_key="uuid:1"): + render_add_correction_form( + correction_processor=processor, + key_col="KEY", + alias="survey", + tab_index=0, + survey_id_col="hhid", + ) + + written = [str(c.args[0]) for c in _st.write.call_args_list] + assert not any("Survey ID" in text for text in written) + + def test_apply_passes_survey_id_to_processor(self): + """Clicking Apply forwards the looked-up Survey ID to apply_correction.""" + data = pl.DataFrame({"KEY": ["uuid:1", "uuid:2"], "hhid": ["HH001", "HH002"]}) + processor = self._mock_processor(data) + processor.validate_correction_input.return_value = (True, "") + + with self._mocked_widgets( + selected_key="uuid:1", click_apply=True, reason="Test reason" + ): + render_add_correction_form( + correction_processor=processor, + key_col="KEY", + alias="survey", + tab_index=0, + survey_id_col="hhid", + ) + + processor.apply_correction.assert_called_once() + assert processor.apply_correction.call_args[1]["survey_id_value"] == "HH001" + + +class TestGetKeyOptions: + """Test get_key_options: unique key values, in first-seen order.""" + + def test_returns_unique_values_in_order(self): + data = pl.DataFrame({"KEY": ["b", "a", "b", "c"]}) + assert get_key_options(data, "KEY") == ["b", "a", "c"] + + def test_single_row(self): + data = pl.DataFrame({"KEY": ["only"]}) + assert get_key_options(data, "KEY") == ["only"] + + +class TestGetCurrentValue: + """Test get_current_value: lookup and graceful failure.""" + + def test_returns_value_when_found(self): + data = pl.DataFrame({"KEY": ["k1", "k2"], "name": ["Alice", "Bob"]}) + assert get_current_value(data, "KEY", "k2", "name") == "Bob" + + def test_key_not_found_does_not_raise(self): + """A key that doesn't exist yields an empty result, not a crash. + + In practice this path isn't reachable from the UI (the key selector + only ever offers values already present in the data), so this just + guards the try/except boundary rather than asserting a specific + "not found" sentinel. + """ + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + result = get_current_value(data, "KEY", "missing", "name") + assert result is None or len(result) == 0 + + def test_returns_none_for_nonexistent_column(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + assert get_current_value(data, "KEY", "k1", "nonexistent") is None + + +class TestParseDateValue: + """Test parse_date_value: string/datetime parsing and failure handling.""" + + def test_none_returns_none(self): + assert parse_date_value(None) is None + + def test_empty_string_returns_none(self): + assert parse_date_value("") is None + + def test_parses_iso_string(self): + assert parse_date_value("2024-01-15") == dt.date(2024, 1, 15) + + def test_parses_datetime_object(self): + assert parse_date_value(dt.datetime(2024, 1, 15, 10, 30)) == dt.date( + 2024, 1, 15 + ) + + def test_invalid_string_returns_none(self): + assert parse_date_value("not-a-date") is None + + def test_value_without_date_method_returns_none(self): + assert parse_date_value(12345) is None + + +class TestValidateNumericInput: + """Test validate_numeric_input across numeric and non-numeric dtypes.""" + + @pytest.mark.parametrize("dtype", [pl.Int64, pl.Int32, pl.Float64, pl.Float32]) + def test_valid_numeric_for_numeric_dtype(self, dtype): + is_valid, err = validate_numeric_input("42", dtype) + assert is_valid is True + assert err is None + + def test_invalid_numeric_for_numeric_dtype(self): + is_valid, err = validate_numeric_input("abc", pl.Float64) + assert is_valid is False + assert err == "New value must be a number." + + def test_non_numeric_dtype_always_valid(self): + is_valid, err = validate_numeric_input("anything at all", pl.Utf8) + assert is_valid is True + assert err is None + + +class TestShouldEnableApplyButton: + """Test should_enable_apply_button across every action/reason combination.""" + + def test_no_reason_disables_regardless_of_action(self): + assert should_enable_apply_button("remove row", "", None) is False + assert should_enable_apply_button("modify value", "", "new") is False + + def test_modify_value_requires_new_value(self): + assert should_enable_apply_button("modify value", "reason", None) is False + assert should_enable_apply_button("modify value", "reason", "") is False + assert should_enable_apply_button("modify value", "reason", "new") is True + + def test_remove_value_enabled_with_reason(self): + assert should_enable_apply_button("remove value", "reason") is True + + def test_remove_row_enabled_with_reason(self): + assert should_enable_apply_button("remove row", "reason") is True + + def test_unknown_action_disabled(self): + assert should_enable_apply_button("unknown action", "reason") is False + + +class TestLoadHfcConfig: + """Test load_hfc_config: empty vs populated check configuration.""" + + @patch("datasure.views.correction_view.duckdb_get_table") + def test_empty_config_returns_empty_and_no_pages(self, mock_get): + mock_get.return_value = pl.DataFrame() + + logs, pages = load_hfc_config("proj1") + + assert logs.is_empty() + assert pages == [] + + @patch("datasure.views.correction_view.duckdb_get_table") + def test_returns_page_names(self, mock_get): + mock_get.return_value = pl.DataFrame({"page_name": ["Page A", "Page B"]}) + + _logs, pages = load_hfc_config("proj1") + + assert pages == ["Page A", "Page B"] + mock_get.assert_called_once_with( + project_id="proj1", alias="check_config", db_name="logs" + ) + + +class TestValidatePrerequisites: + """Test validate_prerequisites: each st.stop() branch and the happy path.""" + + def test_no_project_id_stops(self): + with _patched_st(stop=MagicMock(side_effect=StopIteration), info=MagicMock()): + with pytest.raises(StopIteration): + validate_prerequisites(None) + assert _st.info.called + + @patch("datasure.views.correction_view.load_hfc_config") + def test_empty_hfc_config_stops(self, mock_load): + mock_load.return_value = (pl.DataFrame(), []) + + with ( + _patched_st(stop=MagicMock(side_effect=StopIteration), info=MagicMock()), + pytest.raises(StopIteration), + ): + validate_prerequisites("proj1") + + @patch("datasure.views.correction_view.load_hfc_config") + def test_empty_pages_stops(self, mock_load): + mock_load.return_value = (pl.DataFrame({"page_name": ["x"]}), []) + + with ( + _patched_st(stop=MagicMock(side_effect=StopIteration), info=MagicMock()), + pytest.raises(StopIteration), + ): + validate_prerequisites("proj1") + + @patch("datasure.views.correction_view.load_hfc_config") + def test_all_prerequisites_met_returns_data(self, mock_load): + mock_load.return_value = (pl.DataFrame({"page_name": ["x"]}), ["Page A"]) + + _logs, pages = validate_prerequisites("proj1") + + assert pages == ["Page A"] + + +class TestRenderValueInputWidget: + """Test render_value_input_widget: datetime vs text input, numeric validation.""" + + def test_datetime_dtype_uses_date_input(self): + with _patched_st(date_input=MagicMock(return_value=dt.date(2024, 1, 1))): + new_value, error = render_value_input_widget( + "col", pl.Datetime, "2023-06-01", tab_index=0 + ) + assert new_value == dt.date(2024, 1, 1) + assert error is None + + def test_non_datetime_valid_numeric(self): + with _patched_st(text_input=MagicMock(return_value="42")): + new_value, error = render_value_input_widget("col", pl.Int64, 10, 0) + assert new_value == "42" + assert error is None + + def test_non_datetime_invalid_numeric(self): + with _patched_st(text_input=MagicMock(return_value="abc")): + new_value, error = render_value_input_widget("col", pl.Int64, 10, 0) + assert new_value is None + assert error == "New value must be a number." + + def test_empty_new_value_skips_validation(self): + with _patched_st(text_input=MagicMock(return_value="")): + new_value, error = render_value_input_widget("col", pl.Int64, 10, 0) + assert new_value == "" + assert error is None + + def test_non_numeric_dtype_accepts_any_text(self): + with _patched_st(text_input=MagicMock(return_value="hello")): + new_value, error = render_value_input_widget("col", pl.Utf8, "old", 0) + assert new_value == "hello" + assert error is None + + +class TestRenderColumnSelector: + """Test _render_column_selector: column chosen vs left blank.""" + + def test_no_column_selected_returns_none_none(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + with _patched_st(selectbox=MagicMock(return_value=None)): + column, value = _render_column_selector(data, "KEY", "k1", 0) + assert column is None + assert value is None + + def test_column_selected_returns_current_value(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + with _patched_st(selectbox=MagicMock(return_value="name"), write=MagicMock()): + column, value = _render_column_selector(data, "KEY", "k1", 0) + assert column == "name" + assert value == "Alice" + + +class TestRenderModifyValueAction: + """Test _render_modify_value_action: no column vs a column selected.""" + + def test_no_column_selected(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + with _patched_st(selectbox=MagicMock(return_value=None)): + state = _render_modify_value_action(data, "KEY", "k1", 0) + + assert isinstance(state, CorrectionFormState) + assert state.action == "modify value" + assert state.column is None + + def test_column_selected_builds_full_state(self): + data = pl.DataFrame({"KEY": ["k1"], "age": [25]}) + with _patched_st( + selectbox=MagicMock(return_value="age"), + write=MagicMock(), + text_input=MagicMock(return_value="30"), + ): + state = _render_modify_value_action(data, "KEY", "k1", 0) + + assert state.column == "age" + assert state.current_value == 25 + assert state.new_value == "30" + assert state.validation_error is None + + def test_column_selected_with_validation_error(self): + data = pl.DataFrame({"KEY": ["k1"], "age": [25]}) + with _patched_st( + selectbox=MagicMock(return_value="age"), + write=MagicMock(), + text_input=MagicMock(return_value="not-a-number"), + error=MagicMock(), + ): + state = _render_modify_value_action(data, "KEY", "k1", 0) + assert _st.error.called + + assert state.validation_error == "New value must be a number." + + +class TestRenderRemoveValueAction: + """Test _render_remove_value_action.""" + + def test_builds_remove_value_state(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + with _patched_st(selectbox=MagicMock(return_value="name"), write=MagicMock()): + state = _render_remove_value_action(data, "KEY", "k1", 0) + + assert state.action == "remove value" + assert state.column == "name" + assert state.current_value == "Alice" + + +class TestRenderRemoveRowAction: + """Test _render_remove_row_action.""" + + def test_builds_remove_row_state_and_warns(self): + with _patched_st(warning=MagicMock()): + state = _render_remove_row_action("k1") + assert _st.warning.called + + assert state.action == "remove row" + assert state.key_value == "k1" + + +class TestRenderActionUi: + """Test _render_action_ui dispatches to the right per-action handler.""" + + def test_dispatches_modify_value(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + with _patched_st(selectbox=MagicMock(return_value=None)): + state = _render_action_ui("modify value", data, "KEY", "k1", 0) + assert state.action == "modify value" + + def test_dispatches_remove_value(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + with _patched_st(selectbox=MagicMock(return_value=None), write=MagicMock()): + state = _render_action_ui("remove value", data, "KEY", "k1", 0) + assert state.action == "remove value" + + def test_dispatches_remove_row(self): + data = pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}) + with _patched_st(warning=MagicMock()): + state = _render_action_ui("remove row", data, "KEY", "k1", 0) + assert state.action == "remove row" + + +class TestHandleApplyCorrection: + """Test _handle_apply_correction: validation failure, success, exception.""" + + def _base_kwargs(self, processor): + return dict( + correction_processor=processor, + corrected_data=pl.DataFrame({"KEY": ["k1"], "name": ["Alice"]}), + alias="survey", + key_col="KEY", + key_value="k1", + action="modify value", + column="name", + current_value="Alice", + new_value="Alicia", + reason="typo fix", + ) + + def test_validation_error_shows_error_and_does_not_apply(self): + processor = MagicMock() + processor.validate_correction_input.return_value = (False, "bad input") + + with _patched_st(error=MagicMock(), success=MagicMock(), rerun=MagicMock()): + _handle_apply_correction(**self._base_kwargs(processor)) + + assert _st.error.called + assert "bad input" in _st.error.call_args[0][0] + processor.apply_correction.assert_not_called() + assert not _st.success.called + + def test_success_applies_and_reruns(self): + processor = MagicMock() + processor.validate_correction_input.return_value = (True, "") + + with _patched_st(error=MagicMock(), success=MagicMock(), rerun=MagicMock()): + _handle_apply_correction( + survey_id_value="HH001", **self._base_kwargs(processor) + ) + + processor.apply_correction.assert_called_once() + assert processor.apply_correction.call_args[1]["survey_id_value"] == ( + "HH001" + ) + assert _st.success.called + assert _st.rerun.called + + def test_exception_during_apply_shows_error(self): + processor = MagicMock() + processor.validate_correction_input.return_value = (True, "") + processor.apply_correction.side_effect = RuntimeError("boom") + + with _patched_st(error=MagicMock(), success=MagicMock(), rerun=MagicMock()): + _handle_apply_correction(**self._base_kwargs(processor)) + + assert _st.error.called + assert "boom" in _st.error.call_args[0][0] + assert not _st.success.called + + +class TestRenderCorrectionInputForm: + """Test render_correction_input_form: empty vs populated corrected data.""" + + def test_empty_data_shows_warning(self): + processor = MagicMock() + processor.get_corrected_data.return_value = pl.DataFrame() + + with _patched_st(warning=MagicMock()): + render_correction_input_form(processor, "KEY", "survey", 0) + assert _st.warning.called + + def test_populated_data_renders_add_form_and_calls_remove_form(self): + """render_remove_correction_form is `@st.fragment`-wrapped, so under + the test harness's mock it becomes an opaque callable - this only + confirms it's invoked with the right args, not its internal + behavior (covered separately if extracted, per the existing + `@st.fragment` testing limitation noted elsewhere in this suite). + """ + processor = MagicMock() + processor.get_corrected_data.return_value = pl.DataFrame( + {"KEY": ["k1"], "name": ["Alice"]} + ) + + with ( + _patched_st( + columns=MagicMock( + return_value=[ + _mock_context_widget(), + _mock_context_widget(), + _mock_context_widget(), + ] + ), + popover=MagicMock(return_value=_mock_context_widget()), + selectbox=MagicMock(return_value=None), + markdown=MagicMock(), + info=MagicMock(), + ), + patch( + "datasure.views.correction_view.render_remove_correction_form" + ) as mock_remove_form, + ): + render_correction_input_form(processor, "KEY", "survey", 0) + + processor.get_corrected_data.assert_called() + mock_remove_form.assert_called_once_with( + correction_processor=processor, alias="survey", tab_index=0 + ) + + +class TestDisplayCorrectionDetails: + """Test _display_correction_details renders the selected summary's fields.""" + + def test_shows_all_optional_fields_when_present(self): + summaries = [ + { + "action_index": "0 - modify value - x", + "action": "modify value", + "key_value": "k1", + "column": "name", + "new_value": "Alicia", + "reason": "typo fix", + } + ] + + with _patched_st(write=MagicMock()): + _display_correction_details(summaries, "0 - modify value - x") + written = [str(c.args[0]) for c in _st.write.call_args_list] + + assert any("modify value" in t for t in written) + assert any("k1" in t for t in written) + assert any("name" in t for t in written) + assert any("Alicia" in t for t in written) + assert any("typo fix" in t for t in written) + + def test_skips_absent_optional_fields(self): + summaries = [ + { + "action_index": "0 - remove row - x", + "action": "remove row", + "key_value": "k1", + "column": None, + "new_value": None, + "reason": "duplicate", + } + ] + + with _patched_st(write=MagicMock()): + _display_correction_details(summaries, "0 - remove row - x") + written = [str(c.args[0]) for c in _st.write.call_args_list] + + assert not any("Column" in t for t in written) + assert not any("New Value" in t for t in written) + + +class TestHandleRemoveCorrection: + """Test _handle_remove_correction: success and exception paths.""" + + def test_success_removes_and_reruns(self): + processor = MagicMock() + processor.remove_correction_entry.return_value = [] + summaries = [{"action_index": "0 - remove row - x", "index": 0}] + + with _patched_st(success=MagicMock(), rerun=MagicMock(), warning=MagicMock()): + _handle_remove_correction( + processor, summaries, "survey", "0 - remove row - x" + ) + + processor.remove_correction_entry.assert_called_once_with("survey", 0) + assert _st.success.called + assert _st.rerun.called + + def test_reapply_failures_trigger_warning(self): + from datasure.utils.reapply_utils import ReapplyFailure + + processor = MagicMock() + processor.remove_correction_entry.return_value = [ + ReapplyFailure(step="Modify name for key2", reason="Key not found") + ] + summaries = [{"action_index": "0 - remove row - x", "index": 0}] + + with _patched_st(success=MagicMock(), rerun=MagicMock(), warning=MagicMock()): + _handle_remove_correction( + processor, summaries, "survey", "0 - remove row - x" + ) + + assert _st.warning.called + + def test_exception_shows_error(self): + processor = MagicMock() + processor.remove_correction_entry.side_effect = RuntimeError("db error") + summaries = [{"action_index": "0 - remove row - x", "index": 0}] + + with _patched_st(success=MagicMock(), rerun=MagicMock(), error=MagicMock()): + _handle_remove_correction( + processor, summaries, "survey", "0 - remove row - x" + ) + + assert _st.error.called + assert "db error" in _st.error.call_args[0][0] + + +class TestRenderPageHeaderAndNavigation: + """Test the small page-chrome rendering functions.""" + + @patch("datasure.views.correction_view.demo_expander") + @patch("datasure.views.correction_view.page_header") + def test_render_page_header(self, mock_page_header, mock_demo_expander): + render_page_header() + + mock_page_header.assert_called_once() + mock_demo_expander.assert_called_once() + + @patch("datasure.views.correction_view.page_navigation") + def test_render_page_navigation_without_replication_page(self, mock_nav): + with _patched_st(session_state={"st_output_page1": "output_view_1"}): + render_page_navigation() + + mock_nav.assert_called_once() + assert mock_nav.call_args[1]["next"] is None + + @patch("datasure.views.correction_view.page_navigation") + def test_render_page_navigation_with_replication_page(self, mock_nav): + with _patched_st( + session_state={ + "st_output_page1": "output_view_1", + "st_replication_page": "replication_view", + } + ): + render_page_navigation() + + mock_nav.assert_called_once() + assert mock_nav.call_args[1]["next"] is not None + + +class TestMain: + """Test the main() entry point orchestration.""" + + @patch("datasure.views.correction_view.render_page_navigation") + @patch("datasure.views.correction_view.render_correction_tab") + @patch("datasure.views.correction_view.CorrectionProcessor") + @patch("datasure.views.correction_view.validate_prerequisites") + @patch("datasure.views.correction_view.render_page_header") + @patch("datasure.views.correction_view.add_demo_navigation") + @patch("datasure.views.correction_view.demo_sidebar_help") + def test_renders_a_tab_per_hfc_page( + self, + mock_demo_sidebar, + mock_add_demo_nav, + mock_page_header, + mock_validate, + mock_processor_cls, + mock_render_tab, + mock_page_nav, + ): + mock_validate.return_value = (pl.DataFrame({"x": [1]}), ["Page A", "Page B"]) + mock_processor_instance = MagicMock() + mock_processor_cls.return_value = mock_processor_instance + + with _patched_st( + session_state={"st_project_id": "proj1"}, + tabs=MagicMock( + return_value=[_mock_context_widget(), _mock_context_widget()] + ), + ): + main() + + mock_demo_sidebar.assert_called_once() + mock_add_demo_nav.assert_called_once_with("correction_view", step=6) + mock_page_header.assert_called_once() + mock_validate.assert_called_once_with("proj1") + mock_processor_cls.assert_called_once_with("proj1") + assert mock_render_tab.call_count == 2 + mock_render_tab.assert_any_call(mock_processor_instance, "proj1", 0) + mock_render_tab.assert_any_call(mock_processor_instance, "proj1", 1) + mock_page_nav.assert_called_once()