341 lines
19 KiB
Python
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()
|