Load Publish (Loaded panel) searches every asset of the project, then loads any of its published .blend files at any version, cast in the shot or not. The file the casting would load and the latest version on this site are preselected; a version not on this site is requested instead. Built on the existing wtd_list_assets / wtd_get_asset_files / wtd_get_revisions commands. New instances are numbered (COL_bob_01, tree_01...), counting on from the highest number in the file (SCENE_MANAGER_INSTANCE_NAME). README: Pick Shot only lists shots that exist in Libreflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
16 KiB
Python
321 lines
16 KiB
Python
"""End-to-end test of the Scene Manager against dev/fake_libreflow.py.
|
|
|
|
blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>
|
|
python dev/fake_libreflow.py <workdir> (keep it running, fresh state)
|
|
blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>
|
|
|
|
Prints every check and exits with an error when one fails. Timers do not run in
|
|
background mode, so the test runs the background-job poller itself (pump).
|
|
"""
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
import bpy
|
|
|
|
WORKDIR = sys.argv[sys.argv.index("--") + 1]
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.dirname(REPO))
|
|
PACKAGE = os.path.basename(REPO)
|
|
addon = importlib.import_module(PACKAGE)
|
|
addon.register()
|
|
config = sys.modules[PACKAGE + ".config"]
|
|
ipc = sys.modules[PACKAGE + ".scene_manager.ipc"]
|
|
sm = sys.modules[PACKAGE + ".scene_manager.operators"]
|
|
lib = sys.modules[PACKAGE + ".scene_manager.library"]
|
|
shot_viewer = sys.modules[PACKAGE + ".shot_viewer"]
|
|
|
|
SERVER_FILE = os.path.join(WORKDIR, "kabaret", "server.json")
|
|
config.LIBREFLOW_SERVER_FILE = SERVER_FILE
|
|
SHOT = "/melvile/films/f01/sequences/s010/shots/p010"
|
|
BOB_FILE = "/melvile/asset_types/main_character/assets/bob/tasks/rigging/files/rig_blend"
|
|
CAR = "/melvile/asset_types/vehicles/assets/car"
|
|
CAR_FILE = CAR + "/tasks/rigging/files/car_blend"
|
|
FAILED = []
|
|
|
|
|
|
def check(label, condition, detail=""):
|
|
print((" ok " if condition else " FAIL ") + label + (f" ({detail})" if detail else ""))
|
|
if not condition:
|
|
FAILED.append(label)
|
|
|
|
|
|
def pump(timeout=15):
|
|
end = time.time() + timeout
|
|
while ipc._jobs or not ipc._results.empty():
|
|
ipc._poll()
|
|
if time.time() > end:
|
|
check("background jobs finish", False, str(ipc._jobs))
|
|
return
|
|
time.sleep(0.05)
|
|
|
|
|
|
def fake(path, body):
|
|
info = json.load(open(SERVER_FILE))
|
|
request = urllib.request.Request(
|
|
f"http://127.0.0.1:{info['port']}{path}",
|
|
data=json.dumps(body).encode(),
|
|
method="POST",
|
|
headers={"Authorization": "Bearer " + info["token"], "Content-Type": "application/json"},
|
|
)
|
|
return json.loads(urllib.request.urlopen(request).read())["result"]
|
|
|
|
|
|
def loaded():
|
|
found = lib.all_instances()
|
|
return {
|
|
library.name: (library.get("wtd_revision"), [i.name for i in found.get(library.name, [])])
|
|
for library in lib.managed_libraries()
|
|
}
|
|
|
|
|
|
def message():
|
|
return sm.STATE.message[0]
|
|
|
|
|
|
bpy.ops.wm.read_homefile(use_empty=True)
|
|
scene = bpy.context.scene
|
|
|
|
print("Connection")
|
|
sm.connect(scene)
|
|
pump()
|
|
check("connected to the session", sm.connected() and sm.current_session().get("session_name") == "libreflow_dev")
|
|
check("user and site known", sm.STATE.ping.get("user") == "nero" and sm.STATE.ping.get("site") == "lyon")
|
|
|
|
print("Context")
|
|
scene.wtd_scene_manager.context_oid = "/melvile/nope"
|
|
pump()
|
|
check("invalid oid reported", not sm.context_label("/melvile/nope")[1])
|
|
scene.wtd_scene_manager.context_oid = SHOT
|
|
pump()
|
|
check("shot context validated", sm.context_label(SHOT) == ("shot: f01 / s010 / p010", True))
|
|
errors = {a["name"]: a["error"] for a in sm.STATE.casting.get(SHOT, {}).get("assets", [])}
|
|
check("casting read", set(errors) == {"bob", "tree", "rock", "lamp", "car", "ghost"}, str(errors))
|
|
|
|
print("Load")
|
|
bpy.ops.wtd_scene.load()
|
|
pump()
|
|
state = loaded()
|
|
check("bob: 2 numbered instances of v002", state.get("bob") == ("v002", ["COL_bob_01", "COL_bob_02"]), str(state.get("bob")))
|
|
check("tree: 1 numbered collection instance", state.get("tree") == ("v001", ["tree_01"]), str(state.get("tree")))
|
|
check("rock: single top-level collection fallback", state.get("rock") == ("v001", ["rock_geo_01"]))
|
|
check("lamp and ghost reported", "lamp" in message() and "ghost" in message(), message())
|
|
check("type collections", {"COL_main_character", "COL_bg_set", "COL_main_props"} <= set(scene.collection.children.keys()))
|
|
check(
|
|
"chars are posable overrides",
|
|
all(ob.override_library for ob in bpy.data.collections["COL_bob_01"].all_objects),
|
|
)
|
|
check("shot manager groups bob as character", "Characters" in shot_viewer.rig_groups(bpy.context.view_layer))
|
|
bpy.ops.wtd_scene.load()
|
|
pump()
|
|
check("load again adds nothing", message().startswith("Nothing new to load"), message())
|
|
|
|
print("Request")
|
|
check("car can be requested", sm.missing_files(scene) == [CAR_FILE], str(sm.missing_files(scene)))
|
|
bpy.ops.wtd_scene.request()
|
|
pump()
|
|
check("car v001 requested", message() == "Requested car v001", message())
|
|
check("nothing left to request", sm.missing_files(scene) == [])
|
|
calls = fake("/_test/calls", {})
|
|
check("request sent to Libreflow", ["wtd_request_revisions", {"revision_oids": [CAR_FILE + "/history/revisions/v001"]}] in calls)
|
|
fake("/_test/download", {"revision_oid": CAR_FILE + "/history/revisions/v001"})
|
|
bpy.ops.wtd_scene.refresh()
|
|
pump()
|
|
bpy.ops.wtd_scene.load(asset_oid=CAR)
|
|
pump()
|
|
check("car loaded once downloaded", loaded().get("car", (None,))[0] == "v001", message())
|
|
|
|
print("LOD")
|
|
tree_lib, car_lib = bpy.data.libraries["tree"], bpy.data.libraries["car"]
|
|
tree, car = lib.instances(tree_lib)[0], lib.instances(car_lib)[0]
|
|
check("numbered LODs: instance shows the first", lib.current_lod(tree_lib, tree) == "0" and tree.instance_collection.name == "COL_tree_LOD0")
|
|
check("named LODs in config order", list(lib.lods(car_lib, car)) == ["high", "mid", "low"], str(list(lib.lods(car_lib, car))))
|
|
check("named LODs: override shows the first in config order", lib.current_lod(car_lib, car) == "high")
|
|
hidden = {c.name: (c.hide_viewport, c.hide_render) for c in lib.lods(car_lib, car).values()}
|
|
check(
|
|
"other LODs hidden (viewport and render)",
|
|
hidden == {"COL_car_LODhigh": (False, False), "COL_car_LODmid": (True, True), "COL_car_LODlow": (True, True)},
|
|
str(hidden),
|
|
)
|
|
evaluated = {ob.name for ob in bpy.context.evaluated_depsgraph_get().objects}
|
|
check("hidden LODs not evaluated", "GEO_COL_car_LODhigh" in evaluated and not evaluated & {"GEO_COL_car_LODmid", "GEO_COL_car_LODlow"})
|
|
bpy.ops.wtd_scene.set_lod(lod="low", library="car", instance=car.name)
|
|
check("override switched to low", lib.current_lod(car_lib, car) == "low")
|
|
for ob in bpy.context.view_layer.objects:
|
|
ob.select_set(ob in (tree, bpy.data.objects["RIG_COL_car"]))
|
|
check("LOD of selected", bpy.ops.wtd_scene.set_lod(lod="1", selected=True) == {"FINISHED"})
|
|
check("only the selected instances having it switched", (lib.current_lod(tree_lib, tree), lib.current_lod(car_lib, car)) == ("1", "low"))
|
|
check("missing LOD refused", bpy.ops.wtd_scene.set_lod(lod="5", library="tree", instance="tree_01") == {"CANCELLED"})
|
|
check("LOD order: list, numbers, names", sorted(["b", "10", "low", "2", "a", "high"], key=lib.lod_order) == ["high", "low", "2", "10", "a", "b"])
|
|
second = lib.add_instance(scene, bpy.context.view_layer, car_lib)
|
|
check(
|
|
"second override: LODs found by their linked names",
|
|
list(lib.lods(car_lib, second)) == ["high", "mid", "low"] and lib.current_lod(car_lib, second) == "high",
|
|
str([c.name for c in second.children]),
|
|
)
|
|
lib.remove_instance(second)
|
|
lib.update("tree", {"name": "v002", "path": os.path.join(WORKDIR, "lib", "tree", "v002", "model.blend")})
|
|
tree = lib.instances(bpy.data.libraries["tree"])[0]
|
|
check("LOD kept by an update", lib.current_lod(bpy.data.libraries["tree"], tree) == "1")
|
|
check("content outside the LODs: instance refused", bpy.ops.wtd_scene.set_lod(lod="0", library="tree", instance="tree_01") == {"CANCELLED"})
|
|
lib.update("tree", {"name": "v003", "path": os.path.join(WORKDIR, "lib", "tree", "v003", "model.blend")})
|
|
tree = lib.instances(bpy.data.libraries["tree"])[0]
|
|
check("LODs gone: whole collection instanced", tree.instance_collection.name == "COL_tree" and not lib.lods(bpy.data.libraries["tree"], tree))
|
|
lib.update("tree", {"name": "v001", "path": os.path.join(WORKDIR, "lib", "tree", "v001", "model.blend")})
|
|
tree = lib.instances(bpy.data.libraries["tree"])[0]
|
|
check("LODs back: default LOD", lib.current_lod(bpy.data.libraries["tree"], tree) == "0")
|
|
|
|
print("Versions")
|
|
check("bob is the latest", sm.version_status(bpy.data.libraries["bob"])[0] == "LATEST")
|
|
fake("/_test/publish", {"file_oid": BOB_FILE, "name": "v003", "available": False})
|
|
sm.refresh_versions()
|
|
pump()
|
|
check("v003 not on this site: pending", sm.version_status(bpy.data.libraries["bob"])[0] == "PENDING")
|
|
bpy.ops.wtd_scene.request(file_oid=BOB_FILE)
|
|
pump()
|
|
status = sm.version_status(bpy.data.libraries["bob"])
|
|
check("v003 requested", status == ("PENDING", "v003 requested, waiting for the download"), str(status))
|
|
fake(
|
|
"/_test/publish",
|
|
{"file_oid": BOB_FILE, "name": "v004", "available": True, "path": os.path.join(WORKDIR, "lib", "bob", "v001", "rig.blend")},
|
|
)
|
|
sm.refresh_versions()
|
|
pump()
|
|
check("v004 available: update", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE")
|
|
bpy.ops.wtd_scene.update(library="bob")
|
|
pump()
|
|
check("bob updated to v004", loaded()["bob"][0] == "v004", message())
|
|
meshes = {c.name: len([o for o in c.all_objects if o.type == "MESH"]) for c in lib.instances(bpy.data.libraries["bob"])}
|
|
check("overrides resynced to v004 (1 mesh)", set(meshes.values()) == {1}, str(meshes))
|
|
|
|
print("Version picker")
|
|
sm.STATE.revisions[BOB_FILE] = ipc.call("wtd_get_revisions", None, file_oid=BOB_FILE)
|
|
sm._REVISION_ITEMS[:] = [(r["name"], r["name"], "", "NONE", i) for i, r in enumerate(sm.STATE.revisions[BOB_FILE])]
|
|
bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v002")
|
|
check("rolled back to v002", loaded()["bob"][0] == "v002")
|
|
check("unavailable v003 refused", bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v003") == {"CANCELLED"})
|
|
fake("/_test/publish", {"file_oid": BOB_FILE, "name": "v005", "available": False})
|
|
sm.STATE.revisions[BOB_FILE] = ipc.call("wtd_get_revisions", None, file_oid=BOB_FILE)
|
|
sm._REVISION_ITEMS[:] = [(r["name"], r["name"], "", "NONE", i) for i, r in enumerate(sm.STATE.revisions[BOB_FILE])]
|
|
bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v005")
|
|
pump()
|
|
check("picking a version not on this site requests it", message() == "Requested bob v005", message())
|
|
check("still on v002", loaded()["bob"][0] == "v002")
|
|
|
|
print("Remove")
|
|
bpy.ops.wtd_scene.remove(instance="COL_bob_02")
|
|
check("one instance removed", loaded()["bob"][1] == ["COL_bob_01"])
|
|
for ob in bpy.context.view_layer.objects:
|
|
ob.select_set(False)
|
|
bpy.data.objects["tree_01"].select_set(True)
|
|
bpy.ops.wtd_scene.remove(selected=True)
|
|
check("selected instance removed", loaded()["tree"][1] == [])
|
|
bpy.ops.wtd_scene.remove("EXEC_DEFAULT", library="rock")
|
|
check("whole asset removed", "rock" not in bpy.data.libraries)
|
|
check("its indirect library removed too", set(bpy.data.libraries.keys()) == {"bob", "car", "tree"}, str(bpy.data.libraries.keys()))
|
|
bpy.ops.wtd_scene.load()
|
|
pump()
|
|
check("load restores what is missing", {k: len(v[1]) for k, v in loaded().items()} == {"bob": 2, "car": 1, "rock": 1, "tree": 1})
|
|
check("numbers go on after the highest one", loaded()["bob"][1] == ["COL_bob_01", "COL_bob_02"], str(loaded()["bob"][1]))
|
|
|
|
print("Load publish")
|
|
CHAIR = "/melvile/asset_types/set_props/assets/chair"
|
|
check("project of the context", sm.project_oid(scene) == "/melvile")
|
|
sm.STATE.assets = ipc.call("wtd_list_assets", None, oid=sm.project_oid(scene))
|
|
check("any asset listed, cast or not", CHAIR in [a["oid"] for a in sm.STATE.assets])
|
|
sm.browse_asset(next(a for a in sm.STATE.assets if a["oid"] == CHAIR))
|
|
chair_file = sm.default_file("set_props", sm.STATE.browse["files"])
|
|
check("default file follows the file patterns", chair_file["label"] == "modeling/chair.blend", chair_file["label"])
|
|
check("versions newest first", [r["name"] for r in chair_file["revisions"]] == ["v002", "v001"])
|
|
check("default version: latest on this site", sm.default_revision(chair_file)["name"] == "v001")
|
|
result = bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=chair_file["file_oid"], revision="v002")
|
|
pump()
|
|
check(
|
|
"version not on this site: requested, not loaded",
|
|
result == {"CANCELLED"} and "chair" not in loaded()
|
|
and ["wtd_request_revisions", {"revision_oids": [chair_file["file_oid"] + "/history/revisions/v002"]}] in fake("/_test/calls", {}),
|
|
)
|
|
bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=chair_file["file_oid"], revision="v001", count=2)
|
|
check("loaded twice, numbered", loaded().get("chair") == ("v001", ["chair_01", "chair_02"]), str(loaded().get("chair")))
|
|
check("its versions followed in Loaded", sm.version_status(bpy.data.libraries["chair"])[0] == "PENDING")
|
|
sm.browse_asset(next(a for a in sm.STATE.assets if a["asset"] == "bob"))
|
|
check(
|
|
"another version of a loaded file refused",
|
|
bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=BOB_FILE, revision="v004") == {"CANCELLED"} and loaded()["bob"][0] == "v002",
|
|
)
|
|
bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=BOB_FILE, revision="v002")
|
|
check("same version: one more numbered instance", loaded()["bob"][1] == ["COL_bob_01", "COL_bob_02", "COL_bob_03"], str(loaded()["bob"][1]))
|
|
|
|
print("Ambiguous file")
|
|
try:
|
|
lib.link_asset(
|
|
{"name": "zzz", "oid": "x", "asset_type": "main_props", "file": {"file_oid": "f"}},
|
|
{"name": "v1", "path": os.path.join(WORKDIR, "lib", "bob", "v001", "rig.blend")},
|
|
SHOT,
|
|
)
|
|
check("ambiguous file refused", False)
|
|
except lib.LoadError as exc:
|
|
check("ambiguous file refused", "top-level collections" in str(exc), str(exc))
|
|
|
|
print("Assetize")
|
|
local = bpy.data.collections.new("COL_lamp")
|
|
scene.collection.children.link(local)
|
|
local.objects.link(bpy.data.objects.new("GEO_lamp", bpy.data.meshes.new("GEO_lamp")))
|
|
check("only free local collections offered", [c.name for c in lib.assetize_candidates()] == ["COL_lamp"])
|
|
sm.STATE.assets = ipc.call("wtd_list_assets", None, oid=SHOT)
|
|
bpy.ops.wtd_scene.assetize(
|
|
"EXEC_DEFAULT", collection="COL_lamp", asset_type="main_props", asset_name="lamp", task="modeling", comment="test"
|
|
)
|
|
pump()
|
|
check("published and replaced", "replaced" in message(), message())
|
|
check("lamp linked", loaded().get("lamp") == ("v001", ["COL_lamp_01"]), str(loaded().get("lamp")))
|
|
check("local collection kept, renamed", "COL_lamp_local" in bpy.data.collections)
|
|
bad = bpy.data.collections.new("COL_bad")
|
|
scene.collection.children.link(bad)
|
|
bpy.ops.wtd_scene.assetize("EXEC_DEFAULT", collection="COL_bad", asset_type="main_props", asset_name="bad", task="layout")
|
|
pump()
|
|
check("unknown task reported", "available: modeling, rigging" in message(), message())
|
|
|
|
print("Save / reopen")
|
|
path = os.path.join(WORKDIR, "scene_manager_test.blend")
|
|
bpy.ops.wm.save_as_mainfile(filepath=path)
|
|
bpy.ops.wm.open_mainfile(filepath=path)
|
|
sm._on_load_post()
|
|
check("everything found after reopening", set(loaded()) == {"bob", "car", "chair", "lamp", "rock", "tree"})
|
|
car_lib = bpy.data.libraries["car"]
|
|
check("LOD kept after reopening", lib.current_lod(car_lib, lib.instances(car_lib)[0]) == "low")
|
|
sm.connect(bpy.context.scene)
|
|
pump()
|
|
check("versions checked again", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE")
|
|
|
|
print("Several sessions")
|
|
fake(
|
|
"/_test/sessions",
|
|
{
|
|
"uid-1": {"session_name": "a", "cluster_name": "melvile", "is_gui": True, "current_oid": SHOT},
|
|
"uid-2": {"session_name": "b", "cluster_name": "melvile", "is_gui": False, "current_oid": "/melvile"},
|
|
},
|
|
)
|
|
bpy.context.window_manager.wtd_libreflow.session_uid = ""
|
|
sm.connect(bpy.context.scene)
|
|
pump()
|
|
check("asks to pick a session", "pick one" in message(), message())
|
|
bpy.ops.wtd_scene.select_session(uid="uid-2")
|
|
pump()
|
|
check("works once a session is picked", sm.context_label(SHOT)[1])
|
|
|
|
print("Libreflow not running")
|
|
config.LIBREFLOW_SERVER_FILE = os.path.join(WORKDIR, "missing.json")
|
|
sm.connect(bpy.context.scene)
|
|
pump()
|
|
check("reported as not running", not sm.connected() and sm.STATE.connection_error == "Libreflow is not running")
|
|
|
|
addon.unregister()
|
|
print(f"\n{len(FAILED)} check(s) failed: {', '.join(FAILED)}" if FAILED else "\nAll checks passed")
|
|
if FAILED:
|
|
raise RuntimeError(f"{len(FAILED)} Scene Manager check(s) failed")
|