Files
olivv-cs-ComfyUI-FunPack/core/tests/test_projects.py
T
digital-garbage faaa465d68 app, core: the inspector, the wheel, ratings, a ruler, and a way in
The surfaces v4 has that v5 did not. Each announces itself and each can be
absent without the rest noticing, which is the point of the exercise: the
frontend should read as v4, and nothing in it should be load-bearing for
anything else.

* SCENE AND PROJECT, in the column v4 keeps them in, which now says which
  scene it is about. Length on the timeline is the scene's own CROP and says
  outright that a regenerate reads the project's -- the rule that mixing the
  two is how a per-scene value quietly becomes everyone's.
* RATINGS on a scene, and a clip carries its rating as a colour on its edge.
  Stored, not yet read: nothing in v5 learns from them yet.
* A RULER, and clips as wide as they are long, so the strip reads as time
  rather than a row of equal boxes. Both ends bounded -- one long clip must
  not become the whole strip, and one short one must stay hittable.
* THE PICKER WHEEL, on the middle button anywhere. It shows whatever
  announced itself to actions.js, so a part of the app that grew a new action
  is in the wheel without the wheel being told, and a part that failed to
  load is simply not among them. v4's three pinned buttons had a fixed idea
  of what could go in them.
* THE WAY IN, and it closes -- on Escape, on the backdrop, on its own button.
  v4's could only be completed: a page refresh was the only way out, which
  makes it a trap rather than a wizard. Dismissing is a real answer.
* File > rename, and the listing moves with it rather than going stale until
  a reload.

Two things the kit refused, correctly, and now nobody has to know: a wheel of
fewer than two items throws (there is nothing to aim between), so the shell
does not offer one; and a strip cell with no ceiling took the whole strip.
2026-09-06 12:30:59 +03:00

290 lines
11 KiB
Python

