Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/projects.test.js
T

612 lines
24 KiB
JavaScript

// What the project generates at.
//
// The scene text has its own path (boot wires the prompt box to it); this is
// the other half -- settings that belong to the project rather than to any one
// scene, which is what makes "regenerate" mean "again, at the same size".
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let createProject;
test.before(async () => {
setupDom();
({ createProject } = await import("../projects.js"));
});
test.after(() => teardownDom());
/** A server that remembers the last body it was PUT. */
function server({ video = {}, negative = "" } = {}) {
const sent = [];
const project = { id: "abcdef012345", name: "Untitled", scenes: [], video, negative, updated_at: 1 };
globalThis.fetch = async (path, opts = {}) => {
const body = opts.body ? JSON.parse(opts.body) : null;
if (opts.method === "PUT") sent.push(body);
return {
ok: true, status: 200,
json: async () => (path.endsWith("/projects") && !opts.method
? { projects: [{ id: project.id }] }
: (opts.method === "PUT" ? { ...body, updated_at: 2 } : project)),
};
};
return { sent, project };
}
test("a setting is kept on the project and saved with it", async () => {
const { sent } = server();
const p = createProject({});
await p.start();
p.setVideo("width", 832);
p.setVideo("length", 97);
await p.flush();
assert.deepEqual(p.video, { width: 832, length: 97 });
assert.deepEqual(sent.at(-1).video, { width: 832, length: 97 });
});
test("setting a value to what it already is saves nothing", async () => {
// Every commit of a number box calls this, including the ones that changed
// nothing -- a blur is a commit. Each would be a PUT of the whole project.
const { sent } = server({ video: { width: 512 } });
const p = createProject({});
await p.start();
p.setVideo("width", 512);
await p.flush();
assert.equal(sent.length, 0, "an unchanged value was saved anyway");
p.setVideo("width", 832);
await p.flush();
assert.equal(sent.length, 1);
});
test("the settings a project was opened with are what it reports", async () => {
server({ video: { width: 832, height: 480 } });
const p = createProject({});
await p.start();
assert.deepEqual(p.video, { width: 832, height: 480 });
});
test("with no project open there is nothing to set and nothing breaks", () => {
const p = createProject({});
p.setVideo("width", 832);
assert.deepEqual(p.video, {});
});
test("the negative prompt does not follow a switch to another project", async () => {
// The bug this guards: negative prompt used to have no per-project sync at
// all (unlike video), so it silently carried over from whichever project
// was open last -- typed for one project, sent as another's. This is the
// same store shape as "a run's result still lands on its scene..." above,
// for the same reason: a single store, switched with open(), not two.
const store = {
a: { id: "aaaaaaaaaaaa", name: "A", scenes: [{ id: "scene-a", text: "" }], negative: "", updated_at: 1 },
b: { id: "bbbbbbbbbbbb", name: "B", scenes: [{ id: "scene-b", text: "" }], negative: "", updated_at: 1 },
};
globalThis.fetch = async (path, opts = {}) => {
if (path === "/funpack/api/projects") {
return { ok: true, status: 200, json: async () => ({ projects: [{ id: store.a.id }, { id: store.b.id }] }) };
}
const id = decodeURIComponent(String(path).split("/").pop());
const key = id === store.a.id ? "a" : "b";
if (opts.method === "PUT") {
const body = JSON.parse(opts.body);
store[key] = body;
return { ok: true, status: 200, json: async () => ({ ...body, updated_at: (store[key].updated_at || 1) + 1 }) };
}
return { ok: true, status: 200, json: async () => store[key] };
};
const p = createProject({});
await p.start(); // opens project A
p.setNegative("blurry, low quality");
await p.flush();
await p.open(store.b.id);
assert.equal(p.negative, "", "project B opened holding project A's negative prompt");
});
test("setting the negative prompt to what it already is saves nothing", async () => {
const { sent } = server({ negative: "blurry" });
const p = createProject({});
await p.start();
p.setNegative("blurry");
await p.flush();
assert.equal(sent.length, 0, "an unchanged value was saved anyway");
p.setNegative("smudged");
await p.flush();
assert.equal(sent.length, 1);
});
test("a scene added on the client has the same shape as one the server sent", async () => {
// core's Scene dataclass always has source_image/references -- the
// Inspector reads scene.references unconditionally (a list to map over,
// never a maybe-undefined field). A scene born here that skipped them
// crashed the very next thing that tried to draw it.
server();
const p = createProject({});
await p.start();
p.addScene();
const scene = p.selected;
assert.equal(scene.source_image, null);
assert.deepEqual(scene.references, []);
});
test("a run's result lands on the scene it was started for, not the current one", async () => {
// A run takes minutes and the user goes on clicking. Reading "the selected
// scene" when it finishes attaches the picture to whatever they wandered to.
const { sent } = server();
const p = createProject({});
await p.start();
p.addScene();
p.addScene();
const [first, second] = p.scenes.map((s) => s.id);
const startedFor = first; // what boot captures at Generate
p.select(second); // the user moves on
p.setResult(startedFor, "/view?filename=a.png");
assert.equal(p.scenes[0].result, "/view?filename=a.png");
assert.equal(p.scenes[1].result, null);
await p.flush();
assert.equal(sent.at(-1).scenes[0].result, "/view?filename=a.png");
});
test("a run's result still lands on its scene after the user opened a DIFFERENT project", async () => {
// A run takes minutes; nothing stops the user opening another project while
// one is in flight. `setResult` alone looks the scene id up in whatever is
// open NOW -- finds nothing in the other project, and drops the result with
// no trace. `setResultFor` is what boot.js calls instead once it knows which
// project the run belongs to.
const store = {
a: { id: "aaaaaaaaaaaa", name: "A", scenes: [{ id: "scene-a", text: "", result: null }], updated_at: 1 },
b: { id: "bbbbbbbbbbbb", name: "B", scenes: [{ id: "scene-b", text: "", result: null }], updated_at: 1 },
};
const puts = [];
globalThis.fetch = async (path, opts = {}) => {
if (path === "/funpack/api/projects") {
return { ok: true, status: 200, json: async () => ({ projects: [{ id: store.a.id }, { id: store.b.id }] }) };
}
const id = decodeURIComponent(String(path).split("/").pop());
const key = id === store.a.id ? "a" : "b";
if (opts.method === "PUT") {
const body = JSON.parse(opts.body);
store[key] = body;
puts.push({ key, body });
return { ok: true, status: 200, json: async () => ({ ...body, updated_at: (store[key].updated_at || 1) + 1 }) };
}
return { ok: true, status: 200, json: async () => store[key] };
};
const p = createProject({});
await p.start(); // opens whatever `list()` names first -- project A
const startedInProject = p.project.id;
const startedForScene = p.selected.id;
assert.equal(startedInProject, store.a.id);
await p.open(store.b.id); // the user wanders off to a different project
assert.equal(p.project.id, store.b.id);
await p.setResultFor(startedInProject, startedForScene, "/view?filename=a.png");
// The project on screen (B) must be untouched.
assert.equal(p.scenes[0].result, null);
// Project A, not open, got the result written straight to its saved copy.
const saved = puts.find((x) => x.key === "a");
assert.ok(saved, "project A was never saved");
assert.equal(saved.body.scenes[0].result, "/view?filename=a.png");
});
test("a result for a project that is open again goes through the normal live path", async () => {
// The user switched away and back before the run finished -- the project is
// open again by the time the result lands, so it should update on screen
// immediately rather than only reaching the server.
server();
const p = createProject({});
await p.start();
p.addScene();
const id = p.project.id;
const sceneId = p.selectedId;
await p.setResultFor(id, sceneId, "/view?filename=b.png");
assert.equal(p.scenes[0].result, "/view?filename=b.png");
await p.flush(); // setResultFor's same-project branch schedules a save like
// any other edit -- left pending, its real debounce timer
// outlives this test and can fire mid a LATER one.
});
test("downloadUrl names the current project, and nothing when there is none", async () => {
const p = createProject({});
assert.equal(p.downloadUrl(), null, "a download link before anything is open");
server(); // default project id abcdef012345
await p.start();
assert.equal(p.downloadUrl(), "/funpack/api/projects/abcdef012345/download");
});
test("importing a project file opens whatever the server hands back, as its own project", async () => {
// The server strips the id (a file imported twice, or on the machine it
// came from, must not overwrite anything already here) -- this only has to
// send what the file held and open what comes back.
const posted = [];
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
posted.push(JSON.parse(opts.body));
return { ok: true, status: 200, json: async () => ({
id: "newid000001", name: "Imported", scenes: [{ id: "s1", text: "", result: null }],
video: {}, updated_at: 5,
}) };
}
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
};
const p = createProject({});
const result = await p.importProject({ id: "someoldid0001", name: "Imported", scenes: [] });
assert.equal(p.project.id, "newid000001");
assert.equal(p.project.name, "Imported");
assert.equal(p.selectedId, "s1", "an imported project with scenes has nothing selected");
assert.deepEqual(p.recent.map((r) => r.id), ["newid000001"]);
assert.equal(posted[0].id, "someoldid0001", "the client's job is to send the file, not clean it");
assert.equal(result.id, "newid000001");
assert.equal(p.canUndo, false, "an import just opened has no past to step into");
});
test("a project open when an import happens is not what flush() saves afterward", async () => {
// flush() is called before the import switches `project` -- if it ran
// AFTER, whatever debounced edit was pending on the OLD project would be
// saved against the NEW one's id instead.
const { sent } = server();
const p = createProject({});
await p.start();
p.rename("About to leave"); // scheduled, not yet sent
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
return { ok: true, status: 200, json: async () => ({
id: "newid000002", name: "Imported", scenes: [], video: {}, updated_at: 5 }) };
}
const body = opts.body ? JSON.parse(opts.body) : null;
if (opts.method === "PUT") sent.push(body);
return { ok: true, status: 200, json: async () => (body ? { ...body, updated_at: 2 } : {}) };
};
await p.importProject({ name: "Imported", scenes: [] });
assert.equal(sent.length, 1, "the pending rename was never saved, or was saved against the wrong project");
assert.equal(sent[0].id, "abcdef012345", "flushed against the OLD project, which is what flush() is for");
assert.equal(p.project.id, "newid000002");
});
test("an edit made WHILE an import is in flight is still saved, against the old project", async () => {
// The gap this closes: importProject()'s OWN initial flush() finds nothing
// dirty (the edit hasn't happened yet), then the edit lands DURING the
// import's network round trip, on the project still open at that moment.
// scheduleSave() has to remember which object that was -- the debounce
// timer fires only after the import has already switched `project` out
// from under it.
const { sent } = server();
const p = createProject({});
await p.start(); // project abcdef012345
let resolveImport;
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
return new Promise((resolve) => {
resolveImport = () => resolve({ ok: true, status: 200, json: async () => (
{ id: "newid000003", name: "Imported", scenes: [], video: {}, updated_at: 5 }) });
});
}
const body = opts.body ? JSON.parse(opts.body) : null;
if (opts.method === "PUT") sent.push(body);
return { ok: true, status: 200, json: async () => (body ? { ...body, updated_at: 2 } : {}) };
};
const importing = p.importProject({ name: "Imported", scenes: [] });
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
assert.equal(p.project.id, "abcdef012345", "the import switched before the edit -- this test proves nothing");
p.rename("Typed while importing"); // scheduled against the still-current OLD project
resolveImport();
await importing;
assert.equal(p.project.id, "newid000003", "the import did not complete");
await new Promise((r) => setTimeout(r, 700)); // past the real debounce window
// Only this edit's own PUT: an earlier test's still-pending debounce can fire into this fetch too.
const mine = sent.filter((body) => body.name === "Typed while importing");
assert.equal(mine.length, 1, "the edit made during the import never reached the server");
assert.equal(mine[0].id, "abcdef012345", "saved against the OLD project, not the one now open");
});
test("two imports racing land on whichever was clicked last, not whichever answered last", async () => {
const p = createProject({});
await p.start();
const pending = {};
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
const body = JSON.parse(opts.body);
return new Promise((resolve) => {
pending[body.name] = () => resolve({ ok: true, status: 200, json: async () => (
{ id: `id-${body.name}`, name: body.name, scenes: [], video: {}, updated_at: 5 }) });
});
}
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
};
const first = p.importProject({ name: "A", scenes: [] }); // clicked first
await Promise.resolve();
const second = p.importProject({ name: "B", scenes: [] }); // clicked second, while A is still in flight
await Promise.resolve();
// A answers LAST, after B -- a real, ordinary network race, not a
// contrived one.
pending.B();
await second;
assert.equal(p.project.name, "B");
pending.A();
await first;
assert.equal(p.project.name, "B", "the earlier, superseded import overwrote the one clicked last");
assert.equal(p.project.id, "id-B");
});
test("a superseded import deletes its own orphaned project instead of leaving it behind", async () => {
const deleted = [];
const pending = {};
globalThis.fetch = async (path, opts = {}) => {
if (String(path).endsWith("/projects/import")) {
const body = JSON.parse(opts.body);
return new Promise((resolve) => {
pending[body.name] = () => resolve({ ok: true, status: 200, json: async () => (
{ id: `id-${body.name}`, name: body.name, scenes: [], video: {}, updated_at: 5 }) });
});
}
if (opts.method === "DELETE") { deleted.push(path); return { ok: true, status: 200, json: async () => ({}) }; }
return { ok: true, status: 200, json: async () => ({ projects: [] }) };
};
const p = createProject({});
await p.start();
const first = p.importProject({ name: "A", scenes: [] });
await Promise.resolve();
const second = p.importProject({ name: "B", scenes: [] });
await Promise.resolve();
pending.B(); await second;
pending.A(); await first;
assert.deepEqual(deleted, ["/funpack/api/projects/id-A"], "the superseded project was never cleaned up");
});
test("start() losing a race to a switch the user already made does not revert it", async () => {
// build() wires the File menu (and so onProject -> newProject/open/import)
// before start() is ever awaited -- start()'s own network calls are real
// gaps a user's click can finish inside of.
let resolveList;
globalThis.fetch = async (path, opts = {}) => {
if (path === "/funpack/api/projects" && opts.method === "GET") {
return new Promise((resolve) => { resolveList = () => resolve(
{ ok: true, status: 200, json: async () => ({ projects: [] }) }); });
}
if (opts.method === "POST" && path.endsWith("/projects")) {
const body = JSON.parse(opts.body);
return { ok: true, status: 200, json: async () => (
{ id: "usercreated0", name: body.name, scenes: [{ id: "s1" }], video: {}, updated_at: 2 }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const p = createProject({});
const starting = p.start(); // in flight -- list() has not answered yet
await Promise.resolve();
await p.newProject("MyNewThing"); // the user's own action completes first
assert.equal(p.project.name, "MyNewThing");
resolveList(); // start()'s list() finally answers
await starting;
assert.equal(p.project.name, "MyNewThing", "start() caught up and silently reverted the switch");
});
test("an undo during an in-flight open supersedes it too, not just another switch", async () => {
const store = {
a: { id: "aaaaaaaaaaaa", name: "A", scenes: [{ id: "s1", text: "", result: null }], updated_at: 1 },
b: { id: "bbbbbbbbbbbb", name: "B", scenes: [{ id: "s1", text: "", result: null }], updated_at: 1 },
};
let resolveOpenB;
globalThis.fetch = async (path, opts = {}) => {
if (path === "/funpack/api/projects" && opts.method === "GET") {
return { ok: true, status: 200, json: async () => ({ projects: [{ id: store.a.id }] }) };
}
if (String(path).endsWith(store.b.id) && opts.method === "GET") {
return new Promise((resolve) => { resolveOpenB = () => resolve(
{ ok: true, status: 200, json: async () => store.b }); });
}
if (opts.method === "PUT") return { ok: true, status: 200, json: async () => JSON.parse(opts.body) };
return { ok: true, status: 200, json: async () => store.a };
};
const p = createProject({});
await p.start(); // project A
p.setText(p.selectedId, "edited"); // undo history now has the original
const opening = p.open(store.b.id); // in flight
await new Promise((r) => setTimeout(r, 0)); // past open()'s own flush() first
p.undo(); // restores A's original text, DURING the open
assert.equal(p.project.id, store.a.id);
assert.equal(p.scenes[0].text, "");
resolveOpenB();
await opening;
// The open lost the race it was already in when the undo happened -- it
// must not land on top of the undo a moment later.
assert.equal(p.project.id, store.a.id, "the in-flight open overwrote the undo");
});
test("renaming a project moves the name in the File menu too", async () => {
// The listing and the open project are the same name seen twice; a menu that
// kept the old one until a reload is a menu that lies about what is open.
const { sent } = server();
const p = createProject({});
await p.start();
p.rename("Rooftops");
assert.equal(p.project.name, "Rooftops");
assert.deepEqual(p.recent.map((r) => r.name), ["Rooftops"]);
await p.flush();
assert.equal(sent.at(-1).name, "Rooftops");
});
test("a rename that says nothing changes nothing", async () => {
const { sent } = server();
const p = createProject({});
await p.start();
p.rename(" ");
p.rename("Untitled");
await p.flush();
assert.equal(sent.length, 0);
});
test("changing a project setting tells whoever draws from it", async () => {
// The timeline's clip widths and its ruler are computed from the project's
// length. A setting that changes without saying so leaves them showing the
// proportions of the value before it.
server();
const drew = [];
const p = createProject({ onChange: () => drew.push(1) });
await p.start();
const before = drew.length;
p.setVideo("length", 97);
assert.ok(drew.length > before, "nothing was told the project changed");
});
// --- undo ---------------------------------------------------------------
test("an edit can be taken back, and put back again", async () => {
server();
const p = createProject({});
await p.start();
p.addScene();
const id = p.selectedId;
p.setText(id, "a cat");
p.setText(id, "a cat on a roof");
assert.equal(p.selected.text, "a cat on a roof");
assert.equal(p.undo(), true);
assert.equal(p.selected.text, "a cat");
assert.equal(p.undo(), true);
assert.equal(p.selected.text, "");
assert.equal(p.redo(), true);
assert.equal(p.selected.text, "a cat");
});
test("undo puts back a scene that was removed, and the selection with it", async () => {
server();
const p = createProject({});
await p.start();
p.addScene();
p.addScene();
const [first, second] = p.scenes.map((s) => s.id);
p.select(second);
p.removeScene(second);
assert.equal(p.scenes.length, 1);
p.undo();
assert.equal(p.scenes.length, 2);
assert.ok(p.scenes.some((s) => s.id === second), "the scene came back as a different one");
assert.equal(p.selectedId, second, "the selection did not follow the scene back");
void first;
});
test("a selection that no longer names anything falls back rather than emptying", async () => {
// Undo can remove the scene that is selected. A selection naming nothing
// reads as "no scene" in every panel that draws from it.
server();
const p = createProject({});
await p.start();
p.addScene();
p.addScene();
const added = p.selectedId;
p.undo(); // takes the second scene away again
assert.ok(p.selectedId, "the selection was left naming nothing");
assert.notEqual(p.selectedId, added);
assert.ok(p.scenes.some((s) => s.id === p.selectedId));
});
test("a new edit ends the redo line", async () => {
// Otherwise a redo walks into a version that never followed from what is on
// screen.
server();
const p = createProject({});
await p.start();
p.addScene();
const id = p.selectedId;
p.setText(id, "one");
p.undo();
assert.equal(p.canRedo, true);
p.setText(id, "two");
assert.equal(p.canRedo, false);
assert.equal(p.redo(), false);
});
test("undo with nothing behind it does nothing, and says so", async () => {
server();
const p = createProject({});
await p.start();
assert.equal(p.canUndo, false);
assert.equal(p.undo(), false);
assert.equal(p.redo(), false);
});
test("a project just opened has no past to step into", async () => {
// Undoing across a project switch would replace the open project with a
// version of a DIFFERENT one.
server();
const p = createProject({});
await p.start();
p.addScene();
assert.equal(p.canUndo, true);
await p.newProject("Another");
assert.equal(p.canUndo, false, "undo reached back past the project switch");
});
test("taking an edit back saves it, and tells whoever draws it", async () => {
const { sent } = server();
const drew = [];
const opens = [];
const p = createProject({ onChange: () => drew.push(1), onOpen: () => opens.push(1) });
await p.start();
p.addScene();
p.setText(p.selectedId, "a cat");
await p.flush();
const before = drew.length;
p.undo();
assert.ok(drew.length > before, "nothing was told the project moved");
assert.ok(opens.length > 1, "the controls that follow a project were not put back");
await p.flush();
assert.equal(sent.at(-1).scenes.at(-1).text, "", "the version saved was not the one on screen");
});