feat(tests): add red_review/blue_review states to test state machine
This commit is contained in:
@@ -35,8 +35,12 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
|
# Red Lead reviews the operator's work before it queues for Blue Team
|
||||||
|
red_review = "red_review"
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
|
# Blue Lead reviews the operator's work before cross-validation
|
||||||
|
blue_review = "blue_review"
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
|
# Red Lead reviews the operator's work before it queues for Blue Team
|
||||||
|
red_review = "red_review"
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
|
# Blue Lead reviews the operator's work before cross-validation
|
||||||
|
blue_review = "blue_review"
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
@@ -74,8 +78,10 @@ class TestState(str, enum.Enum):
|
|||||||
# Assign VALID_TRANSITIONS = {
|
# Assign VALID_TRANSITIONS = {
|
||||||
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
||||||
TestState.draft: [TestState.red_executing],
|
TestState.draft: [TestState.red_executing],
|
||||||
TestState.red_executing: [TestState.blue_evaluating],
|
TestState.red_executing: [TestState.red_review],
|
||||||
TestState.blue_evaluating: [TestState.in_review],
|
TestState.red_review: [TestState.blue_evaluating, TestState.red_executing],
|
||||||
|
TestState.blue_evaluating: [TestState.blue_review],
|
||||||
|
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
|
||||||
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
||||||
TestState.disputed: [TestState.validated, TestState.rejected],
|
TestState.disputed: [TestState.validated, TestState.rejected],
|
||||||
TestState.rejected: [TestState.draft],
|
TestState.rejected: [TestState.draft],
|
||||||
@@ -363,10 +369,12 @@ class TestEntity:
|
|||||||
|
|
||||||
# Define function submit_red_evidence
|
# Define function submit_red_evidence
|
||||||
def submit_red_evidence(self) -> int:
|
def submit_red_evidence(self) -> int:
|
||||||
"""Transition the test from ``red_executing`` to ``blue_evaluating``.
|
"""Transition the test from ``red_executing`` to ``red_review``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation).
|
during this phase (for worklog calculation). The Blue Team queue
|
||||||
|
timer does not start yet — that happens in ``approve_red_review``,
|
||||||
|
once the Red Lead actually releases the test to Blue Team.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Total seconds the red phase was paused.
|
int: Total seconds the red phase was paused.
|
||||||
@@ -374,13 +382,9 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.blue_evaluating)
|
self._transition(TestState.red_review)
|
||||||
# Assign total_paused = self.red_paused_seconds + paused_extra
|
# Assign total_paused = self.red_paused_seconds + paused_extra
|
||||||
total_paused = self.red_paused_seconds + paused_extra
|
total_paused = self.red_paused_seconds + paused_extra
|
||||||
# Assign self.blue_started_at = datetime.utcnow()
|
|
||||||
self.blue_started_at = datetime.utcnow()
|
|
||||||
# Assign self.blue_paused_seconds = 0
|
|
||||||
self.blue_paused_seconds = 0
|
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent(
|
self._events.append(DomainEvent(
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -390,9 +394,32 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
|
def approve_red_review(self) -> None:
|
||||||
|
"""Transition the test from ``red_review`` to ``blue_evaluating``.
|
||||||
|
|
||||||
|
Called when the assigned Red Lead approves the operator's work.
|
||||||
|
Starts the Blue Team queue timer.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.blue_evaluating)
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("red_review_approved"))
|
||||||
|
|
||||||
|
def reopen_red_review(self) -> None:
|
||||||
|
"""Transition the test from ``red_review`` back to ``red_executing``.
|
||||||
|
|
||||||
|
Called when the assigned Red Lead sends the work back for rework.
|
||||||
|
Resets the red-phase timer for a fresh attempt (the first attempt's
|
||||||
|
worklog was already recorded at submit time, so this loses nothing).
|
||||||
|
"""
|
||||||
|
self._transition(TestState.red_executing)
|
||||||
|
self.red_started_at = datetime.utcnow()
|
||||||
|
self.red_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("red_review_reopened"))
|
||||||
|
|
||||||
# Define function submit_blue_evidence
|
# Define function submit_blue_evidence
|
||||||
def submit_blue_evidence(self) -> int:
|
def submit_blue_evidence(self) -> int:
|
||||||
"""Transition the test from ``blue_evaluating`` to ``in_review``.
|
"""Transition the test from ``blue_evaluating`` to ``blue_review``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation).
|
during this phase (for worklog calculation).
|
||||||
@@ -403,7 +430,7 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.in_review)
|
self._transition(TestState.blue_review)
|
||||||
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
||||||
total_paused = self.blue_paused_seconds + paused_extra
|
total_paused = self.blue_paused_seconds + paused_extra
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
@@ -415,6 +442,39 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
|
def approve_blue_review(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` to ``in_review``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead approves the operator's work.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.in_review)
|
||||||
|
self._events.append(DomainEvent("blue_review_approved"))
|
||||||
|
|
||||||
|
def reopen_blue_review(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` back to ``blue_evaluating``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead sends the work back for rework.
|
||||||
|
Resets the blue-phase timer for a fresh attempt. Note:
|
||||||
|
``blue_work_started_at`` (the pickup timestamp) lives only on the
|
||||||
|
ORM model, not on this entity — the service layer resets it there.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.blue_evaluating)
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("blue_review_reopened"))
|
||||||
|
|
||||||
|
def flag_blue_review_gap(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` to ``in_review``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead determines the shortfall is a
|
||||||
|
missing capability, not operator error — the test still proceeds
|
||||||
|
to cross-validation since a retry can't fix a capability gap. The
|
||||||
|
gap description itself is stored on the ORM model
|
||||||
|
(``Test.system_gaps``), not on this entity.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.in_review)
|
||||||
|
self._events.append(DomainEvent("blue_review_gap_flagged"))
|
||||||
|
|
||||||
# Define function pause_timer
|
# Define function pause_timer
|
||||||
def pause_timer(self) -> None:
|
def pause_timer(self) -> None:
|
||||||
"""Pause the active phase timer.
|
"""Pause the active phase timer.
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ from app.domain.test_entity import (
|
|||||||
VALID_TRANSITIONS,
|
VALID_TRANSITIONS,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
)
|
)
|
||||||
from app.domain.errors import BusinessRuleViolation, InvalidStateTransition
|
from app.domain.errors import (
|
||||||
|
BusinessRuleViolation,
|
||||||
|
InvalidOperationError,
|
||||||
|
InvalidStateTransition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
@@ -97,9 +101,15 @@ def test_validated_is_terminal():
|
|||||||
("draft", "red_executing", True),
|
("draft", "red_executing", True),
|
||||||
("draft", "validated", False),
|
("draft", "validated", False),
|
||||||
("draft", "blue_evaluating", False),
|
("draft", "blue_evaluating", False),
|
||||||
("red_executing", "blue_evaluating", True),
|
("red_executing", "red_review", True),
|
||||||
|
("red_executing", "blue_evaluating", False),
|
||||||
("red_executing", "draft", False),
|
("red_executing", "draft", False),
|
||||||
("blue_evaluating", "in_review", True),
|
("red_review", "blue_evaluating", True),
|
||||||
|
("red_review", "red_executing", True),
|
||||||
|
("blue_evaluating", "blue_review", True),
|
||||||
|
("blue_evaluating", "in_review", False),
|
||||||
|
("blue_review", "in_review", True),
|
||||||
|
("blue_review", "blue_evaluating", True),
|
||||||
("in_review", "validated", True),
|
("in_review", "validated", True),
|
||||||
("in_review", "rejected", True),
|
("in_review", "rejected", True),
|
||||||
("in_review", "draft", False),
|
("in_review", "draft", False),
|
||||||
@@ -187,10 +197,9 @@ def test_start_execution_from_wrong_state():
|
|||||||
def test_submit_red_evidence():
|
def test_submit_red_evidence():
|
||||||
e = _entity("red_executing", red_started_at=datetime.utcnow())
|
e = _entity("red_executing", red_started_at=datetime.utcnow())
|
||||||
total_paused = e.submit_red_evidence()
|
total_paused = e.submit_red_evidence()
|
||||||
assert e.state == TestState.blue_evaluating
|
assert e.state == TestState.red_review
|
||||||
assert total_paused == 0
|
assert total_paused == 0
|
||||||
assert e.blue_started_at is not None
|
assert e.blue_started_at is None
|
||||||
assert e.blue_paused_seconds == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_red_evidence_auto_resumes():
|
def test_submit_red_evidence_auto_resumes():
|
||||||
@@ -201,13 +210,55 @@ def test_submit_red_evidence_auto_resumes():
|
|||||||
assert total_paused >= 40
|
assert total_paused >= 40
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5b. Lifecycle: red_review gate ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_red_evidence_now_goes_to_red_review():
|
||||||
|
e = _entity("red_executing", red_started_at=datetime.utcnow())
|
||||||
|
total_paused = e.submit_red_evidence()
|
||||||
|
assert e.state == TestState.red_review
|
||||||
|
assert total_paused == 0
|
||||||
|
# blue timer must NOT start yet — that's approve_red_review's job
|
||||||
|
assert e.blue_started_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_red_review_moves_to_blue_evaluating():
|
||||||
|
e = _entity("red_review")
|
||||||
|
e.approve_red_review()
|
||||||
|
assert e.state == TestState.blue_evaluating
|
||||||
|
assert e.blue_started_at is not None
|
||||||
|
assert e.blue_paused_seconds == 0
|
||||||
|
assert any(ev.name == "red_review_approved" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_red_review_wrong_state():
|
||||||
|
e = _entity("red_executing")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.approve_red_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_red_review_moves_to_red_executing():
|
||||||
|
e = _entity("red_review", red_paused_seconds=50)
|
||||||
|
e.reopen_red_review()
|
||||||
|
assert e.state == TestState.red_executing
|
||||||
|
assert e.red_started_at is not None
|
||||||
|
assert e.red_paused_seconds == 0
|
||||||
|
assert any(ev.name == "red_review_reopened" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_red_review_wrong_state():
|
||||||
|
e = _entity("blue_evaluating")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.reopen_red_review()
|
||||||
|
|
||||||
|
|
||||||
# ── 6. Lifecycle: submit_blue_evidence ───────────────────────────────
|
# ── 6. Lifecycle: submit_blue_evidence ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_submit_blue_evidence():
|
def test_submit_blue_evidence():
|
||||||
e = _entity("blue_evaluating", blue_started_at=datetime.utcnow())
|
e = _entity("blue_evaluating", blue_started_at=datetime.utcnow())
|
||||||
total_paused = e.submit_blue_evidence()
|
total_paused = e.submit_blue_evidence()
|
||||||
assert e.state == TestState.in_review
|
assert e.state == TestState.blue_review
|
||||||
assert total_paused == 0
|
assert total_paused == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -219,6 +270,62 @@ def test_submit_blue_evidence_auto_resumes():
|
|||||||
assert total_paused >= 25
|
assert total_paused >= 25
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6b. Lifecycle: blue_review gate ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_blue_review_moves_to_in_review():
|
||||||
|
e = _entity("blue_review")
|
||||||
|
e.approve_blue_review()
|
||||||
|
assert e.state == TestState.in_review
|
||||||
|
assert any(ev.name == "blue_review_approved" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_blue_review_wrong_state():
|
||||||
|
e = _entity("blue_evaluating")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.approve_blue_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_blue_review_moves_to_blue_evaluating():
|
||||||
|
e = _entity("blue_review", blue_paused_seconds=30)
|
||||||
|
e.reopen_blue_review()
|
||||||
|
assert e.state == TestState.blue_evaluating
|
||||||
|
assert e.blue_started_at is not None
|
||||||
|
assert e.blue_paused_seconds == 0
|
||||||
|
assert any(ev.name == "blue_review_reopened" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_blue_review_wrong_state():
|
||||||
|
e = _entity("in_review")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.reopen_blue_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_blue_review_gap_moves_to_in_review():
|
||||||
|
e = _entity("blue_review")
|
||||||
|
e.flag_blue_review_gap()
|
||||||
|
assert e.state == TestState.in_review
|
||||||
|
assert any(ev.name == "blue_review_gap_flagged" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_blue_review_gap_wrong_state():
|
||||||
|
e = _entity("blue_evaluating")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.flag_blue_review_gap()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_red_not_reachable_from_red_review():
|
||||||
|
e = _entity("red_review")
|
||||||
|
with pytest.raises(InvalidOperationError, match="must be in_review"):
|
||||||
|
e.validate_red("approved", by=uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_blue_not_reachable_from_blue_review():
|
||||||
|
e = _entity("blue_review")
|
||||||
|
with pytest.raises(InvalidOperationError, match="must be in_review"):
|
||||||
|
e.validate_blue("approved", by=uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
# ── 7. pause_timer / resume_timer ────────────────────────────────────
|
# ── 7. pause_timer / resume_timer ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -408,9 +515,15 @@ def test_full_lifecycle_happy_path():
|
|||||||
assert e.state == TestState.red_executing
|
assert e.state == TestState.red_executing
|
||||||
|
|
||||||
e.submit_red_evidence()
|
e.submit_red_evidence()
|
||||||
|
assert e.state == TestState.red_review
|
||||||
|
|
||||||
|
e.approve_red_review()
|
||||||
assert e.state == TestState.blue_evaluating
|
assert e.state == TestState.blue_evaluating
|
||||||
|
|
||||||
e.submit_blue_evidence()
|
e.submit_blue_evidence()
|
||||||
|
assert e.state == TestState.blue_review
|
||||||
|
|
||||||
|
e.approve_blue_review()
|
||||||
assert e.state == TestState.in_review
|
assert e.state == TestState.in_review
|
||||||
|
|
||||||
e.validate_red("approved", by=uid_red)
|
e.validate_red("approved", by=uid_red)
|
||||||
@@ -428,7 +541,9 @@ def test_full_lifecycle_rejection_reopen():
|
|||||||
e = _entity("draft")
|
e = _entity("draft")
|
||||||
e.start_execution()
|
e.start_execution()
|
||||||
e.submit_red_evidence()
|
e.submit_red_evidence()
|
||||||
|
e.approve_red_review()
|
||||||
e.submit_blue_evidence()
|
e.submit_blue_evidence()
|
||||||
|
e.approve_blue_review()
|
||||||
e.validate_red("rejected", by=uuid.uuid4())
|
e.validate_red("rejected", by=uuid.uuid4())
|
||||||
assert e.state == TestState.rejected
|
assert e.state == TestState.rejected
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user