Files
cdsl-cad/backend/tests/test_incremental_generation.py
T
2026-08-26 14:13:11 +08:00

341 lines
19 KiB
Python

from __future__ import annotations
import asyncio
from dataclasses import replace
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from fastapi import HTTPException # noqa: E402
from app import main as api # noqa: E402
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, validate_fragment # noqa: E402
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan # noqa: E402
from app.services.incremental_generation import IncrementalGenerationRunner # noqa: E402
from app.services.review_renderer import CANONICAL_VIEWS, REVIEW_SIZE, RENDER_SIZE, render_checkpoint # noqa: E402
from app.services.storage import WorkspaceStore # noqa: E402
from app.services.visual_review import _selected_review_views # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
def plan() -> dict:
return {
"schema_version": "cad.generation-plan.v2",
"plan_id": "incremental_test",
"requirements": [
{"id": "req_base", "source": "explicit", "priority": "hard", "description": "base solid"},
{"id": "req_round", "source": "explicit", "priority": "hard", "description": "edge round"},
],
"assumptions": [],
"nodes": [
{
"id": "base", "intent": "base", "atomic_id": "extrude_add_blind", "depends_on": [],
"requirement_ids": ["req_base"], "verification_rules": [], "review_targets": [],
},
{
"id": "round", "intent": "round", "atomic_id": "fillet", "depends_on": ["base"],
"requires_topology": True,
"requirement_ids": ["req_round"], "verification_rules": [], "review_targets": [],
},
],
}
class GenerationPlanTests(unittest.TestCase):
def test_hard_requirements_and_backend_outputs_are_owned(self) -> None:
invalid = plan()
invalid["nodes"][1]["requirement_ids"] = []
with self.assertRaisesRegex(GenerationPlanError, "Hard requirements"):
validate_generation_plan(invalid, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
generated = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
self.assertEqual(generated["id_strategy"], "backend-derived-v1")
self.assertEqual(len(generated["nodes"][0]["cdsl_feature_ids"]), 1)
self.assertEqual(len(generated["nodes"][0]["cdsl_sketch_ids"]), 1)
self.assertEqual(generated["nodes"][1]["cdsl_sketch_ids"], [])
# Legacy fields from an untrusted model output cannot choose CDSL IDs.
supplied = plan()
supplied["nodes"][0]["expected_feature_ids"] = ["model_chosen_id"]
self.assertEqual(
validate_generation_plan(supplied, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")["nodes"][0]["cdsl_feature_ids"],
generated["nodes"][0]["cdsl_feature_ids"],
)
def test_upstream_invalidation_marks_all_descendants_stale(self) -> None:
spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
spec["nodes"][0]["status"] = "completed"
spec["nodes"][1]["status"] = "completed"
self.assertEqual(descendant_closure(spec, "base"), {"base", "round"})
stale = mark_nodes_stale(spec, "base", reason="topology_changed")
self.assertTrue(all(node["stale"] for node in stale["nodes"]))
self.assertTrue(all(node["status"] == "planned" for node in stale["nodes"]))
class FragmentTests(unittest.TestCase):
def setUp(self) -> None:
self.spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")
def base_fragment(self) -> dict:
return {
"schema_version": "cad.cdsl-fragment.v1", "node_id": "base", "base_revision_id": "",
"base_cdsl_sha256": cdsl_sha256(None), "add_sketches": [{}],
"add_features": [{}], "verification_rules": [], "assumptions": [],
}
def test_fragment_assigns_ids_dependencies_and_atomic_from_plan(self) -> None:
accepted = validate_fragment(self.base_fragment(), plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
base = self.spec["nodes"][0]
self.assertEqual(accepted["add_sketches"][0]["id"], base["cdsl_sketch_ids"][0])
self.assertEqual(accepted["add_features"][0]["id"], base["cdsl_feature_ids"][0])
self.assertEqual(accepted["add_features"][0]["sketch_id"], base["cdsl_sketch_ids"][0])
self.assertEqual(accepted["add_features"][0]["atomic_id"], "extrude_add_blind")
self.assertEqual(accepted["add_features"][0]["depends_on"], [])
model_ids = self.base_fragment()
model_ids["add_sketches"][0]["id"] = "model_sketch"
model_ids["add_features"][0].update({"id": "model_feature", "atomic_id": "fillet", "sketch_id": "model_sketch"})
overwritten = validate_fragment(model_ids, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
self.assertEqual(overwritten["add_features"][0]["id"], base["cdsl_feature_ids"][0])
self.assertEqual(overwritten["add_features"][0]["atomic_id"], "extrude_add_blind")
def test_fragment_rejects_wrong_hash_and_output_shape(self) -> None:
wrong_hash = self.base_fragment()
wrong_hash["base_cdsl_sha256"] = "0" * 64
with self.assertRaisesRegex(CdslFragmentError, "sha256"):
validate_fragment(wrong_hash, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
unexpected_sketch = self.base_fragment()
unexpected_sketch["add_sketches"].append({"id": "extra"})
with self.assertRaisesRegex(CdslFragmentError, "one new sketch"):
validate_fragment(unexpected_sketch, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None)
def test_topology_fragment_requires_active_snapshot_owner_and_geometry(self) -> None:
base = self.spec["nodes"][0]
round_node = self.spec["nodes"][1]
base_cdsl = {
"geometry": {"sketches": [{"id": base["cdsl_sketch_ids"][0]}]},
"features": [{"id": base["cdsl_feature_ids"][0], "atomic_id": "extrude_add_blind"}],
}
fragment = {
"schema_version": "cad.cdsl-fragment.v1", "node_id": "round", "base_revision_id": "rev_001",
"base_cdsl_sha256": cdsl_sha256(base_cdsl), "required_snapshot_id": "cad_test/rev_001",
"add_sketches": [],
"add_features": [{
"selectors": [{
"kind": "edge", "stable_id": "body:feature_base:edge:0", "owner_node_id": "base",
"snapshot_id": "cad_test/rev_001", "geometry": {"curve_type": "line", "length_mm": 10},
}],
}],
"verification_rules": [], "assumptions": [],
}
accepted = validate_fragment(
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
required_snapshot_id="cad_test/rev_001",
)
self.assertEqual(accepted["node_id"], "round")
self.assertEqual(accepted["add_features"][0]["id"], round_node["cdsl_feature_ids"][0])
self.assertEqual(accepted["add_features"][0]["depends_on"], [base["cdsl_feature_ids"][0]])
self.assertEqual(accepted["add_features"][0]["selectors"][0]["owner_feature_id"], base["cdsl_feature_ids"][0])
fragment["add_features"][0]["selectors"][0].pop("geometry")
with self.assertRaisesRegex(CdslFragmentError, "geometry signature"):
validate_fragment(
fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl,
required_snapshot_id="cad_test/rev_001",
)
class StorageAndRunnerTests(unittest.TestCase):
def settings(self, root: Path) -> Settings:
provider = ProviderConfig("author", "Author", "https://example.invalid/v1", "secret", (ProviderModel("author-model"),))
return Settings(
task_root=root / "tasks", conversation_root=root / "conversations", library_root=root / "library",
engine_root=ROOT / "backend" / "engine" / "cdsl_engine", llm_base_url="", llm_api_key="", llm_model="author-model",
llm_timeout_s=1, default_provider_id="author", providers=(provider,), incremental_generation=True,
)
def test_rollback_anchor_rewinds_before_affected_nodes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = WorkspaceStore(self.settings(Path(directory)))
task = store.ensure_task(None, "test")
task_id = task["task_id"]
for revision_id, parent, node_id in (("rev_001", "", "base"), ("rev_002", "rev_001", "middle"), ("rev_003", "rev_002", "tip")):
store.update_task(task_id, {"revision_id": revision_id, "status": "success", "parent_revision_id": parent, "node_id": node_id, "visibility": "checkpoint"})
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["middle", "tip"], fallback_revision_id="rev_002"), "rev_001")
self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["base"], fallback_revision_id="rev_001"), "")
store.rollback_to_revision(task_id, "rev_002", branch_id="branch_repair")
revisions = {item["revision_id"]: item for item in (store.read_task(task_id) or {})["revisions"]}
self.assertEqual(revisions["rev_001"]["visibility"], "checkpoint")
self.assertEqual(revisions["rev_002"]["visibility"], "checkpoint")
self.assertEqual(revisions["rev_003"]["visibility"], "superseded")
def test_run_context_is_persisted_for_worker_recovery(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = WorkspaceStore(self.settings(Path(directory)))
task = store.ensure_task(None, "test")
store.write_generation_run_context(task["task_id"], {
"schema_version": "cad.generation-run-context.v1",
"request": "test", "conversation_id": "conv_abcdef123456",
"provider_id": "author", "model_id": "author-model", "author_messages": [], "part_skills": {},
})
recovered = store.read_generation_run_context(task["task_id"])
self.assertEqual(recovered and recovered["request"], "test")
task = store.start_generation(task["task_id"], request="test")
self.assertEqual([item["task_id"] for item in store.running_tasks()], [task["task_id"]])
def test_missing_visual_review_configuration_fails_the_run(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
task = store.ensure_task(None, "test")
async def complete(*_args: object) -> dict:
raise AssertionError("author must not be called before visual configuration validation")
runner = IncrementalGenerationRunner(settings, store, complete)
async def collect() -> list[tuple[str, dict]]:
return [item async for item in runner.run(
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
provider=settings.providers[0], model=settings.providers[0].models[0], author_messages=[],
)]
events = asyncio.run(collect())
self.assertEqual(events[-1][0], "task_terminal")
self.assertEqual(events[-1][1]["lifecycle"], "failed")
self.assertEqual((store.read_task(task["task_id"]) or {})["lifecycle"], "failed")
def test_invalid_plan_is_preserved_in_failure_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
provider = ProviderConfig(
"author", "Author", "https://example.invalid/v1", "secret",
(ProviderModel("author-model", vision=True),),
)
settings = replace(
self.settings(root), providers=(provider,), review_provider_id="author", review_model_id="author-model",
)
store = WorkspaceStore(settings)
task = store.ensure_task(None, "test")
raw_plan = {"schema_version": "cad.generation-plan.v2", "plan_id": "broken", "requirements": []}
async def complete(*_args: object) -> dict:
return {
"choices": [{"message": {"tool_calls": [{"function": {
"name": "plan_generation_task", "arguments": json.dumps(raw_plan),
}}]}}],
}
runner = IncrementalGenerationRunner(settings, store, complete)
engine = type("Engine", (), {"SUPPORTED_ATOMIC_IDS": ("extrude_add_blind",)})()
async def collect() -> list[tuple[str, dict]]:
return [item async for item in runner.run(
task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []},
provider=provider, model=provider.models[0], author_messages=[],
)]
with patch("app.services.incremental_generation.load_engine", return_value=engine), patch(
"app.services.incremental_generation.renderer_status", return_value=(True, "")
):
events = asyncio.run(collect())
self.assertEqual(events[-1], ("task_terminal", {
"taskId": task["task_id"], "lifecycle": "failed",
"message": "Generation plan requires a non-empty requirements array",
}))
failed_task = store.read_task(task["task_id"]) or {}
failure_path = root / "tasks" / task["task_id"] / str(failed_task["run_failure_path"])
with failure_path.open(encoding="utf-8") as handle:
failure = json.load(handle)
diagnostic_path = root / "tasks" / task["task_id"] / str(failure["plan_diagnostic_path"])
with diagnostic_path.open(encoding="utf-8") as handle:
diagnostic = json.load(handle)
self.assertEqual(diagnostic["stage"], "generation_plan_validation")
self.assertEqual(diagnostic["raw_plan"], raw_plan)
class ArtifactAccessTests(unittest.TestCase):
def test_checkpoint_allows_only_the_active_glb_until_publication(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = WorkspaceStore(StorageAndRunnerTests().settings(Path(directory)))
task = store.ensure_task(None, "test")
task_id = task["task_id"]
revision_id, revision_dir = store.next_revision(task_id)
glb = revision_dir / "model.glb"
step = revision_dir / "model.step"
glb.write_bytes(b"glb")
step.write_bytes(b"step")
store.update_task(task_id, {
"revision_id": revision_id,
"status": "success",
"cdsl_path": f"revisions/{revision_id}/model.cdsl.json",
"glb_path": f"revisions/{revision_id}/model.glb",
"step_path": f"revisions/{revision_id}/model.step",
"report_path": f"revisions/{revision_id}/rebuild-report.json",
"visibility": "checkpoint",
})
previous_store = api.store
api.store = store
try:
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.glb"))
self.assertEqual(response.media_type, "model/gltf-binary")
with self.assertRaises(HTTPException) as rejected:
asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
self.assertEqual(rejected.exception.status_code, 403)
store.finish_generation(task_id, lifecycle="completed")
response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step"))
self.assertEqual(response.status_code, 200)
finally:
api.store = previous_store
class TechnicalRenderTests(unittest.TestCase):
def test_cpu_renderer_emits_fixed_views_and_keeps_detail_separate(self) -> None:
from build123d import Box, export_step
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
step_path = root / "box.step"
export_step(Box(30, 20, 10), step_path)
manifest = render_checkpoint(
StorageAndRunnerTests().settings(root),
step_path=step_path,
output_dir=root / "review",
review_targets=[{"bbox_mm": [-5, -5, -5, 5, 5, 5]}],
)
self.assertEqual(manifest["renderer"], "python-occ-hlr-pillow")
views = {item["id"]: item for item in manifest["views"]}
self.assertEqual(set(CANONICAL_VIEWS), set(views) & set(CANONICAL_VIEWS))
self.assertIn("detail-1", views)
self.assertNotEqual(views["isometric"]["path"], views["detail-1"]["path"])
self.assertTrue(all(Path(views[view_id]["path"]).is_file() for view_id in CANONICAL_VIEWS))
self.assertTrue(all(views[view_id]["diagnostics"]["valid"] for view_id in CANONICAL_VIEWS))
self.assertTrue(views["detail-1"]["diagnostics"]["intentional_crop"])
from PIL import Image
with Image.open(views["isometric"]["path"]) as image:
self.assertEqual(image.size, (REVIEW_SIZE, REVIEW_SIZE))
with Image.open(views["isometric"]["high_resolution_path"]) as image:
self.assertEqual(image.size, (RENDER_SIZE, RENDER_SIZE))
def test_routine_visual_review_uses_compact_evidence(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
contact = root / "contact-sheet.jpg"
contact.write_bytes(b"jpg")
manifest = {
"contact_sheet_path": str(contact),
"views": [{"id": view_id, "path": str(root / f"{view_id}.png")} for view_id in (*CANONICAL_VIEWS, "detail-1", "detail-2")],
}
routine = _selected_review_views(manifest, final_checkpoint=False)
final = _selected_review_views(manifest, final_checkpoint=True)
self.assertEqual([item["id"] for item in routine], ["contact-sheet", "detail-1", "detail-2"])
self.assertEqual({item["id"] for item in final}, {"contact-sheet", "detail-1", "detail-2", *CANONICAL_VIEWS})
if __name__ == "__main__":
unittest.main()