Skip to content

Commit 3133a1f

Browse files
luis-dkclaude
andcommitted
refactor: extract reusable test-result disposition service
Move test-result disposition logic — the disposition write, the parent test-definition active/lock coupling, and the Passed-row exclusion — out of the test_results UI view into a Streamlit-free service under common/ so it can be reused outside the UI. The view now delegates to it; behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 504c6ab commit 3133a1f

3 files changed

Lines changed: 190 additions & 41 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Shared test-result disposition service.
2+
3+
Sets the disposition on test results and keeps the parent test definition's
4+
active/lock state coupled: a "Muted" (``Disposition.INACTIVE``) disposition
5+
deactivates the test definition and locks it against auto-regeneration; any
6+
other value — or clearing the disposition — reactivates and unlocks it. Passed
7+
test results are never dispositioned. Used by both the Streamlit UI
8+
(test_results page) and the MCP tools. Must not import Streamlit.
9+
"""
10+
from collections.abc import Sequence
11+
from dataclasses import dataclass
12+
from datetime import UTC, datetime
13+
from uuid import UUID
14+
15+
from sqlalchemy import func, select, update
16+
17+
from testgen.common.enums import Disposition
18+
from testgen.common.models import get_current_session
19+
from testgen.common.models.test_definition import TestDefinition
20+
from testgen.common.models.test_result import TestResult, TestResultStatus
21+
22+
23+
@dataclass(frozen=True)
24+
class DispositionUpdate:
25+
"""Outcome of a disposition write.
26+
27+
``matched`` is the number of non-Passed results updated; ``passed_skipped`` is
28+
the number of Passed results that matched but were left unchanged.
29+
"""
30+
31+
matched: int
32+
passed_skipped: int
33+
34+
35+
def coupled_test_definition_state(disposition: Disposition | None) -> tuple[bool, bool]:
36+
"""Return the parent test definition's ``(test_active, lock_refresh)`` for a disposition.
37+
38+
Muted (``INACTIVE``) → ``(False, True)``: deactivate and lock against
39+
auto-regeneration. Any other value, or cleared (``None``) → ``(True, False)``.
40+
"""
41+
deactivate = disposition == Disposition.INACTIVE
42+
return (not deactivate, deactivate)
43+
44+
45+
def coerce_ui_disposition(value: str | None) -> Disposition | None:
46+
"""Map a UI disposition string to the stored value.
47+
48+
The UI passes ``Confirmed`` / ``Dismissed`` / ``Inactive``, or ``No Decision`` /
49+
empty / ``None`` to clear. Unknown values raise ``ValueError`` (caller's bug).
50+
"""
51+
if value in (None, "", "No Decision"):
52+
return None
53+
return Disposition(value)
54+
55+
56+
def set_test_results_disposition(
57+
test_result_ids: Sequence[str | UUID],
58+
disposition: Disposition | None,
59+
) -> DispositionUpdate:
60+
"""Set ``disposition`` on the given results and couple their parent test definitions.
61+
62+
Passed results are excluded from the write. ``disposition=None`` clears it (NULL).
63+
Returns the matched (non-Passed, updated) and passed-skipped counts.
64+
"""
65+
ids = [UUID(str(rid)) for rid in test_result_ids]
66+
if not ids:
67+
return DispositionUpdate(matched=0, passed_skipped=0)
68+
69+
session = get_current_session()
70+
71+
passed_skipped = session.scalar(
72+
select(func.count())
73+
.select_from(TestResult)
74+
.where(TestResult.id.in_(ids), TestResult.status == TestResultStatus.Passed)
75+
) or 0
76+
77+
# NULL result_status rows (e.g. training-mode results) are excluded by `!= Passed`,
78+
# matching the prior UI behavior — disposition only applies to evaluated results.
79+
non_passed = (TestResult.id.in_(ids), TestResult.status != TestResultStatus.Passed)
80+
81+
tr_stmt = (
82+
update(TestResult)
83+
.where(*non_passed)
84+
.values(disposition=disposition.value if disposition is not None else None)
85+
)
86+
matched = session.execute(tr_stmt).rowcount
87+
88+
test_active, lock_refresh = coupled_test_definition_state(disposition)
89+
affected_td_ids = select(TestResult.test_definition_id).where(*non_passed)
90+
td_stmt = (
91+
update(TestDefinition)
92+
.where(TestDefinition.id.in_(affected_td_ids))
93+
.values(
94+
test_active=test_active,
95+
lock_refresh=lock_refresh,
96+
last_manual_update=datetime.now(UTC).replace(tzinfo=None),
97+
)
98+
)
99+
session.execute(td_stmt)
100+
101+
return DispositionUpdate(matched=matched, passed_skipped=passed_skipped)

testgen/ui/views/test_results.py

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
from testgen.common.models.test_definition import TestDefinition, TestDefinitionNote, TestDefinitionSummary
1414
from testgen.common.models.test_suite import TestSuiteMinimal
1515
from testgen.common.pii_masking import get_pii_columns, mask_profiling_pii
16+
from testgen.common.test_result_disposition_service import (
17+
coerce_ui_disposition,
18+
set_test_results_disposition,
19+
)
1620
from testgen.ui.components import widgets as testgen
1721
from testgen.ui.components.widgets.download_dialog import (
1822
FILE_DATA_TYPE,
@@ -31,7 +35,7 @@
3135
get_test_issue_source_query,
3236
get_test_issue_source_query_custom,
3337
)
34-
from testgen.ui.services.database_service import execute_db_query, fetch_df_from_db, fetch_one_from_db
38+
from testgen.ui.services.database_service import fetch_df_from_db, fetch_one_from_db
3539
from testgen.ui.services.query_cache import (
3640
get_table_group_minimal,
3741
get_test_definition,
@@ -911,43 +915,4 @@ def update_result_disposition(
911915
test_result_ids: list[str],
912916
disposition: str,
913917
) -> None:
914-
execute_db_query(
915-
"""
916-
WITH selects
917-
AS (SELECT UNNEST(ARRAY [:test_result_ids]) AS selected_id)
918-
UPDATE test_results
919-
SET disposition = NULLIF(:disposition, 'No Decision')
920-
FROM test_results r
921-
INNER JOIN selects s
922-
ON (r.id = s.selected_id::UUID)
923-
WHERE r.id = test_results.id
924-
AND r.result_status != 'Passed';
925-
""",
926-
{
927-
"test_result_ids": test_result_ids,
928-
"disposition": disposition,
929-
},
930-
)
931-
932-
execute_db_query(
933-
"""
934-
WITH selects
935-
AS (SELECT UNNEST(ARRAY [:test_result_ids]) AS selected_id)
936-
UPDATE test_definitions
937-
SET test_active = :test_active,
938-
last_manual_update = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
939-
lock_refresh = :lock_refresh
940-
FROM test_definitions d
941-
INNER JOIN test_results r
942-
ON (d.id = r.test_definition_id)
943-
INNER JOIN selects s
944-
ON (r.id = s.selected_id::UUID)
945-
WHERE d.id = test_definitions.id
946-
AND r.result_status != 'Passed';
947-
""",
948-
{
949-
"test_result_ids": test_result_ids,
950-
"test_active": "N" if disposition == "Inactive" else "Y",
951-
"lock_refresh": "Y" if disposition == "Inactive" else "N",
952-
},
953-
)
918+
set_test_results_disposition(test_result_ids, coerce_ui_disposition(disposition))
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from unittest.mock import MagicMock, patch
2+
from uuid import uuid4
3+
4+
import pytest
5+
6+
from testgen.common.enums import Disposition
7+
from testgen.common.test_result_disposition_service import (
8+
DispositionUpdate,
9+
coerce_ui_disposition,
10+
coupled_test_definition_state,
11+
set_test_results_disposition,
12+
)
13+
14+
pytestmark = pytest.mark.unit
15+
16+
MODULE = "testgen.common.test_result_disposition_service"
17+
18+
19+
class Test_coupled_test_definition_state:
20+
def test_muted_deactivates_and_locks(self):
21+
# (test_active, lock_refresh)
22+
assert coupled_test_definition_state(Disposition.INACTIVE) == (False, True)
23+
24+
@pytest.mark.parametrize("disposition", [Disposition.CONFIRMED, Disposition.DISMISSED, None])
25+
def test_other_values_reactivate_and_unlock(self, disposition):
26+
assert coupled_test_definition_state(disposition) == (True, False)
27+
28+
29+
class Test_coerce_ui_disposition:
30+
@pytest.mark.parametrize(
31+
"value,expected",
32+
[
33+
("Confirmed", Disposition.CONFIRMED),
34+
("Dismissed", Disposition.DISMISSED),
35+
("Inactive", Disposition.INACTIVE),
36+
("No Decision", None),
37+
(None, None),
38+
("", None),
39+
],
40+
)
41+
def test_maps_ui_string_to_stored_value(self, value, expected):
42+
assert coerce_ui_disposition(value) is expected
43+
44+
45+
class Test_set_test_results_disposition:
46+
def test_empty_ids_is_noop(self):
47+
with patch(f"{MODULE}.get_current_session") as get_session:
48+
result = set_test_results_disposition([], Disposition.CONFIRMED)
49+
assert result == DispositionUpdate(matched=0, passed_skipped=0)
50+
get_session.assert_not_called()
51+
52+
def test_returns_matched_and_passed_counts(self):
53+
session = MagicMock()
54+
# First call: COUNT of passed rows -> 2. Then the TR update returns rowcount 3.
55+
session.scalar.return_value = 2
56+
tr_update_result = MagicMock(rowcount=3)
57+
session.execute.return_value = tr_update_result
58+
with patch(f"{MODULE}.get_current_session", return_value=session):
59+
result = set_test_results_disposition([uuid4(), uuid4()], Disposition.DISMISSED)
60+
assert result == DispositionUpdate(matched=3, passed_skipped=2)
61+
# One TR update + one TD update.
62+
assert session.execute.call_count == 2
63+
64+
def test_muted_couples_td_to_inactive(self):
65+
session = MagicMock()
66+
session.scalar.return_value = 0
67+
session.execute.return_value = MagicMock(rowcount=1)
68+
with patch(f"{MODULE}.get_current_session", return_value=session):
69+
set_test_results_disposition([uuid4()], Disposition.INACTIVE)
70+
td_stmt = session.execute.call_args_list[1].args[0]
71+
params = td_stmt.compile().params
72+
# YNString stores 'N'/'Y'; bound values are the Python bools before type processing.
73+
assert params["test_active"] is False
74+
assert params["lock_refresh"] is True
75+
76+
def test_clear_passes_null_disposition(self):
77+
session = MagicMock()
78+
session.scalar.return_value = 0
79+
session.execute.return_value = MagicMock(rowcount=1)
80+
with patch(f"{MODULE}.get_current_session", return_value=session):
81+
set_test_results_disposition([uuid4()], None)
82+
tr_stmt = session.execute.call_args_list[0].args[0]
83+
assert tr_stmt.compile().params["disposition"] is None

0 commit comments

Comments
 (0)