"""The project store, and the same store over HTTP.
A project is the one thing in the app that has to survive a reload, so the tests
that matter here are about what happens to it when the input is not what the app
would have sent: a hand-edited file, a body that is not an object, an id that is
really a path.
"""
import json
import socket
import threading
import pytest
from core import config, projects
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setattr(config, "PROJECTS_DIR", tmp_path / "projects")
return tmp_path / "projects"
# ── the model ────────────────────────────────────────────────────────────
def test_a_new_project_can_be_typed_into(store):
"""One empty scene, not none: a timeline with nothing on it has nowhere to
put a prompt, so an empty new project would need an Add before it was usable."""
made = projects.create("First")
assert made.name == "First"
assert len(made.scenes) == 1
assert made.scenes[0].text == ""
def test_a_project_survives_a_round_trip(store):
made = projects.create("Round trip")
made.scenes.append(projects.Scene(text="a wolf on a ridge"))
projects.save(made)
read = projects.get(made.id)
assert [s.text for s in read.scenes] == ["", "a wolf on a ridge"]
assert read.name == "Round trip"
def test_scenes_keep_their_order(store):
made = projects.create("Ordered")
made.scenes = [projects.Scene(text=str(i)) for i in range(6)]
projects.save(made)
assert [s.text for s in projects.get(made.id).scenes] == [str(i) for i in range(6)]
def test_a_hand_edited_file_does_not_crash_the_app(store):
"""The store reads files a person can edit. Every one of these used to be an
attribute error on a dict that was a list, or a str that was None."""
made = projects.create("Edited")
path = store / f"{made.id}.json"
for body in ('{"scenes": "not a list"}', '{"scenes": [null, 3, "x"]}',
'{"name": 42}', '[]', '"just a string"', '{}'):
path.write_text(body, encoding="utf-8")
read = projects.get(made.id)
assert read is not None, body
assert isinstance(read.scenes, list)
assert isinstance(read.name, str)
def test_unreadable_json_is_not_a_project(store):
made = projects.create("Broken")
(store / f"{made.id}.json").write_text("{oh no", encoding="utf-8")
assert projects.get(made.id) is None
assert projects.listing() == [] # and it does not take the list down with it
def test_a_scene_without_an_id_gets_one(store):
"""Ids address a scene from the client. Two scenes sharing one, or a scene
with none, is a selection that lands on the wrong row."""
p = projects.Project.from_dict({"scenes": [{"text": "a"}, {"text": "b"}]})
ids = [s.id for s in p.scenes]
assert all(projects.is_id(i) for i in ids)
assert len(set(ids)) == 2
def test_an_id_from_a_request_cannot_be_a_path(store):
"""The filename is a generated id or the call fails. A sanitiser that
repairs "../../x" writes somewhere nobody asked for and says it saved."""
for bad in ("../escape", "a/b", "", "..", "x" * 64, None, 5, "ABCDEF123456"):
assert projects.is_id(bad) is False
assert projects.get(bad) is None
assert projects.delete(bad) is False
def test_the_listing_is_newest_first(store):
a = projects.create("A")
b = projects.create("B")
a.name = "A again"
projects.save(a) # touches updated_at
assert [p["id"] for p in projects.listing()] == [a.id, b.id]
assert [p["name"] for p in projects.listing()][0] == "A again"
def test_a_long_name_is_cut_not_refused(store):
made = projects.create("x" * 500)
assert len(made.name) == projects.MAX_NAME
def test_delete_removes_it(store):
made = projects.create("Doomed")
assert projects.delete(made.id) is True
assert projects.get(made.id) is None
assert projects.delete(made.id) is False
def test_a_save_is_atomic(store):
"""A reader must never see half a project. The temp file is what makes that
true, and a leftover .tmp in the directory would be read as one."""
made = projects.create("Atomic")
projects.save(made)
assert list(store.glob("*.tmp")) == []
assert len(projects.listing()) == 1
# ── over HTTP ────────────────────────────────────────────────────────────
pytest.importorskip("aiohttp")
@pytest.fixture
def server(store):
from aiohttp import web as aioweb
from core import routes
app = aioweb.Application()
table = aioweb.RouteTableDef()
routes.register(table, prefix="/funpack")
app.add_routes(table)
holder = {"ready": threading.Event()}
def run():
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
runner = aioweb.AppRunner(app)
loop.run_until_complete(runner.setup())
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
loop.run_until_complete(site.start())
holder["port"] = site._server.sockets[0].getsockname()[1]
holder["loop"] = loop
holder["ready"].set()
loop.run_forever()
thread = threading.Thread(target=run, daemon=True)
thread.start()
assert holder["ready"].wait(20), "the test server did not start"
yield holder["port"]
holder["loop"].call_soon_threadsafe(holder["loop"].stop)
def _request(port, method, path, body=None):
payload = b"" if body is None else json.dumps(body).encode()
head = (f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(payload)}\r\nConnection: close\r\n\r\n").encode()
with socket.create_connection(("127.0.0.1", port), timeout=30) as sock:
sock.sendall(head + payload)
chunks = []
while True:
got = sock.recv(65536)
if not got:
break
chunks.append(got)
header, _, rest = b"".join(chunks).partition(b"\r\n\r\n")
status = int(header.split()[1])
try:
return status, json.loads(rest.decode() or "{}")
except ValueError:
return status, {"raw": rest.decode(errors="replace")}
def test_create_list_read_save_delete(server):
status, made = _request(server, "POST", "/funpack/api/projects", {"name": "HTTP"})
assert status == 200 and made["name"] == "HTTP"
pid = made["id"]
status, listed = _request(server, "GET", "/funpack/api/projects")
assert status == 200 and [p["id"] for p in listed["projects"]] == [pid]
assert listed["projects"][0]["scenes"] == 1
made["scenes"].append({"text": "a second scene"})
status, saved = _request(server, "PUT", f"/funpack/api/projects/{pid}", made)
assert status == 200 and len(saved["scenes"]) == 2
status, read = _request(server, "GET", f"/funpack/api/projects/{pid}")
assert status == 200 and read["scenes"][1]["text"] == "a second scene"
assert _request(server, "DELETE", f"/funpack/api/projects/{pid}")[0] == 200
assert _request(server, "GET", f"/funpack/api/projects/{pid}")[0] == 404
def test_a_put_cannot_rename_its_own_target(server):
"""The id comes from the URL. A body that could name a different project is
a write over someone else's work, reported as a success."""
_, first = _request(server, "POST", "/funpack/api/projects", {"name": "First"})
_, second = _request(server, "POST", "/funpack/api/projects", {"name": "Second"})
_request(server, "PUT", f"/funpack/api/projects/{first['id']}",
{**first, "id": second["id"], "name": "Overwritten"})
_, untouched = _request(server, "GET", f"/funpack/api/projects/{second['id']}")
assert untouched["name"] == "Second"
_, target = _request(server, "GET", f"/funpack/api/projects/{first['id']}")
assert target["name"] == "Overwritten"
def test_a_body_that_is_not_an_object_is_refused(server):
_, made = _request(server, "POST", "/funpack/api/projects", {"name": "Shape"})
for body in ([], "text", 7):
status, _ = _request(server, "PUT", f"/funpack/api/projects/{made['id']}", body)
assert status == 400, body
def test_saving_something_that_is_not_there_is_a_404(server):
status, _ = _request(server, "PUT", "/funpack/api/projects/0123456789ab", {"name": "Ghost"})
assert status == 404
def test_a_path_shaped_id_is_refused_not_served(server):
for pid in ("..", "%2e%2e", "a/b"):
status, _ = _request(server, "GET", f"/funpack/api/projects/{pid}")
assert status in (404, 400), pid
# --- what the project is generated at ----------------------------------------
def test_video_settings_survive_a_round_trip(store):
made = projects.create("With settings")
made.video = {"width": 832, "height": 480, "length": 97}
projects.save(made)
back = projects.get(made.id)
assert back.video == {"width": 832, "height": 480, "length": 97}
def test_a_project_file_is_never_trusted_for_a_setting():
"""The one thing in this app that outlives the code that wrote it."""
project = projects.Project.from_dict({"video": {
"width": "832", # a number that arrived as text
"height": 480,
"length": 0, # out of range
"batch": -4, # out of range
"huge": 999_999, # out of range
"flag": True, # bool is an int in Python
"name": "wide", # not a number at all
7: 512, # not a name
}})
assert project.video == {"width": 832, "height": 480}
def test_video_is_an_object_or_it_is_nothing():
for junk in ([512, 512], "832x480", 7, None):
assert projects.Project.from_dict({"video": junk}).video == {}
def test_a_project_with_no_settings_has_an_empty_one(store):
assert projects.Project.from_dict({}).video == {}
assert projects.create("Fresh").video == {}
def test_a_scene_keeps_a_crop_and_a_rating(store):
made = projects.create("Rated")
made.scenes = [projects.Scene(text="a cat", length=48, rating="good")]
projects.save(made)
back = projects.get(made.id).scenes[0]
assert back.length == 48
assert back.rating == "good"
def test_a_scene_field_read_back_is_never_trusted():
scene = projects.Scene.from_dict({
"length": "48", # a number that arrived as text
"rating": "excellent", # not a word this app knows
})
assert scene.length == 48
assert scene.rating is None
for junk in (0, -4, True, None, "soon", 999_999):
assert projects.Scene.from_dict({"length": junk}).length is None, junk