"""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()