Make render status receipt based
This commit is contained in:
parent
21c4992f9c
commit
0fe968c475
10 changed files with 913 additions and 27 deletions
|
|
@ -77,6 +77,171 @@ class DocForgeRenderingTests(unittest.TestCase):
|
|||
self.assertEqual("stale", stale["outputs"][0]["state"])
|
||||
self.assertEqual(first_bytes, output.read_bytes())
|
||||
|
||||
def test_warm_render_status_uses_only_publication_receipts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
rendered = RenderService(Project.open(root)).render("manual")
|
||||
self.assertEqual("current", rendered["receipt"]["state"])
|
||||
|
||||
project = Project.open(root)
|
||||
service = RenderService(project)
|
||||
with (
|
||||
mock.patch.object(
|
||||
project,
|
||||
"load",
|
||||
side_effect=AssertionError("receipt status must not load canonical source"),
|
||||
),
|
||||
mock.patch.object(
|
||||
service,
|
||||
"_prepare",
|
||||
side_effect=AssertionError("receipt status must not render"),
|
||||
),
|
||||
):
|
||||
current = service.status("manual")
|
||||
self.assertEqual("current", current["state"])
|
||||
self.assertEqual("receipt", current["verification"])
|
||||
self.assertEqual("current", current["outputs"][0]["state"])
|
||||
|
||||
output = root / ".docforge/rendered/manual.html"
|
||||
output.write_bytes(output.read_bytes() + b"\n")
|
||||
changed_output = service.status("manual")
|
||||
self.assertEqual("stale", changed_output["state"])
|
||||
self.assertEqual("output_changed", changed_output["outputs"][0]["reason"])
|
||||
|
||||
RenderService(Project.open(root)).render("manual")
|
||||
template = root / "docs/templates/manual.html"
|
||||
template.write_text(
|
||||
template.read_text(encoding="utf-8") + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
changed_template = service.status("manual")
|
||||
self.assertEqual("template_changed", changed_template["outputs"][0]["reason"])
|
||||
|
||||
def test_render_receipt_failures_are_degraded_after_output_publication(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
service = RenderService(Project.open(root))
|
||||
with mock.patch.object(
|
||||
service,
|
||||
"_publish_receipt",
|
||||
side_effect=DocForgeError(
|
||||
"render_receipt_failure",
|
||||
"Synthetic receipt failure",
|
||||
),
|
||||
):
|
||||
result = service.render("manual")
|
||||
|
||||
self.assertEqual("degraded", result["state"])
|
||||
self.assertEqual("published", result["publication"])
|
||||
self.assertEqual("failed", result["receipt"]["state"])
|
||||
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
|
||||
|
||||
def test_render_receipt_refuses_post_render_input_and_output_changes(self) -> None:
|
||||
for changed in ("template", "output", "source"):
|
||||
with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
service = RenderService(Project.open(root))
|
||||
publish = service._publish_receipt
|
||||
|
||||
def mutate_then_publish(
|
||||
snapshot,
|
||||
view,
|
||||
prepared,
|
||||
*,
|
||||
changed_kind=changed,
|
||||
project_root=root,
|
||||
publish_receipt=publish,
|
||||
):
|
||||
if changed_kind == "template":
|
||||
target = project_root / "docs/templates/manual.html"
|
||||
elif changed_kind == "output":
|
||||
target = project_root / ".docforge/rendered/manual.html"
|
||||
else:
|
||||
target = project_root / "docs/content/workflow.md"
|
||||
target.write_bytes(target.read_bytes() + b"\nChanged before receipt.\n")
|
||||
return publish_receipt(snapshot, view, prepared)
|
||||
|
||||
with mock.patch.object(
|
||||
service,
|
||||
"_publish_receipt",
|
||||
side_effect=mutate_then_publish,
|
||||
):
|
||||
result = service.render("manual")
|
||||
|
||||
self.assertEqual("degraded", result["state"])
|
||||
self.assertEqual("published", result["publication"])
|
||||
self.assertNotEqual("current", service.status("manual")["state"])
|
||||
self.assertEqual("stale", service.deep_status("manual")["state"])
|
||||
|
||||
def test_missing_and_corrupt_render_receipts_are_conservative(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
service = RenderService(Project.open(root))
|
||||
service.render("manual")
|
||||
receipt = root / ".docforge/cache/render-receipts/manual.json"
|
||||
|
||||
receipt.unlink()
|
||||
missing = service.status("manual")
|
||||
self.assertEqual("unverified", missing["outputs"][0]["state"])
|
||||
self.assertEqual("receipt_missing", missing["outputs"][0]["reason"])
|
||||
|
||||
receipt.write_text("{not-json", encoding="utf-8")
|
||||
corrupt = service.status("manual")
|
||||
self.assertEqual("unverified", corrupt["outputs"][0]["state"])
|
||||
self.assertEqual("receipt_corrupt", corrupt["outputs"][0]["reason"])
|
||||
|
||||
def test_render_receipt_schema_and_renderer_version_fail_closed(self) -> None:
|
||||
for mutation in ("missing_hash", "renderer_version", "file_identity"):
|
||||
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
service = RenderService(Project.open(root))
|
||||
service.render("manual")
|
||||
receipt_path = root / ".docforge/cache/render-receipts/manual.json"
|
||||
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
|
||||
if mutation == "missing_hash":
|
||||
receipt.pop("output_hash")
|
||||
elif mutation == "renderer_version":
|
||||
receipt["renderer_version"] = "obsolete"
|
||||
else:
|
||||
receipt["output_file"].pop("ctime_ns")
|
||||
receipt_path.write_text(
|
||||
json.dumps(receipt, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
status = service.status("manual")
|
||||
self.assertEqual("unverified", status["outputs"][0]["state"])
|
||||
self.assertEqual(
|
||||
"foreign_or_incompatible_receipt",
|
||||
status["outputs"][0]["reason"],
|
||||
)
|
||||
|
||||
def test_render_status_detects_change_between_bounded_captures(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
service = RenderService(Project.open(root))
|
||||
service.render("manual")
|
||||
receipt_status = service._receipt_status
|
||||
calls = 0
|
||||
|
||||
def mutate_between_captures(descriptor, view, current_state):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
output = root / ".docforge/rendered/manual.html"
|
||||
output.write_bytes(output.read_bytes() + b"\n")
|
||||
return receipt_status(descriptor, view, current_state)
|
||||
|
||||
with mock.patch.object(
|
||||
service,
|
||||
"_receipt_status",
|
||||
side_effect=mutate_between_captures,
|
||||
):
|
||||
result = service.status("manual")
|
||||
|
||||
self.assertEqual("stale", result["state"])
|
||||
self.assertNotEqual("current", result["outputs"][0]["state"])
|
||||
|
||||
def test_changeset_preview_is_deterministic_escaped_and_isolated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
|
|
@ -312,6 +477,7 @@ class DocForgeRenderingTests(unittest.TestCase):
|
|||
("render", "manual"),
|
||||
("render-status", "manual"),
|
||||
("preview", "cli-preview", "manual"),
|
||||
("render-status", "manual", "--deep"),
|
||||
)
|
||||
results: list[dict] = []
|
||||
for command in commands:
|
||||
|
|
@ -322,6 +488,8 @@ class DocForgeRenderingTests(unittest.TestCase):
|
|||
self.assertEqual("current", results[0]["state"])
|
||||
self.assertEqual("current", results[1]["state"])
|
||||
self.assertEqual("current", results[2]["state"])
|
||||
self.assertEqual("receipt", results[1]["verification"])
|
||||
self.assertEqual("deep", results[3]["verification"])
|
||||
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
|
||||
self.assertTrue((root / ".docforge/previews/cli-preview/manual.html").is_file())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue