351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""HEL-684 — playground takes flow back to their workbench source row.
|
|
|
|
Covers the acceptance list: real-context-only passthrough (no guessing,
|
|
stale rows rejected), explicit flow-back with idempotency (repeated
|
|
operations create no duplicates), cancelled/independent paths never
|
|
writing the project, and playground job deletion clearing the link.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from calliope.db import migrate_db
|
|
from calliope.main import create_app
|
|
|
|
SAMPLE_WORKFLOW = {
|
|
"1": {
|
|
"class_type": "CLIPTextEncode",
|
|
"inputs": {"text": "hello"},
|
|
"_meta": {"title": "Prompt (Input:prompt)"},
|
|
},
|
|
"2": {
|
|
"class_type": "SaveImage",
|
|
"inputs": {"images": ["1", 0]},
|
|
"_meta": {"title": "Save (Output:image)"},
|
|
},
|
|
}
|
|
|
|
SAMPLE_VIDEO_WORKFLOW = {
|
|
"1": {
|
|
"class_type": "CLIPTextEncode",
|
|
"inputs": {"text": "hello"},
|
|
"_meta": {"title": "Prompt (Input:prompt)"},
|
|
},
|
|
"2": {
|
|
"class_type": "SaveVideo",
|
|
"inputs": {"video": ["1", 0]},
|
|
"_meta": {"title": "Save (Output:video)"},
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
import asyncio
|
|
|
|
import calliope.config as config_module
|
|
|
|
s = config_module.settings
|
|
prev = {
|
|
"data_dir": s.data_dir,
|
|
"assets_dir": s.assets_dir,
|
|
"dry_run": s.dry_run,
|
|
}
|
|
s.data_dir = Path(tmpdir)
|
|
s.assets_dir = Path(tmpdir) / "assets"
|
|
s.assets_dir.mkdir(parents=True, exist_ok=True)
|
|
s.dry_run = True
|
|
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
|
|
asyncio.run(migrate_db(s.db_path))
|
|
app = create_app()
|
|
try:
|
|
with TestClient(app) as c:
|
|
yield c
|
|
finally:
|
|
for k, v in prev.items():
|
|
setattr(s, k, v)
|
|
|
|
|
|
def _wait_done(client: TestClient, job_id: int) -> dict:
|
|
for _ in range(60):
|
|
jobs = client.get("/api/playground/jobs").json()
|
|
done = next((j for j in jobs if j["id"] == job_id and j["status"] == "done"), None)
|
|
if done and done.get("output_paths"):
|
|
return done
|
|
time.sleep(0.25)
|
|
raise AssertionError("playground job did not finish in dry-run")
|
|
|
|
|
|
def _seed_assets_stage(client: TestClient) -> dict:
|
|
proj = client.post("/api/projects", json={"title": "Reel"}).json()
|
|
# Real stage rows the way the workbench itself creates them.
|
|
patch = client.patch(
|
|
f"/api/workbench/projects/{proj['id']}/stages/assets",
|
|
json={
|
|
"content": {
|
|
"assets": [
|
|
{
|
|
"id": "asset-1",
|
|
"kind": "character",
|
|
"name": "林夏",
|
|
"description": "",
|
|
"prompt": "a stoic courier",
|
|
"negative_prompt": "",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
)
|
|
assert patch.status_code == 200, patch.text
|
|
return proj
|
|
|
|
|
|
def _context(proj: dict, **over: object) -> dict:
|
|
base = {
|
|
"project_id": proj["id"],
|
|
"stage": "assets",
|
|
"source_kind": "asset",
|
|
"source_id": "asset-1",
|
|
"label": "角色「林夏」",
|
|
}
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
def _make_workflow(client: TestClient, name: str, workflow: dict) -> int:
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={
|
|
"name": name,
|
|
"kind": "video" if "SaveVideo" in str(workflow) else "image",
|
|
"workflow_json": workflow,
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["id"]
|
|
|
|
|
|
# ── context passthrough ────────────────────────────────────────────────
|
|
|
|
|
|
def test_generate_with_context_stamps_job_and_stays_on_playground(client):
|
|
wf_id = _make_workflow(client, "FB Img", SAMPLE_WORKFLOW)
|
|
proj = _seed_assets_stage(client)
|
|
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={
|
|
"workflow_id": wf_id,
|
|
"input_values": {"1": "a cat"},
|
|
"source": "studio-image",
|
|
"context": _context(proj),
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
job = r.json()["job"]
|
|
assert job["payload"]["workbench"]["project_id"] == proj["id"]
|
|
assert job["payload"]["workbench"]["source_id"] == "asset-1"
|
|
assert job["payload"]["workbench"]["label"] == "角色「林夏」"
|
|
|
|
# The job itself rides the hidden playground project — independent
|
|
# desk behavior (feed, SSE, tasks) is untouched.
|
|
scratch = client.get("/api/playground/project").json()
|
|
assert job["project_id"] == scratch["id"]
|
|
|
|
|
|
def test_generate_without_context_writes_nothing_to_any_project(client):
|
|
wf_id = _make_workflow(client, "FB Solo", SAMPLE_WORKFLOW)
|
|
_seed_assets_stage(client)
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
|
|
)
|
|
assert r.status_code == 200
|
|
assert "workbench" not in r.json()["job"]["payload"]
|
|
|
|
|
|
def test_generate_rejects_fake_or_stale_context(client):
|
|
wf_id = _make_workflow(client, "FB Fake", SAMPLE_WORKFLOW)
|
|
proj = _seed_assets_stage(client)
|
|
|
|
# Unknown project
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "context": _context(proj, project_id=99999)},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
# Source row that does not exist in the stage content
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "context": _context(proj, source_id="asset-404")},
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
# Kind mismatch: assets stage wants an image workflow
|
|
video_wf = _make_workflow(client, "FB Vid", SAMPLE_VIDEO_WORKFLOW)
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": video_wf, "context": _context(proj)},
|
|
)
|
|
assert r.status_code == 422
|
|
|
|
# The playground scratch project is not a valid source
|
|
scratch = client.get("/api/playground/project").json()
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "context": _context(proj, project_id=scratch["id"])},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ── flow back ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_attach_workbench_links_take_to_source_row(client):
|
|
wf_id = _make_workflow(client, "FB Link", SAMPLE_WORKFLOW)
|
|
proj = _seed_assets_stage(client)
|
|
job = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
|
).json()["job"]
|
|
|
|
# Not done yet → refused, nothing written.
|
|
early = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
|
assert early.status_code == 400
|
|
|
|
done = _wait_done(client, job["id"])
|
|
|
|
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["already_attached"] is False
|
|
assert body["project_id"] == proj["id"]
|
|
assert body["target_id"] == "asset-1"
|
|
|
|
# The workbench overview now shows the artifact on the source row,
|
|
# through the exact enrichment path in-flow generation uses.
|
|
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
|
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
|
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
|
assert row["job"]["id"] == body["attached_job_id"]
|
|
assert row["job"]["status"] == "done"
|
|
assert row["job"]["output"] == done["output_paths"][0]
|
|
|
|
# Repeated flow-back: idempotent, no duplicate row.
|
|
again = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
|
assert again.status_code == 200
|
|
assert again.json()["already_attached"] is True
|
|
assert again.json()["attached_job_id"] == body["attached_job_id"]
|
|
|
|
# The desk jobs list reports the honest 已回流 marker.
|
|
jobs = client.get("/api/playground/jobs").json()
|
|
listed = next(j for j in jobs if j["id"] == job["id"])
|
|
assert listed["workbench_attach"]["job_id"] == body["attached_job_id"]
|
|
|
|
|
|
def test_attach_workbench_requires_real_context(client):
|
|
wf_id = _make_workflow(client, "FB NoCtx", SAMPLE_WORKFLOW)
|
|
job = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
|
|
).json()["job"]
|
|
_wait_done(client, job["id"])
|
|
|
|
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
|
assert r.status_code == 400
|
|
assert "独立" in r.json()["detail"]
|
|
|
|
|
|
def test_attach_workbench_refuses_regenerated_source(client):
|
|
wf_id = _make_workflow(client, "FB Stale", SAMPLE_WORKFLOW)
|
|
proj = _seed_assets_stage(client)
|
|
job = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
|
).json()["job"]
|
|
_wait_done(client, job["id"])
|
|
|
|
# Stage content regenerated: the old row id is gone.
|
|
client.patch(
|
|
f"/api/workbench/projects/{proj['id']}/stages/assets",
|
|
json={
|
|
"content": {
|
|
"assets": [
|
|
{
|
|
"id": "asset-2",
|
|
"kind": "prop",
|
|
"name": "Blaster",
|
|
"description": "",
|
|
"prompt": "a blaster",
|
|
"negative_prompt": "",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
)
|
|
|
|
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
|
assert r.status_code == 409
|
|
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
|
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
|
assert all("job" not in a for a in assets_stage["content"]["assets"])
|
|
|
|
|
|
def test_attach_workbench_allows_new_flow_job_to_take_over(client):
|
|
"""A later in-flow generation for the same row wins the display, as
|
|
the enrichment keeps the newest workbench job per target."""
|
|
wf_id = _make_workflow(client, "FB Takeover", SAMPLE_WORKFLOW)
|
|
proj = _seed_assets_stage(client)
|
|
job = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
|
).json()["job"]
|
|
_wait_done(client, job["id"])
|
|
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
|
|
|
|
flow = client.post(
|
|
f"/api/workbench/projects/{proj['id']}/generate",
|
|
json={
|
|
"kind": "image",
|
|
"stage_key": "assets",
|
|
"target_id": "asset-1",
|
|
"prompt": "in-flow take",
|
|
},
|
|
)
|
|
assert flow.status_code == 200, flow.text
|
|
|
|
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
|
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
|
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
|
assert row["job"]["id"] != link["attached_job_id"]
|
|
|
|
|
|
def test_delete_playground_job_removes_workbench_link(client):
|
|
wf_id = _make_workflow(client, "FB Del", SAMPLE_WORKFLOW)
|
|
proj = _seed_assets_stage(client)
|
|
job = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
|
).json()["job"]
|
|
done = _wait_done(client, job["id"])
|
|
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
|
|
|
|
dead = client.delete(f"/api/playground/jobs/{job['id']}")
|
|
assert dead.status_code == 200
|
|
assert dead.json()["removed_workbench_links"] == [link["attached_job_id"]]
|
|
|
|
# The link row is gone: the project row no longer points at deleted files.
|
|
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
|
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
|
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
|
assert "job" not in row or row["job"]["id"] != link["attached_job_id"]
|
|
assert not Path(done["output_paths"][0]).exists()
|