Artifex/tests/test_book_authoring_state.py

625 lines
22 KiB
Python
Raw Normal View History

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
2026-08-29 00:58:22 +07:00
from model_router.router import ModelCapability, 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={},
)
2026-08-29 00:58:22 +07:00
class OutOfScopePlanningRouter:
def complete(self, request):
assert str(request.purpose) == str(ModelCapability.STORY_PLANNING)
return ModelResponseContract(
model="test-planner",
content=json.dumps(
{
"purpose": "Perform the approved turn.",
"pov_character": "Protagonist",
"tense": "past",
"location": "Records room",
"time_context": "Chapter 1",
"present": ["Protagonist"],
"beats": [
{"text": "The chapter performs turn 1.", "required": True},
{
"text": "The planner adds a Floor ninety-six inspection.",
"required": True,
},
],
"exact_values": [
{"label": "approved turn", "value": "turn 1"},
{"label": "background floor", "value": "Floor ninety-six"},
],
"constraints": [],
"forbidden_events": [],
"ending_state": "Turn 1 is complete.",
"final_image": "The protagonist closes the record.",
"boundary_constraints": [],
"continuity_questions": [],
}
),
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)
2026-08-29 00:14:42 +07:00
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
2026-08-29 00:58:22 +07:00
def test_book_scene_plan_discards_exact_values_outside_approved_scope(work: Work) -> None:
state_service = BookStateService(FakeReviewRouter())
state = state_service.create(work=work, content=book_content(1))
make_review_ready(state)
state = state_service.approve(state)
state_service.start_run(state)
scene_service = StandaloneSceneService(OutOfScopePlanningRouter())
scene = scene_service.create(
work=work,
title="Chapter 1",
brief="Perform turn 1 and stop.",
book_state=state,
book_chapter_key="chapter-1",
)
scene = scene_service.plan(scene)
assert scene.plan["exact_values"] == [
{"label": "approved turn", "value": "turn 1"}
]
assert scene.plan["beats"] == [
{"text": "The chapter performs turn 1.", "required": True}
]
assert scene.plan["purpose"] == "Advance turn 1."
assert scene.plan["ending_state"] == "Turn 1 is complete."
assert scene.generation_metadata["planning"][
"discarded_out_of_scope_exact_values"
] == [{"label": "background floor", "value": "Floor ninety-six"}]
assert scene.generation_metadata["planning"]["discarded_model_proposed_beats"] == [
{
"text": "The planner adds a Floor ninety-six inspection.",
"required": True,
}
]
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)