Slide-creation-engine / tests /test_p0_quality.py
rigelbar's picture
Add clear slide deck start paths
6d9bd01
Raw
History Blame Contribute Delete
18.1 kB
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