File size: 3,185 Bytes
bf9488e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
"""Intent-driven editing contract tests: intent -> plan -> approval-gated execution."""
from __future__ import annotations

import json
import wave

import pytest

from m3d.modules.gltf_mcp import build_module as build_gltf
from m3d.modules.intent_planner import build_module as build_planner
from m3d.modules.mock_edit import build_sfx_module, build_texture_module
from m3d.runner import Runner
from m3d.store import Store
from tests.fixtures import make_character_glb, make_sample_image


def _env(tmp_path):
    store = Store(root=tmp_path / "cas")
    modules = {
        "std/gltf-mcp": build_gltf(store),
        "std/mock-texture": build_texture_module(store),
        "std/mock-sfx": build_sfx_module(store),
        "std/intent-planner": build_planner(store),
    }
    runner = Runner(store, modules, gates_dir=tmp_path / "gates")
    return store, modules, runner


def _hero_asset(tmp_path, store):
    """A hero-knight asset: mesh GLB + texture PNG + footsteps WAV + .asset.json."""
    mesh = tmp_path / "hero.glb"
    make_character_glb(mesh)
    tex = tmp_path / "cloak.png"
    make_sample_image(tex)
    wav_path = tmp_path / "footsteps.wav"
    with wave.open(str(wav_path), "w") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(8000)
        w.writeframes(b"\x00\x00" * 8000)
    manifest = {
        "asset": store.put_file(mesh),
        "canonical": "hero.glb",
        "parts": [
            {"role": "mesh", "uri": str(mesh)},
            {"role": "texture/albedo", "uri": str(tex)},
            {"role": "audio", "uri": str(wav_path)},
        ],
        "provenance": [],
    }
    manifest_uri = store.put_bytes(json.dumps(manifest).encode(), "hero.asset.json")
    return manifest_uri


def test_intent_plan_and_execute(tmp_path):
    store, modules, runner = _env(tmp_path)
    manifest_uri = _hero_asset(tmp_path, store)

    # 1) intent -> plan
    planner = modules["std/intent-planner"]
    res = planner.run_tool("plan", {
        "intent": "make the cloak tattered and the footsteps heavier",
        "manifest_uri": manifest_uri,
    })
    assert res["ok"] is True
    plan_wf = res["plan"]
    node_ids = {n["id"] for n in plan_wf["nodes"]}
    # texture + audio modules AND the Blender human-gate, but rig untouched
    assert "tex" in node_ids and "sfx" in node_ids and "mesh-edit" in node_ids
    assert "rig" not in node_ids

    # the planner must respect the manifest: no matching part -> no invented nodes
    with pytest.raises(ValueError):
        planner.run_tool("plan", {"intent": "re-rig the character", "manifest_uri": manifest_uri})

    # 2) execute the emitted plan through the same runner, approvals pre-seeded
    for nid in ("mesh-edit", "review"):
        stem = Runner._gate_stem(plan_wf["id"], nid)
        (tmp_path / "gates" / f"{stem}.approved.json").write_text(json.dumps({"decision": "approve"}))

    result = runner.run(plan_wf)
    out = result["outputs"]
    assert out["tex"]["output_kind"] == "uri:image"
    assert out["sfx"]["output_kind"] == "uri:audio"
    assert out["review"]["decision"] == "approve"
    assert out["post"]["counts"]["meshes"] == 1  # conditioned edge taken