sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Unit tests for the ImageHub task-pipeline domain (project-workflow §3/§4 transitions).
|
||||
|
||||
The pipeline service is pure (plain functions over ``StageInfo`` lists — no Postgres, no FastAPI),
|
||||
so the whole state machine is exercised here with plain data. The thin HTTP wrappers in
|
||||
``imagehub_routes`` are verified live; this file owns the transition rules.
|
||||
|
||||
Covers: stage ordering, the new-task start state, TP1 finalize (advance / -> Ground Truth),
|
||||
TP2 accept + accept-with-corrections, TP3 reject (-> first stage), the guards (wrong-state finalize/
|
||||
review, bad decision), and RS1 reference-standard (Ground-Truth-only).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from src.imagehub_task_pipeline import (
|
||||
StageInfo,
|
||||
TaskPipelineError,
|
||||
compute_finalize,
|
||||
compute_review,
|
||||
first_stage,
|
||||
initial_transition,
|
||||
order_stages,
|
||||
stage_after,
|
||||
state_for_stage,
|
||||
validate_set_reference,
|
||||
)
|
||||
|
||||
# A canonical pipeline: Label(0) -> Review_1(1) -> Review_2(2). Deliberately unsorted in the list
|
||||
# so order_stages / first_stage / stage_after are actually exercised, not given pre-sorted input.
|
||||
LABEL = StageInfo(id="s-label", kind="label", seq=0)
|
||||
REV1 = StageInfo(id="s-rev1", kind="review", seq=1)
|
||||
REV2 = StageInfo(id="s-rev2", kind="review", seq=2)
|
||||
PIPELINE = [REV2, LABEL, REV1] # out of order on purpose
|
||||
|
||||
|
||||
class StageOrderingTests(unittest.TestCase):
|
||||
def test_order_stages_sorts_by_seq(self):
|
||||
self.assertEqual([s.id for s in order_stages(PIPELINE)], ["s-label", "s-rev1", "s-rev2"])
|
||||
|
||||
def test_first_stage_is_lowest_seq(self):
|
||||
self.assertEqual(first_stage(PIPELINE).id, "s-label")
|
||||
|
||||
def test_first_stage_empty_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
first_stage([])
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_stage_after_returns_next(self):
|
||||
self.assertEqual(stage_after(PIPELINE, "s-label").id, "s-rev1")
|
||||
self.assertEqual(stage_after(PIPELINE, "s-rev1").id, "s-rev2")
|
||||
|
||||
def test_stage_after_last_is_none(self):
|
||||
self.assertIsNone(stage_after(PIPELINE, "s-rev2"))
|
||||
|
||||
def test_stage_after_unknown_or_none_is_none(self):
|
||||
self.assertIsNone(stage_after(PIPELINE, "nope"))
|
||||
self.assertIsNone(stage_after(PIPELINE, None))
|
||||
|
||||
def test_state_for_stage(self):
|
||||
self.assertEqual(state_for_stage(LABEL), "inLabel")
|
||||
self.assertEqual(state_for_stage(REV1), "inReview")
|
||||
|
||||
|
||||
class InitialTransitionTests(unittest.TestCase):
|
||||
def test_new_task_starts_inlabel_at_first_stage(self):
|
||||
t = initial_transition(PIPELINE)
|
||||
self.assertEqual(t.pipeline_state, "inLabel")
|
||||
self.assertEqual(t.current_stage_id, "s-label")
|
||||
self.assertEqual(t.queue_status, "assigned")
|
||||
|
||||
def test_new_task_starts_inreview_when_first_stage_is_review(self):
|
||||
t = initial_transition([REV1])
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev1")
|
||||
|
||||
|
||||
class FinalizeTests(unittest.TestCase):
|
||||
def test_finalize_label_advances_to_first_review(self):
|
||||
t = compute_finalize("inLabel", "s-label", PIPELINE)
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev1")
|
||||
self.assertEqual(t.queue_status, "assigned")
|
||||
|
||||
def test_finalize_advances_label_to_next_label(self):
|
||||
# PreLabel(0,label) -> Label(1,label): finalizing the first stays inLabel at the next.
|
||||
prelabel = StageInfo(id="s-pre", kind="label", seq=0)
|
||||
label = StageInfo(id="s-lab", kind="label", seq=1)
|
||||
t = compute_finalize("inLabel", "s-pre", [prelabel, label])
|
||||
self.assertEqual(t.pipeline_state, "inLabel")
|
||||
self.assertEqual(t.current_stage_id, "s-lab")
|
||||
|
||||
def test_finalize_single_label_goes_to_ground_truth(self):
|
||||
t = compute_finalize("inLabel", "s-label", [LABEL])
|
||||
self.assertEqual(t.pipeline_state, "groundTruth")
|
||||
self.assertIsNone(t.current_stage_id)
|
||||
|
||||
def test_finalize_when_not_inlabel_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
compute_finalize("inReview", "s-rev1", PIPELINE)
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_finalize_when_groundtruth_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError):
|
||||
compute_finalize("groundTruth", None, PIPELINE)
|
||||
|
||||
|
||||
class ReviewTests(unittest.TestCase):
|
||||
def test_accept_advances_to_next_review(self):
|
||||
t = compute_review("inReview", "s-rev1", PIPELINE, "accept")
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev2")
|
||||
|
||||
def test_accept_on_last_review_goes_to_ground_truth(self):
|
||||
t = compute_review("inReview", "s-rev2", PIPELINE, "accept")
|
||||
self.assertEqual(t.pipeline_state, "groundTruth")
|
||||
self.assertIsNone(t.current_stage_id)
|
||||
|
||||
def test_accept_with_corrections_advances(self):
|
||||
t = compute_review("inReview", "s-rev1", PIPELINE, "acceptWithCorrections")
|
||||
self.assertEqual(t.pipeline_state, "inReview")
|
||||
self.assertEqual(t.current_stage_id, "s-rev2")
|
||||
|
||||
def test_reject_returns_to_first_stage(self):
|
||||
t = compute_review("inReview", "s-rev2", PIPELINE, "reject")
|
||||
self.assertEqual(t.pipeline_state, "inLabel")
|
||||
self.assertEqual(t.current_stage_id, "s-label")
|
||||
self.assertEqual(t.queue_status, "assigned")
|
||||
|
||||
def test_review_when_not_inreview_raises_409(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
compute_review("inLabel", "s-label", PIPELINE, "accept")
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_review_invalid_decision_raises_422(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
compute_review("inReview", "s-rev1", PIPELINE, "maybe")
|
||||
self.assertEqual(ctx.exception.status, 422)
|
||||
|
||||
|
||||
class ReferenceStandardTests(unittest.TestCase):
|
||||
def test_set_reference_ok_on_ground_truth(self):
|
||||
# Should not raise.
|
||||
validate_set_reference("groundTruth", True)
|
||||
|
||||
def test_set_reference_blocked_off_ground_truth(self):
|
||||
with self.assertRaises(TaskPipelineError) as ctx:
|
||||
validate_set_reference("inLabel", True)
|
||||
self.assertEqual(ctx.exception.status, 409)
|
||||
|
||||
def test_unset_reference_allowed_in_any_state(self):
|
||||
# Removing the flag is always fine, even mid-pipeline.
|
||||
validate_set_reference("inLabel", False)
|
||||
validate_set_reference("inReview", False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user