from __future__ import annotations import json from copy import deepcopy from pathlib import Path import pytest from django.test import Client from django.urls import reverse from control_plane.authoring.book_state import BookStateService, validate_book_state_content from control_plane.authoring.models import ( BookRunStatus, BookStateStatus, SceneDraftStatus, Series, StandaloneScene, StoryProject, Work, ) from control_plane.authoring.standalone_scenes import StandaloneSceneService from control_plane.projects.models import Project from model_router.router import ModelResponseContract pytestmark = pytest.mark.django_db(transaction=True) class FakeReviewRouter: def __init__(self) -> None: self.requests = [] def complete(self, request): self.requests.append(request) return ModelResponseContract( model="test-reviewer", content=json.dumps({"findings": []}), metadata={}, ) @pytest.fixture def work(tmp_path: Path) -> Work: series = Series.objects.create(title="Test Series", slug="test-series") work = Work.objects.create(series=series, title="Test Book", slug="test-book") project = Project.objects.create(name="Test Book", project_type="STORY", goal="Write") StoryProject.objects.create( project=project, work=work, title=work.title, series=series.title, slug=work.slug, artifact_root=str(tmp_path), ) return work def book_content(chapter_count: int = 3) -> dict: chapters = [] for number in range(1, chapter_count + 1): key = f"chapter-{number}" chapters.append( { "chapter_key": key, "number": number, "order": number, "title": f"Chapter {number}", "purpose": f"Advance turn {number}.", "target_words": 1800, "depends_on": [] if number == 1 else [f"chapter-{number - 1}"], "act_id": "act-1", "arc_ids": ["main-arc"], "thread_ids": ["main-thread"], "beats": [ { "beat_id": f"beat-{number}", "text": f"The chapter performs turn {number}.", "required": True, } ], "ending_state": f"Turn {number} is complete.", "scene_refs": [], } ) return { "schema_version": 1, "title": "Test Book", "premise": "A protagonist makes progressively harder choices.", "target_words": chapter_count * 1800, "constraints": ["Preserve agency."], "forbidden_events": [], "acts": [ { "act_id": "act-1", "number": 1, "start_chapter": 1, "end_chapter": chapter_count, } ], "arcs": [ { "arc_id": "main-arc", "start_chapter": "chapter-1", "end_chapter": f"chapter-{chapter_count}", } ], "threads": [ { "thread_id": "main-thread", "start_chapter": "chapter-1", "end_chapter": f"chapter-{chapter_count}", } ], "continuity": [ { "fact_id": "fact-choice", "entity_key": "character.protagonist", "category": "character", "description": "The protagonist has made the first choice.", "established_in": "chapter-1", "resolved_in": f"chapter-{chapter_count}", } ], "chapters": chapters, "ending": "The central choice has lasting consequences.", "open_questions": [], } def passing_reviews(state) -> dict: state_hash = state.sha256 return { "act:act-1": {"passed": True, "state_sha256": state_hash}, "manuscript": {"passed": True, "state_sha256": state_hash}, } def make_review_ready(state) -> None: state.status = BookStateStatus.REVIEW state.reviews = passing_reviews(state) state.save(update_fields=["status", "reviews", "updated_at"]) def test_validation_rejects_forward_dependency_and_sorts_messages(work: Work) -> None: content = book_content() content["chapters"][0]["depends_on"] = ["chapter-2"] result = validate_book_state_content(content, work=work) assert result["valid"] is False assert result["errors"] == sorted(result["errors"]) assert any("must name an earlier chapter" in error for error in result["errors"]) def test_validation_checks_acts_continuity_and_approval_ending(work: Work) -> None: content = book_content() content["acts"][0]["end_chapter"] = 2 content["continuity"][0]["resolved_in"] = "chapter-1" content["continuity"][0]["established_in"] = "chapter-2" content["chapters"][1]["ending_state"] = "" draft = validate_book_state_content(content, work=work) approval = validate_book_state_content(content, work=work, for_approval=True) assert any("ranges must cover every chapter" in error for error in draft["errors"]) assert any("cannot precede establishment" in error for error in draft["errors"]) assert any("ending_state" in warning for warning in draft["warnings"]) assert any("ending_state" in error for error in approval["errors"]) def test_validation_reports_malformed_word_targets_without_raising(work: Work) -> None: content = book_content(1) content["chapters"][0]["target_words"] = "many" result = validate_book_state_content(content, work=work) assert result["valid"] is False assert any("target_words" in error for error in result["errors"]) def test_service_allocates_versions_and_preserves_parent(work: Work) -> None: service = BookStateService(FakeReviewRouter()) first = service.create(work=work, content=book_content()) revised_content = deepcopy(first.content) revised_content["open_questions"] = ["Who notices the cost?"] second = service.revise(first, content=revised_content) assert (first.version, second.version) == (1, 2) assert second.parent == first assert second.change_summary["changed_sections"] == ["open_questions"] assert second.change_summary["changed_from_chapter"] is None first.content["premise"] = "Mutation is forbidden." with pytest.raises(ValueError, match="immutable"): first.save() def test_impact_propagates_contract_change_to_dependents_and_later_continuity( work: Work, ) -> None: service = BookStateService(FakeReviewRouter()) first = service.create(work=work, content=book_content()) changed = deepcopy(first.content) changed["chapters"][1]["purpose"] = "Force a materially different second turn." second = service.revise(first, content=changed) impact = service.impact(second) assert impact["from_chapter"] == 2 assert impact["replan_required"] == ["chapter-2", "chapter-3"] assert impact["continuity_review_required"] == ["chapter-3"] def test_approval_requires_all_reviews_and_rejects_stale_parent(work: Work) -> None: service = BookStateService(FakeReviewRouter()) first = service.create(work=work, content=book_content()) first.status = BookStateStatus.REVIEW first.reviews = {"manuscript": {"passed": True, "state_sha256": first.sha256}} first.save(update_fields=["status", "reviews", "updated_at"]) with pytest.raises(ValueError, match="act:act-1"): service.approve(first) first.reviews = passing_reviews(first) first.save(update_fields=["reviews", "updated_at"]) service.approve(first, actor="editor") child = service.revise(first, content=deepcopy(first.content)) make_review_ready(child) work.current_book_state = None work.save(update_fields=["current_book_state", "updated_at"]) with pytest.raises(ValueError, match="stale"): service.approve(child) def test_approval_allows_review_revisions_before_first_approved_state(work: Work) -> None: service = BookStateService(FakeReviewRouter()) first = service.create(work=work, content=book_content(1)) second_content = deepcopy(first.content) second_content["open_questions"] = ["What must the pilot verify?"] second = service.revise(first, content=second_content) third_content = deepcopy(second.content) third_content["open_questions"] = ["What evidence resolves the review finding?"] third = service.revise(second, content=third_content) make_review_ready(third) approved = service.approve(third, actor="editor") work.refresh_from_db() assert approved.status == BookStateStatus.APPROVED assert work.current_book_state == approved def test_review_routes_structure_and_continuity_to_distinct_capabilities(work: Work) -> None: router = FakeReviewRouter() service = BookStateService(router) state = service.create(work=work, content=book_content()) act_review = service.review(state, "act:act-1") manuscript_review = service.review(state, "manuscript") continuity_review = service.review(state, "continuity") assert act_review["passed"] and manuscript_review["passed"] and continuity_review["passed"] assert [str(request.purpose) for request in router.requests] == [ "STORY_REVIEW", "STORY_REVIEW", "STORY_CONTINUITY", ] def test_run_sync_uses_only_scenes_bound_to_exact_state_and_chapter(work: Work) -> None: service = BookStateService(FakeReviewRouter()) state = service.create(work=work, content=book_content(2)) make_review_ready(state) state = service.approve(state) run = service.start_run(state) run = service.sync_run(run) assert run.status == BookRunStatus.PAUSED assert run.current_chapter_key == "chapter-1" assert run.progress == {"chapter-1": "needs_scene", "chapter-2": "needs_scene"} StandaloneScene.objects.create( work=work, book_state=state, book_chapter_key="chapter-1", scene_key="chapter-1-scene", revision=1, title="Chapter 1 Scene", status=SceneDraftStatus.APPROVED, brief="Perform the first turn.", prose="The protagonist made the first choice.", ) run = service.sync_run(run) assert run.progress["chapter-1"] == "complete" assert run.current_chapter_key == "chapter-2" StandaloneScene.objects.create( work=work, book_state=state, book_chapter_key="chapter-2", scene_key="chapter-2-scene", revision=1, title="Chapter 2 Scene", status=SceneDraftStatus.APPROVED, brief="Perform the second turn.", prose="The protagonist made the second choice.", ) run = service.sync_run(run) assert run.status == BookRunStatus.REVIEW review = service.review_run(run) run.refresh_from_db() assert review["passed"] is True assert run.status == BookRunStatus.COMPLETE assert run.reviews["continuity"]["scene_set_sha256"] assert [item["chapter_key"] for item in run.reviews["continuity"]["scene_manifest"]] == [ "chapter-1", "chapter-2", ] scene_service = StandaloneSceneService(FakeReviewRouter()) with pytest.raises(ValueError, match="start a new book run"): scene_service.create( work=work, title="Chapter 2 Scene", brief="Revise after completion.", book_state=state, book_chapter_key="chapter-2", ) service.start_run(state) revision = scene_service.create( work=work, title="Chapter 2 Scene", brief="Revise in an explicit new run.", book_state=state, book_chapter_key="chapter-2", ) assert revision.revision == 2 def test_lifecycle_persists_audit_metadata_and_run_policy(work: Work) -> None: service = BookStateService(FakeReviewRouter()) state = service.create( work=work, content=book_content(1), actor="planner", context_pack={"sources": ["outline-v2"]}, generation_metadata={"model": "test-planner"}, ) state.status = BookStateStatus.REVIEW state.save(update_fields=["status", "updated_at"]) with pytest.raises(ValueError, match="requires notes"): service.approve(state, actor="editor", force=True) state = service.approve(state, actor="editor", force=True, notes="Manual exception.") run = service.start_run(state, policy={"max_parallel_chapters": 1}) assert state.created_by == "planner" assert state.context_pack == {"sources": ["outline-v2"]} assert state.generation_metadata == {"model": "test-planner"} assert state.approval_forced is True assert state.approval_notes == "Manual exception." assert run.policy == { "max_parallel_chapters": 1, "required_reviews": ["act:act-1", "manuscript"], "run_required_reviews": ["continuity"], } def test_rejection_persists_actor_notes_and_timestamp(work: Work) -> None: service = BookStateService(FakeReviewRouter()) state = service.create(work=work, content=book_content(1)) state = service.reject(state, actor="editor", notes="Dependency needs revision.") assert state.status == BookStateStatus.REJECTED assert state.rejected_at is not None assert state.rejected_by == "editor" assert state.rejection_notes == "Dependency needs revision." def test_book_state_artifact_paths_remain_pinned_through_approval( work: Work, tmp_path: Path ) -> None: service = BookStateService(FakeReviewRouter()) state = service.create(work=work, content=book_content(1)) original_json_uri = state.json_artifact_uri original_markdown_uri = state.markdown_artifact_uri story = work.story_project story.artifact_root = str(tmp_path / "moved-artifacts") story.save(update_fields=["artifact_root", "updated_at"]) make_review_ready(state) state = service.approve(state) assert state.json_artifact_uri == original_json_uri assert state.markdown_artifact_uri == original_markdown_uri def test_book_api_approval_and_run_actions_match_service_contract( work: Work, monkeypatch: pytest.MonkeyPatch ) -> None: service = BookStateService(FakeReviewRouter()) monkeypatch.setattr("control_plane.authoring.views.book_service", lambda: service) client = Client() response = client.post( reverse("book_states"), data=json.dumps( { "series_slug": work.series.slug, "work_slug": work.slug, "content": book_content(1), "actor": "api-planner", "context_pack": {"sources": ["outline-v2"]}, } ), content_type="application/json", ) assert response.status_code == 201 state_id = response.json()["id"] state = work.book_state_versions.get(id=state_id) state.status = BookStateStatus.REVIEW state.save(update_fields=["status", "updated_at"]) invalid_boolean = client.post( reverse("book_state_action", args=[state.id]), data=json.dumps( { "action": "approve", "force": "false", "notes": "This must not be treated as true.", } ), content_type="application/json", ) approval = client.post( reverse("book_state_action", args=[state.id]), data=json.dumps( { "action": "approve", "actor": "api-editor", "force": True, "notes": "Reviewed outside Artifex.", } ), content_type="application/json", ) run = client.post( reverse("book_state_action", args=[state.id]), data=json.dumps({"action": "start_run", "policy": {"max_parallel_chapters": 1}}), content_type="application/json", ) assert invalid_boolean.status_code == 400 assert invalid_boolean.json()["error"] == "force must be boolean" assert approval.status_code == 200 assert approval.json()["approval_forced"] is True assert approval.json()["approval_notes"] == "Reviewed outside Artifex." assert run.status_code == 201 assert run.json()["policy"]["max_parallel_chapters"] == 1 def test_review_rejects_malformed_model_response(work: Work) -> None: class MalformedReviewRouter(FakeReviewRouter): def complete(self, request): self.requests.append(request) return ModelResponseContract(model="bad-reviewer", content="{}", metadata={}) service = BookStateService(MalformedReviewRouter()) state = service.create(work=work, content=book_content(1)) with pytest.raises(ValueError, match="findings list"): service.review(state, "manuscript") def test_sync_rejects_cancelled_run(work: Work) -> None: service = BookStateService(FakeReviewRouter()) state = service.create(work=work, content=book_content(1)) make_review_ready(state) state = service.approve(state) run = service.start_run(state) run.status = BookRunStatus.CANCELLED run.save(update_fields=["status", "updated_at"]) with pytest.raises(ValueError, match="cancelled"): service.sync_run(run) def test_bound_scene_lineage_is_scoped_and_unambiguous(work: Work) -> None: book_service = BookStateService(FakeReviewRouter()) state = book_service.create(work=work, content=book_content(1)) make_review_ready(state) state = book_service.approve(state) scene_service = StandaloneSceneService(FakeReviewRouter()) first = scene_service.create( work=work, title="First Draft", brief="Perform the chapter turn.", book_state=state, book_chapter_key="chapter-1", ) revision = scene_service.create( work=work, title="First Draft", brief="Revise the chapter turn.", book_state=state, book_chapter_key="chapter-1", ) assert revision.parent == first assert revision.revision == 2 unbound = scene_service.create( work=work, title="First Draft", brief="Use the same title outside book-state orchestration.", ) assert scene_service._artifact_paths(first) != scene_service._artifact_paths(unbound) with pytest.raises(ValueError, match="different scene lineage"): scene_service.create( work=work, title="Competing Draft", brief="Create an ambiguous chapter lineage.", book_state=state, book_chapter_key="chapter-1", ) def test_scene_refs_reject_scenes_bound_to_another_book_state(work: Work) -> None: service = BookStateService(FakeReviewRouter()) state = service.create(work=work, content=book_content(1)) make_review_ready(state) state = service.approve(state) scene = StandaloneScene.objects.create( work=work, book_state=state, book_chapter_key="chapter-1", scene_key="bound-source", revision=1, title="Bound Source", status=SceneDraftStatus.APPROVED, brief="A bound scene cannot be reused as an assembly reference.", prose="Approved prose.", ) revised = deepcopy(state.content) revised["chapters"][0]["scene_refs"] = [ {"scene_id": str(scene.id), "revision": scene.revision, "sha256": scene.sha256} ] with pytest.raises(ValueError, match="unbound assembly scenes"): service.revise(state, content=revised)