from __future__ import annotations import json from course_slide_factory.constants import STAGE_IDS from course_slide_factory.fixtures import ( invalid_layout_job, invalidated_approval_job, missing_objective_mapping_job, missing_visual_asset_job, stale_downstream_job, unsupported_claim_job, valid_minimal_job, ) from course_slide_factory.models import ArtifactStatus, IssueSeverity, IssueType, LayoutSpec from course_slide_factory.quality import ( aggregate_rubric_score, approve_current_artifact, can_unlock_next_stage, check_text_density, compute_artifact_diff, compute_objective_traces, get_current_stage_artifact, get_stage_lock_reasons, grade_stage, has_valid_human_approval, run_export_preflight, upsert_issue, validate_claim_support, validate_layout_spec, ) from course_slide_factory.review import apply_proposed_change_set from course_slide_factory.workflow import ( build_empty_state, final_render_export, generate_stage, improve_with_ai, save_human_edits, update_setup_from_inputs, ) def test_weighted_rubric_score_aggregates_and_clamps(): from course_slide_factory.models import RubricDimensionScore score = aggregate_rubric_score( [ RubricDimensionScore(dimension_id="a", label="A", score=100, weight=3), RubricDimensionScore(dimension_id="b", label="B", score=50, weight=1), ] ) assert score == 88 def test_gating_requires_score_no_blockers_approval_and_current_artifact(): state = valid_minimal_job() stage_id = "setup_inputs" assert can_unlock_next_stage(stage_id, state) state.stages[stage_id].score = 79 assert not can_unlock_next_stage(stage_id, state) state = valid_minimal_job() upsert_issue( state, IssueType.TECHNICAL_INACCURACY, IssueSeverity.BLOCKER, "Blocking issue", stage_id=stage_id, ) assert not can_unlock_next_stage(stage_id, state) state = valid_minimal_job() state.approvals = [approval for approval in state.approvals if approval.stage_id != stage_id] assert not can_unlock_next_stage(stage_id, state) def test_approval_invalidates_after_human_edit_and_ai_improvement(): state = valid_minimal_job() stage_id = "text_generation" assert has_valid_human_approval(stage_id, state) save_human_edits(state, stage_id, "edited text", "Reviewer", "Needs precision", "[]") assert not has_valid_human_approval(stage_id, state) assert any(approval.approval_status == "invalidated" for approval in state.approvals) state = valid_minimal_job() upsert_issue( state, IssueType.TEXT_DENSITY_EXCEEDED, IssueSeverity.MAJOR, "Text can be tightened.", stage_id=stage_id, slide_id="slide_1", ) improve_with_ai(state, stage_id) assert has_valid_human_approval(stage_id, state) change_set_id = next(reversed(state.proposed_change_sets)) apply_proposed_change_set(change_set_id, state) assert not has_valid_human_approval(stage_id, state) def test_upstream_change_marks_downstream_stale_and_locks(): state = stale_downstream_job() for stage_id in STAGE_IDS[STAGE_IDS.index("slide_outline_order") + 1 :]: assert state.stages[stage_id].is_stale assert "Stage is stale." in get_stage_lock_reasons(stage_id, state) assert not can_unlock_next_stage("text_generation", state) def test_objective_traceability_issues(): state = valid_minimal_job() traces = compute_objective_traces(state) assert all(trace.mapped_slide_ids for trace in traces) assert not any( issue.issue_type == IssueType.OBJECTIVE_UNCOVERED for issue in state.issues.values() if not issue.resolved ) state = missing_objective_mapping_job() traces = compute_objective_traces(state) uncovered = [trace for trace in traces if trace.coverage_status == "uncovered"] assert uncovered assert any( issue.issue_type == IssueType.OBJECTIVE_UNCOVERED and issue.severity == IssueSeverity.BLOCKER for issue in state.issues.values() ) state = valid_minimal_job() state.slides["slide_1"].objective_coverage_scores = {"obj_1": 40} traces = compute_objective_traces(state) assert any(trace.coverage_status == "weak" for trace in traces) assert any(issue.issue_type == IssueType.OBJECTIVE_WEAKLY_COVERED for issue in state.issues.values()) def test_claim_support_blocks_technical_review_and_preflight(): state = valid_minimal_job() assert validate_claim_support(state) == [] state = unsupported_claim_job() issues = validate_claim_support(state) assert issues assert issues[0].severity == IssueSeverity.BLOCKER result = grade_stage("technical_review", state) assert not result.passed_threshold report = run_export_preflight(state) assert not report.can_export assert any("unsupported_claim" in issue_id for issue_id in report.blocking_issue_ids) def test_artifact_lifecycle_and_export_require_approved_current_artifacts(): state = build_empty_state( deck_title="Lifecycle", source_url="mock://source", template_url="mock://template", ) state, _message = generate_stage(state, "setup_inputs") artifact = get_current_stage_artifact("setup_inputs", state) assert artifact is not None assert artifact.status == ArtifactStatus.CANDIDATE state.stages["setup_inputs"].score = 90 approve_current_artifact(state, "setup_inputs") assert get_current_stage_artifact("setup_inputs", state).status == ArtifactStatus.APPROVED state = invalidated_approval_job() report = run_export_preflight(state) assert not report.can_export state = stale_downstream_job() report = run_export_preflight(state) assert not report.can_export def test_preflight_fixture_matrix(): assert run_export_preflight(valid_minimal_job()).can_export for fixture in [ missing_objective_mapping_job, unsupported_claim_job, missing_visual_asset_job, invalid_layout_job, stale_downstream_job, invalidated_approval_job, ]: report = run_export_preflight(fixture()) assert not report.can_export state = valid_minimal_job() state.production_export_requested = True assert not run_export_preflight(state).can_export state = valid_minimal_job() state.mutation_target_url = state.source_url assert not run_export_preflight(state).can_export def test_text_density_limits_and_cognitive_load(): state = valid_minimal_job() assert check_text_density("slide_1", state) == [] state = valid_minimal_job() state.slides["slide_1"].visible_text = " ".join(["word"] * 80) issues = check_text_density("slide_1", state) assert any(issue.issue_type == IssueType.TEXT_DENSITY_EXCEEDED for issue in issues) state = valid_minimal_job() state.slides["slide_1"].bullet_points = ["a", "b", "c", "d", "e"] issues = check_text_density("slide_1", state) assert any(issue.issue_type == IssueType.TEXT_DENSITY_EXCEEDED for issue in issues) state = valid_minimal_job() state.slides["slide_1"].objective_ids = ["obj_1", "obj_2", "obj_3"] issues = check_text_density("slide_1", state) assert any(issue.issue_type == IssueType.COGNITIVE_LOAD_HIGH for issue in issues) def test_layout_validation_schema_and_slots(): state = valid_minimal_job() assert validate_layout_spec(state.layout_specs["slide_1"], state) == [] state = valid_minimal_job() unknown = LayoutSpec(slide_id="slide_1", layout_id="unknown", slot_assignments={}) issues = validate_layout_spec(unknown, state) assert any(issue.issue_type == IssueType.LAYOUT_SCHEMA_INVALID for issue in issues) state = valid_minimal_job() missing = LayoutSpec( slide_id="slide_1", layout_id="title_bullets_visual", slot_assignments={"title": "Only title"}, ) issues = validate_layout_spec(missing, state) assert any(issue.issue_type == IssueType.LAYOUT_SLOT_VIOLATION for issue in issues) state = valid_minimal_job() raw = LayoutSpec(slide_id="slide_1", layout_id="title_body", slot_assignments={"x": 1}) issues = validate_layout_spec(raw, state) assert any(issue.issue_type == IssueType.LAYOUT_SCHEMA_INVALID for issue in issues) def test_final_export_marks_approved_artifacts_exported_and_blocks_failures(): state = valid_minimal_job() state, message = final_render_export(state) assert "completed" in message assert get_current_stage_artifact("setup_inputs", state).status == ArtifactStatus.EXPORTED state = unsupported_claim_job() state, message = final_render_export(state) assert "failed" in message assert any(event.event_type == "export_blocked_by_preflight" for event in state.audit_events) def test_artifact_diff_supports_text_and_json(): text_diff = compute_artifact_diff("alpha\n", "beta\n") json_diff = compute_artifact_diff({"b": 2, "a": 1}, {"a": 1, "b": 3}) assert "-alpha" in text_diff assert '+ "b": 3' in json_diff def test_setup_passes_with_uploaded_text_material_only(tmp_path): material_path = tmp_path / "course-notes.md" material_path.write_text("These uploaded notes describe the course objective.", encoding="utf-8") state = build_empty_state() update_setup_from_inputs( state, deck_title="Uploaded Material Deck", source_url=None, template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text="obj_1: Explain uploaded source material.", material_files=[str(material_path)], ) result = grade_stage("setup_inputs", state) assert result.passed_threshold assert state.source_chunks["upload_1"].startswith("These uploaded notes") assert state.uploaded_materials[0]["parsed"] is True def test_setup_passes_with_material_url_only(): state = build_empty_state() update_setup_from_inputs( state, deck_title="URL Material Deck", source_url="https://example.com/course-notes", template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text="obj_1: Explain URL-backed material.", ) result = grade_stage("setup_inputs", state) assert result.passed_threshold assert state.source_url == "https://example.com/course-notes" assert state.source_chunks == {} def test_setup_fails_without_upload_or_material_url(): state = build_empty_state() update_setup_from_inputs( state, deck_title="Missing Material Deck", source_url=None, template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text="obj_1: Explain the material.", ) result = grade_stage("setup_inputs", state) assert not result.passed_threshold def test_imported_json_draft_creates_structured_candidate_artifacts(tmp_path): draft_path = tmp_path / "draft.json" draft_path.write_text( json.dumps( { "objectives": {"obj_1": "Explain imported draft flow."}, "slides": [ { "slide_id": "slide_1", "slide_number": 1, "title": "Imported Draft Slide", "visible_text": "Imported draft text.", "bullet_points": ["Review source", "Approve candidate"], "objective_ids": ["obj_1"], "pedagogical_role": "concept", "speaker_notes": { "slide_id": "slide_1", "notes_text": "Imported notes for the instructor.", }, } ], "claims": [ { "claim_id": "claim_1", "slide_id": "slide_1", "claim_text": "Imported draft text.", "review_status": "unsupported", } ], "layout_specs": [ { "slide_id": "slide_1", "layout_id": "title_body", "approved_template_id": "default_course_template", "slot_assignments": { "title": "Imported Draft Slide", "body": "Imported draft text.", }, } ], "visual_assets": [], } ), encoding="utf-8", ) state = build_empty_state() update_setup_from_inputs( state, deck_title="Imported JSON Draft", source_url="mock://source/imported", template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text=None, start_mode="import_existing_draft", draft_file=str(draft_path), ) assert state.start_mode == "import_existing_draft" assert state.slides["slide_1"].title == "Imported Draft Slide" assert state.slides["slide_1"].speaker_notes.notes_text == "Imported notes for the instructor." assert state.layout_specs["slide_1"].layout_id == "title_body" assert state.claims["claim_1"].review_status == "unsupported" assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE assert get_current_stage_artifact("text_generation", state).status == ArtifactStatus.CANDIDATE assert not has_valid_human_approval("slide_outline_order", state) def test_imported_pptx_draft_creates_slide_records(tmp_path): from pptx import Presentation draft_path = tmp_path / "draft.pptx" presentation = Presentation() title_slide = presentation.slides.add_slide(presentation.slide_layouts[1]) title_slide.shapes.title.text = "PPTX Imported Slide" title_slide.placeholders[1].text = "First bullet\nSecond bullet" presentation.save(draft_path) state = build_empty_state() update_setup_from_inputs( state, deck_title="Imported PPTX Draft", source_url="mock://source/imported", template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text=None, start_mode="import_existing_draft", draft_file=str(draft_path), ) assert state.slides["slide_1"].title == "PPTX Imported Slide" assert "First bullet" in state.slides["slide_1"].visible_text assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE def test_instruction_file_path_seeds_outline_from_sections(tmp_path): instructions_path = tmp_path / "instructions.txt" instructions_path.write_text( "\n".join( [ "Testing logistic regression", "- Purpose of video", " - Explain why testing matters after training.", "- Prerequisites for testing", " - Validation inputs, labels, and learned weights.", "- Testing process", " - Compute probabilities and convert predictions.", "- Key takeaways", ] ), encoding="utf-8", ) state = build_empty_state() update_setup_from_inputs( state, deck_title="Instruction File Deck", source_url=None, template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text=None, material_files=[str(instructions_path)], start_mode="instructions_file", ) state, _message = generate_stage(state, "slide_outline_order") titles = [slide.title for slide in state.slides.values()] assert state.start_mode == "instructions_file" assert state.source_chunks["upload_1"].startswith("Testing logistic regression") assert "Purpose of video" in titles assert "Testing process" in titles assert state.slides["slide_3"].bullet_points == ["Validation inputs, labels, and learned weights."] def test_input_outline_path_seeds_slides_and_context(): state = build_empty_state() update_setup_from_inputs( state, deck_title="Outline Input Deck", source_url=None, template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text=None, source_text="Audience: learners who already trained a classifier.", outline_text="Opening hook\n Why testing matters\nTesting process\n Compute h and accuracy\nWrap-up", start_mode="input_outline", ) state, _message = generate_stage(state, "slide_outline_order") assert state.start_mode == "input_outline" assert state.source_chunks["pasted_context"].startswith("Audience:") assert [slide.title for slide in state.slides.values()] == ["Opening hook", "Testing process", "Wrap-up"] assert state.slides["slide_2"].requires_visual def test_imported_image_draft_creates_visual_candidate_artifact(tmp_path): draft_path = tmp_path / "draft-slide.png" draft_path.write_bytes(b"uploaded image placeholder") state = build_empty_state() update_setup_from_inputs( state, deck_title="Image Draft Deck", source_url=None, template_url="mock://template/course", output_folder_id=None, dry_run=True, objectives_text=None, start_mode="import_existing_draft", draft_file=str(draft_path), ) assert state.start_mode == "import_existing_draft" assert state.slides["slide_1"].title == "draft slide" assert state.visual_assets["asset_1"].path_or_url == str(draft_path) assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE