from __future__ import annotations import json import pytest from course_slide_factory.fixtures import ( constraint_violation_change_set_job, non_waivable_blocker_job, proposed_change_set_job, restore_candidate_version_job, review_packet_job, review_queue_mixed_issues_job, role_filtered_review_job, semantic_diff_changed_slide_job, selected_slide_improvement_job, waivable_major_issue_job, ) from course_slide_factory.models import ArtifactStatus, IssueSeverity, IssueStatus, IssueType, ReviewRole, RevisionConstraints from course_slide_factory.quality import can_unlock_next_stage, get_current_stage_artifact, has_valid_human_approval, upsert_issue from course_slide_factory.review import ( apply_proposed_change_set, build_review_queue, compare_artifact_versions_semantically, compute_reviewer_productivity_metrics, create_review_packet, generate_suggested_fix_for_issue, generate_suggested_fixes, improve_selected_slides, reject_proposed_change_set, restore_artifact_version_as_candidate, update_issue_status, validate_change_set_against_constraints, waive_issue, ) from course_slide_factory.workflow import ( build_empty_state, run_draft_until_next_required_approval, run_generate_and_grade_for_current_stage, ) def _first_issue_id(state, issue_type: IssueType | None = None, severity: IssueSeverity | None = None) -> str: for issue in state.issues.values(): if issue_type and issue.issue_type != issue_type: continue if severity and issue.severity != severity: continue return issue.issue_id raise AssertionError("Expected fixture issue") def test_review_queue_filters_sorting_and_role_visibility(): state = role_filtered_review_job() queue = build_review_queue(state) assert queue[0].severity == IssueSeverity.BLOCKER assert any(item.status == IssueStatus.OPEN for item in queue) visual_queue = build_review_queue(state, role=ReviewRole.VISUAL_DESIGNER) assert any(item.issue_type == IssueType.LAYOUT_SCHEMA_INVALID for item in visual_queue) assert any(item.severity == IssueSeverity.BLOCKER for item in visual_queue) stage_queue = build_review_queue(state, stage_id="text_generation") assert stage_queue assert all(item.stage_id == "text_generation" for item in stage_queue) issue_id = _first_issue_id(state, IssueType.TEXT_DENSITY_EXCEEDED) update_issue_status(issue_id, IssueStatus.ACKNOWLEDGED, state, note="Seen") acknowledged = build_review_queue(state, status=IssueStatus.ACKNOWLEDGED) assert [item.issue_id for item in acknowledged] == [issue_id] def test_issue_lifecycle_waiver_and_wont_fix_rules(tmp_path, monkeypatch): state = waivable_major_issue_job() issue_id = _first_issue_id(state) update_issue_status(issue_id, IssueStatus.ACKNOWLEDGED, state, note="Seen") assert state.issues[issue_id].status == IssueStatus.ACKNOWLEDGED update_issue_status(issue_id, IssueStatus.IN_PROGRESS, state, note="Fixing") assert state.issues[issue_id].status == IssueStatus.IN_PROGRESS update_issue_status(issue_id, IssueStatus.RESOLVED, state, note="Rechecked manually") assert state.issues[issue_id].resolved state = waivable_major_issue_job() issue_id = _first_issue_id(state) waive_issue(issue_id, "Acceptable for pilot", state) assert state.issues[issue_id].status == IssueStatus.WAIVED blocker_state = non_waivable_blocker_job() with pytest.raises(ValueError, match="cannot be waived"): waive_issue(_first_issue_id(blocker_state), "No override", blocker_state) state = waivable_major_issue_job() with pytest.raises(ValueError, match="requires a reason"): update_issue_status(_first_issue_id(state), IssueStatus.WONT_FIX, state) packet_state = review_packet_job() monkeypatch.chdir(tmp_path) packet = create_review_packet(packet_state, format="markdown") assert "Waived Issues" in (tmp_path / packet.path).read_text(encoding="utf-8") def test_suggested_fixes_cover_core_issue_types(): state = review_queue_mixed_issues_job() notes_issue = upsert_issue( state, IssueType.SPEAKER_NOTES_MISSING, IssueSeverity.MAJOR, "Missing notes", stage_id="text_generation", slide_id="slide_2", ) layout_issue = upsert_issue( state, IssueType.LAYOUT_SCHEMA_INVALID, IssueSeverity.BLOCKER, "Bad layout", stage_id="aesthetic_ordering_visual_composition", slide_id="slide_1", ) fixes = generate_suggested_fixes(state) fix_types = {fix.fix_type for fix in fixes} assert "support_claim" in fix_types assert "reduce_text" in fix_types assert generate_suggested_fix_for_issue(notes_issue.issue_id, state).fix_type == "add_speaker_notes" assert generate_suggested_fix_for_issue(layout_issue.issue_id, state).fix_type == "change_layout" def test_critique_change_set_apply_and_reject_lifecycle(): state = proposed_change_set_job() change_set_id = next(iter(state.proposed_change_sets)) old_artifact = get_current_stage_artifact("text_generation", state) old_artifact_count = len(state.artifacts) apply_proposed_change_set(change_set_id, state) assert len(state.artifacts) == old_artifact_count + 1 assert get_current_stage_artifact("text_generation", state).artifact_version_id != old_artifact.artifact_version_id assert not has_valid_human_approval("text_generation", state) assert state.stages["image_visual_asset_generation"].is_stale assert any(event.event_type == "proposed_change_set_applied" for event in state.audit_events) rejected = proposed_change_set_job() change_set_id = next(iter(rejected.proposed_change_sets)) before = len(rejected.artifacts) reject_proposed_change_set(change_set_id, rejected, reason="Not needed") assert len(rejected.artifacts) == before assert rejected.proposed_change_sets[change_set_id].status == "rejected" def test_revision_constraints_block_violating_changes(): state = constraint_violation_change_set_job() change_set = state.proposed_change_sets["changes_constraint_violation"] violations = validate_change_set_against_constraints(change_set, change_set.constraints) assert violations assert "preserve_slide_titles" in violations[0].message original_title = state.slides["slide_1"].title before = len(state.artifacts) apply_proposed_change_set(change_set.change_set_id, state) assert state.slides["slide_1"].title == original_title assert len(state.artifacts) == before assert state.proposed_change_sets[change_set.change_set_id].status == "rejected" def test_targeted_slide_improvement_affects_only_selected_slide(): state = selected_slide_improvement_job() slide_2_text = state.slides["slide_2"].visible_text change_set = improve_selected_slides( "text_generation", ["slide_1"], state, constraints=RevisionConstraints(selected_slide_ids=["slide_1"]), action="rewrite_visible_text", ) assert change_set.changes assert all(change.target_id == "slide_1" for change in change_set.changes) apply_proposed_change_set(change_set.change_set_id, state) assert "Review draft" in state.slides["slide_1"].visible_text assert state.slides["slide_2"].visible_text == slide_2_text def test_fast_path_generates_grades_but_does_not_approve_or_unlock(): state = build_empty_state( deck_title="Fast Path", source_url="mock://source", template_url="mock://template", ) run_generate_and_grade_for_current_stage("setup_inputs", state) assert get_current_stage_artifact("setup_inputs", state).status == ArtifactStatus.CANDIDATE assert state.stages["setup_inputs"].score is not None assert not has_valid_human_approval("setup_inputs", state) assert not can_unlock_next_stage("setup_inputs", state) assert any(event.event_type == "fast_path_generate_grade_stopped" for event in state.audit_events) blocker_state = non_waivable_blocker_job() run_draft_until_next_required_approval("technical_review", blocker_state) assert any( event.event_type == "fast_path_generate_grade_stopped" and event.stage_id == "technical_review" for event in blocker_state.audit_events ) def test_semantic_diff_detects_slide_level_changes(): state = semantic_diff_changed_slide_job() versions = state.stage_artifact_versions["text_generation"][-2:] comparison = compare_artifact_versions_semantically(versions[0], versions[1], state) assert "slide_1" in comparison.changed_titles assert "slide_1" in comparison.changed_slide_ids assert comparison.diff_text def test_review_packet_markdown_json_and_role_filter(tmp_path, monkeypatch): state = review_packet_job() monkeypatch.chdir(tmp_path) markdown_packet = create_review_packet(state, role=ReviewRole.SME, format="markdown") json_packet = create_review_packet(state, role=ReviewRole.SME, format="json") markdown_text = (tmp_path / markdown_packet.path).read_text(encoding="utf-8") json_payload = json.loads((tmp_path / json_packet.path).read_text(encoding="utf-8")) assert "Open Blockers" in markdown_text assert "Suggested Fixes" in markdown_text assert "Objective Coverage Summary" in markdown_text assert any(issue["severity"] == "blocker" for issue in json_payload["issues"]) assert json_payload["role"] == ReviewRole.SME.value def test_candidate_restore_rules(): state = restore_candidate_version_job() candidate_id = next( artifact.artifact_version_id for artifact in state.artifacts.values() if artifact.stage_id == "text_generation" and artifact.status == ArtifactStatus.CANDIDATE and not artifact.is_current ) restore_artifact_version_as_candidate(candidate_id, state) current = get_current_stage_artifact("text_generation", state) assert current.parent_artifact_version_ids == [candidate_id] assert current.status == ArtifactStatus.CANDIDATE assert not has_valid_human_approval("text_generation", state) assert state.stages["image_visual_asset_generation"].is_stale for status in [ArtifactStatus.STALE, ArtifactStatus.INVALIDATED, ArtifactStatus.EXPORTED]: blocked = restore_candidate_version_job() candidate_id = next( artifact.artifact_version_id for artifact in blocked.artifacts.values() if artifact.stage_id == "text_generation" and artifact.status == ArtifactStatus.CANDIDATE and not artifact.is_current ) blocked.artifacts[candidate_id].status = status with pytest.raises(ValueError): restore_artifact_version_as_candidate(candidate_id, blocked) def test_reviewer_productivity_metrics(): state = review_queue_mixed_issues_job() metrics = compute_reviewer_productivity_metrics(state) assert metrics.total_issues >= 3 assert metrics.blocker_count >= 1 assert "technical_review" in metrics.issues_by_stage