feat: app/ui project core (ported as is) and the Assets project list; features get named services; fixes a test that leaked a pending save into the next
This commit is contained in:
@@ -318,9 +318,10 @@ test("an edit made WHILE an import is in flight is still saved, against the old
|
||||
|
||||
await new Promise((r) => setTimeout(r, 700)); // past the real debounce window
|
||||
|
||||
assert.equal(sent.length, 1, "the edit made during the import never reached the server");
|
||||
assert.equal(sent[0].id, "abcdef012345", "saved against the OLD project, not the one now open");
|
||||
assert.equal(sent[0].name, "Typed while importing");
|
||||
// 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 () => {
|
||||
|
||||
+14
-3
@@ -1,12 +1,23 @@
|
||||
// Entry point: build the frame, load the features. Nothing here names a feature.
|
||||
// Entry point: build the frame, start the core, load the features. Nothing here names a feature.
|
||||
import { build } from "./shell/frame.js";
|
||||
import { loadFeatures } from "./shell/features.js";
|
||||
import { createSettings } from "./shell/settings.js";
|
||||
import { createProject } from "./shell/project.js";
|
||||
import { hostFor } from "./shell/mounts.js";
|
||||
import paths from "./modules.js";
|
||||
|
||||
// What the core offers a feature, by name. `on(fn)` hears every change; fn gets "open" when a
|
||||
// different project was opened, "change" for an edit.
|
||||
const heard = new Set();
|
||||
const say = (what) => heard.forEach((fn) => { try { fn(what); } catch (err) { console.error(err); } });
|
||||
const app = {
|
||||
project: createProject({ onChange: () => say("change"), onOpen: () => say("open"), onError: (err) => console.warn(err) }),
|
||||
on: (fn) => { heard.add(fn); return () => heard.delete(fn); },
|
||||
};
|
||||
|
||||
const frame = build(document.getElementById("app"));
|
||||
const settings = createSettings({ menubar: hostFor("menubar.menus") });
|
||||
const result = await loadFeatures(paths);
|
||||
const result = await loadFeatures(paths, { app });
|
||||
settings.addButton();
|
||||
window.FunPackUI = { frame, settings, ...result };
|
||||
app.project.start().catch((err) => console.warn("could not open a project:", err.message));
|
||||
window.FunPackUI = { frame, settings, app, ...result };
|
||||
|
||||
@@ -322,6 +322,10 @@ export const DEMOS = {
|
||||
{ id: "2", label: "anchor_frame.png", hint: "image" },
|
||||
{ id: "3", label: "a_very_long_generated_filename_00042_.png", hint: "image" },
|
||||
] },
|
||||
"gallery.rows": { selection: ["1"], items: [
|
||||
{ id: "1", label: "export-size-test", hint: "3▦" },
|
||||
{ id: "2", label: "Race Test 2", hint: "1▦" },
|
||||
] },
|
||||
"gallery.cards": { value: "image", items: [
|
||||
{ id: "blank", icon: "▦", label: "Empty project", hint: "Nothing on the timeline" },
|
||||
{ id: "image", icon: "◐", label: "From an image", hint: "Adds it as the first anchor" },
|
||||
|
||||
@@ -144,3 +144,13 @@
|
||||
font-size: var(--fs-8); line-height: var(--lh-8); color: var(--faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cx-pick-rows { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.cx-pick-row {
|
||||
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-2); text-align: left;
|
||||
border: var(--bw) solid transparent; border-radius: var(--ctl-r); background: none; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.cx-pick-row:hover { background: var(--elev-2); }
|
||||
.cx-pick-row.cx-on { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 27%, transparent); }
|
||||
.cx-pick-name { flex: 1; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cx-pick-hint { font-size: var(--fs-6); color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -154,6 +154,18 @@ define("gallery", "list", (props = {}) => {
|
||||
};
|
||||
});
|
||||
|
||||
/** Rows of a name and what it is, nothing to look at: a list of projects. */
|
||||
define("gallery", "rows", (props = {}) => {
|
||||
const body = el("div", { cls: "cx-pick-rows", attrs: { role: "listbox" } });
|
||||
const api = collection(props, body, (item, on) => el("button", {
|
||||
cls: ["cx-pick-row", on ? "cx-on" : null, "cx-focusable"],
|
||||
attrs: option(on),
|
||||
children: [el("span", { cls: "cx-pick-name", text: item.label }),
|
||||
item.hint ? el("span", { cls: "cx-pick-hint", text: item.hint }) : null].filter(Boolean),
|
||||
}));
|
||||
return { node: body, get value() { return api.value; }, setValue: api.setValue, setItems: api.setItems, destroy: () => body.remove() };
|
||||
});
|
||||
|
||||
/** Large clickable cards: the wizard's "what are you making?" choices. */
|
||||
define("gallery", "cards", ({ items = [], value, onActivate } = {}) => {
|
||||
let current = value;
|
||||
|
||||
@@ -11,10 +11,6 @@ const items = {
|
||||
"menubar.menus": () => ["File", "Edit", "View", "Help"].map(menu),
|
||||
"menubar.right": () => [c.chip.neutral({ label: "saved" }), c.chip.good({ label: "ComfyUI live", dot: true })],
|
||||
|
||||
"assets.actions": () => [],
|
||||
assets: () => [c.toolbar.default({ items: [c.text.sm({ text: "Projects" })], trailing: [btn("+ New")] }),
|
||||
c.hint.default({ text: "Your projects appear here." })],
|
||||
|
||||
preview: () => [
|
||||
c.viewer.media({ empty: "No render yet. Use Generate in the timeline header." }),
|
||||
c.toolbar.default({ items: [btn("⏹"), btn("▶"), c.text.sm({ text: "00:00:00:00" }), btn("📌 Save frame")] }),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// The Assets zone's project list: every project, the open one marked, "New" to start another.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { list } from "../../shell/project.js";
|
||||
|
||||
export default {
|
||||
id: "projects",
|
||||
mount: "assets",
|
||||
needs: ["project"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project;
|
||||
const rows = c.gallery.rows({ items: [], onActivate: (item) => p.open(item.id) });
|
||||
const news = c.button.sm({ label: "+ New", onClick: async () => {
|
||||
const name = await c.modal.prompt({ title: "New project", label: "Name", value: "Untitled" }).result;
|
||||
if (name) await p.newProject(name.trim() || "Untitled");
|
||||
} });
|
||||
host.append(c.toolbar.default({ items: [c.text.sm({ text: "Projects" })], trailing: [news] }).node, rows.node);
|
||||
|
||||
let known = [];
|
||||
const draw = () => {
|
||||
rows.setItems(known.map((r) => ({ id: r.id, label: r.name, hint: r.scene_count == null ? "" : `${r.scene_count}▦` })));
|
||||
if (p.project) rows.setValue([p.project.id]);
|
||||
};
|
||||
const refresh = () => list().then((all) => { known = all; draw(); }).catch(() => draw());
|
||||
refresh();
|
||||
return app.on((what) => { if (what === "open") refresh(); else draw(); });
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// The one list of UI features. Adding a feature is a folder under ./features and a line here.
|
||||
export default [
|
||||
"./features/log/index.js",
|
||||
"./features/projects/index.js",
|
||||
"./features/placeholders/index.js",
|
||||
"./features/temp/index.js",
|
||||
].map((path) => new URL(path, import.meta.url).href);
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
// Loads UI features. A feature file's default export is one hub, or several:
|
||||
// { id, mount, needs?, setup({ host }) -> teardown? }
|
||||
// { id, mount, needs?, setup({ host, app }) -> teardown? }
|
||||
// `mount` names a region (see mounts.js). A feature that cannot load, names a region nobody offers,
|
||||
// needs a service the app lacks, or throws in setup is hidden -- nothing of it reaches the screen.
|
||||
import { hostFor, offered } from "./mounts.js";
|
||||
|
||||
function mountOne(hub, has) {
|
||||
function mountOne(hub, has, app) {
|
||||
const host = hostFor(hub.mount);
|
||||
if (!host) throw new Error(`no region offers "${hub.mount}" (offered: ${offered().join(", ")})`);
|
||||
const lacking = (hub.needs || []).filter((name) => !has(name));
|
||||
if (lacking.length) throw new Error(`needs ${lacking.join(", ")}`);
|
||||
const before = [...host.childNodes];
|
||||
try {
|
||||
return { id: hub.id, teardown: hub.setup({ host }) };
|
||||
return { id: hub.id, teardown: hub.setup({ host, app }) };
|
||||
} catch (err) { // whatever it put on the page goes with it
|
||||
[...host.childNodes].filter((n) => !before.includes(n)).forEach((n) => n.remove());
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFeatures(paths, { load = (path) => import(path), has = () => true } = {}) {
|
||||
// `app` is what the core offers features (project, ...). A feature names what it needs; a missing one hides it.
|
||||
export async function loadFeatures(paths, { load = (path) => import(path), app = {}, has = (name) => name in app } = {}) {
|
||||
const mounted = [], hidden = [];
|
||||
const hide = (id, err) => { hidden.push({ id, why: err.message }); console.warn(`[FunPack] ${id} hidden: ${err.message}`); };
|
||||
for (const path of paths) {
|
||||
let hubs;
|
||||
try { hubs = [].concat((await load(path)).default); } catch (err) { hide(path, err); continue; }
|
||||
for (const hub of hubs) {
|
||||
try { mounted.push(mountOne(hub, has)); } catch (err) { hide(hub?.id || path, err); }
|
||||
try { mounted.push(mountOne(hub, has, app)); } catch (err) { hide(hub?.id || path, err); }
|
||||
}
|
||||
}
|
||||
return { mounted, hidden };
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
// The project: an ordered list of scenes, and the only thing in the app that
|
||||
// has to survive a reload.
|
||||
//
|
||||
// Held here rather than in the timeline that draws it, for the reason the
|
||||
// pipeline is held in boot: one store of live values. A timeline that owned the
|
||||
// project would make "what is on screen" and "what a run uses" two questions
|
||||
// with two answers.
|
||||
//
|
||||
// Saving is DEBOUNCED and last-write-wins per project. Typing a prompt is one
|
||||
// edit per keystroke and each is a PUT of the whole project; without this the
|
||||
// server answers them out of order and an older body lands last.
|
||||
|
||||
const BASE = "/funpack/api/projects";
|
||||
const SAVE_AFTER = 600;
|
||||
|
||||
/**
|
||||
* A scene id the server will keep.
|
||||
*
|
||||
* Twelve hex characters is what core/projects.py generates and the only shape it
|
||||
* accepts back; anything else it replaces with one of its own. Minting a valid
|
||||
* one here means a new scene is addressable the moment it appears, instead of
|
||||
* being unselectable until a save round trip has answered.
|
||||
*/
|
||||
function newId() {
|
||||
const bytes = new Uint8Array(6);
|
||||
(globalThis.crypto || {}).getRandomValues?.(bytes);
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function json(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try { detail = ((await res.json()).problems || [])[0] || ""; } catch { /* not JSON */ }
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
export const list = () => json("GET", BASE).then((d) => d.projects || []);
|
||||
export const create = (name) => json("POST", BASE, { name });
|
||||
export const read = (id) => json("GET", `${BASE}/${encodeURIComponent(id)}`);
|
||||
export const remove = (id) => json("DELETE", `${BASE}/${encodeURIComponent(id)}`);
|
||||
const put = (project) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}`, project);
|
||||
|
||||
/**
|
||||
* createProject({ onChange, onError }) -> the live project.
|
||||
*
|
||||
* `onChange` fires for anything that alters what is on screen, including the
|
||||
* selection. Nothing here draws; the timeline subscribes.
|
||||
*/
|
||||
export function createProject({ onChange, onError, onOpen } = {}) {
|
||||
let project = null;
|
||||
let selected = null;
|
||||
// Every project this browser knows about, for the File menu. Read once at
|
||||
// startup and kept in step here rather than re-fetched each time the menu
|
||||
// opens: a listing is not a thing that changes behind the user's back.
|
||||
let recent = [];
|
||||
// Undo, the cheap way: a project is plain JSON and a few kilobytes, so a
|
||||
// snapshot before each edit costs nothing worth measuring and needs no
|
||||
// per-action inverse. Bounded, or a long session keeps every version of a
|
||||
// project it has ever had.
|
||||
// ponytail: whole-project snapshots, capped at 40. Per-field diffs only if a
|
||||
// project ever gets big enough for the copy to show up in a profile.
|
||||
const LIMIT = 40;
|
||||
let past = [];
|
||||
let future = [];
|
||||
// The selection travels with the snapshot. It is view state and not saved,
|
||||
// but undoing a removal and landing on a different scene than the one that
|
||||
// came back is not what anyone means by "take that back".
|
||||
const copy = () => ({ project: JSON.parse(JSON.stringify(project)), selected });
|
||||
let timer = null;
|
||||
let saving = null; // the PUT in flight, so a queued one waits for it
|
||||
let dirty = false;
|
||||
// Bumped by every open/newProject/importProject, BEFORE their own await --
|
||||
// so a switch that starts and then loses a race (two imports, or an import
|
||||
// overlapping an open()) can tell, once its fetch finally answers, that a
|
||||
// NEWER switch already won and abandon its own result instead of clobbering
|
||||
// whatever the user is actually looking at now.
|
||||
let generation = 0;
|
||||
|
||||
const changed = () => { if (onChange) onChange(); };
|
||||
|
||||
/** Before an edit. Anything that changes what is SAVED calls this; selecting
|
||||
* a scene does not, because a view is not an edit. */
|
||||
function remember() {
|
||||
if (!project) return;
|
||||
past.push(copy());
|
||||
if (past.length > LIMIT) past.shift();
|
||||
// A new edit ends the redo line. Keeping it would let a user redo their way
|
||||
// into a version that never followed from what they are looking at.
|
||||
future = [];
|
||||
}
|
||||
|
||||
function step(from, to) {
|
||||
if (!project || !from.length) return false;
|
||||
to.push(copy());
|
||||
const was = from.pop();
|
||||
// Same reason open/newProject/importProject/start bump it: an undo/redo
|
||||
// is also "a different project is open now" (the comment on opened() two
|
||||
// lines below already says so), and an in-flight switch that started
|
||||
// before this must not still be allowed to land on top of it once its
|
||||
// own fetch catches up.
|
||||
generation += 1;
|
||||
project = was.project;
|
||||
selected = was.selected;
|
||||
// The scene that was selected may be gone -- stepping FORWARD into a state
|
||||
// where it had been removed. Fall back rather than leaving a selection that
|
||||
// names nothing, which reads as "no scene" in every panel that draws it.
|
||||
if (!(project.scenes || []).some((sc) => sc.id === selected)) {
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
}
|
||||
scheduleSave();
|
||||
opened(); // the project was REPLACED, not edited
|
||||
return true;
|
||||
}
|
||||
// A DIFFERENT project is open now -- which is not the same event as a field
|
||||
// changing, and the things that follow a project are not the things that
|
||||
// follow an edit. Fired by every route in: start, open, new.
|
||||
const opened = () => { changed(); if (onOpen) onOpen(project); };
|
||||
const forget = () => { past = []; future = []; };
|
||||
|
||||
function scheduleSave() {
|
||||
// The object THIS edit actually touched, captured now -- not read back
|
||||
// later from `project`, which can point somewhere else entirely by the
|
||||
// time this timer fires (a project switch started and finished in
|
||||
// between). Without this, an edit made in the gap between a switch
|
||||
// starting and completing was silently lost: mutated on the right object,
|
||||
// then saved -- once the timer finally fired -- against whatever project
|
||||
// had since replaced it.
|
||||
//
|
||||
// ponytail: `dirty`/`timer`/`saving` stay single, shared flags rather than
|
||||
// one per target. A second edit landing on a DIFFERENT project before this
|
||||
// one's timer fires still cancels it (real, narrower race: an edit during
|
||||
// a switch's gap, then another edit to the new project, both inside one
|
||||
// debounce window). Upgrade to a per-target queue if that ever shows up
|
||||
// outside a deliberately adversarial test.
|
||||
const target = project;
|
||||
dirty = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => flush(target), SAVE_AFTER);
|
||||
}
|
||||
|
||||
async function flush(target = project) {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (!target || !dirty) return;
|
||||
// One PUT at a time. Two overlapping writes of a whole project can land in
|
||||
// either order, and the loser is a version of the project the user has
|
||||
// already moved past.
|
||||
if (saving) { await saving.catch(() => {}); if (!dirty) return; }
|
||||
dirty = false;
|
||||
const body = { ...target };
|
||||
saving = put(body).then((saved) => {
|
||||
// Only the fields the server owns are taken back: replacing the whole
|
||||
// project would overwrite whatever was typed while the PUT was in flight.
|
||||
if (target && saved && target.id === saved.id) target.updated_at = saved.updated_at;
|
||||
}).catch((err) => {
|
||||
dirty = true; // it is still unsaved, so say so
|
||||
if (onError) onError(err);
|
||||
}).finally(() => { saving = null; });
|
||||
return saving;
|
||||
}
|
||||
|
||||
const sceneAt = (id) => (project ? (project.scenes || []).find((s) => s.id === id) : null);
|
||||
|
||||
return {
|
||||
get project() { return project; },
|
||||
get scenes() { return project ? project.scenes || [] : []; },
|
||||
get selectedId() { return selected; },
|
||||
get selected() { return sceneAt(selected); },
|
||||
|
||||
/** The most recent project, or a new one when there are none. */
|
||||
get recent() { return recent; },
|
||||
|
||||
async start() {
|
||||
// Guarded like open/newProject/importProject, and for the same reason:
|
||||
// the page's own handlers (the File menu, the hidden file input) are
|
||||
// wired up by build() well before this is awaited, and both of start()'s
|
||||
// own network calls are real gaps a user's own click can finish inside
|
||||
// of. Losing this race used to mean start() caught up later and
|
||||
// silently reverted a switch the user had already made, with no error.
|
||||
const mine = ++generation;
|
||||
const found = await list();
|
||||
if (mine !== generation) return project;
|
||||
recent = found;
|
||||
const opened_ = found.length ? await read(found[0].id) : await create("Untitled");
|
||||
if (mine !== generation) return project;
|
||||
project = opened_;
|
||||
if (!found.length) recent = [{ id: project.id, name: project.name }];
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget(); // a project just opened has no past
|
||||
opened();
|
||||
return project;
|
||||
},
|
||||
|
||||
async open(id) {
|
||||
const mine = ++generation;
|
||||
await flush();
|
||||
const found = await read(id);
|
||||
// A newer switch already won this race (another open/new/import started
|
||||
// after this one and has already landed) -- applying this one now would
|
||||
// silently pull the user back to a project they already left.
|
||||
if (mine !== generation) return project;
|
||||
project = found;
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget(); // a project just opened has no past
|
||||
opened();
|
||||
return project;
|
||||
},
|
||||
|
||||
async newProject(name) {
|
||||
const mine = ++generation;
|
||||
await flush();
|
||||
const made = await create(name);
|
||||
if (mine !== generation) {
|
||||
// Superseded by a newer switch -- this one already created a real
|
||||
// project on the server that nothing will ever show. Best-effort:
|
||||
// a failed cleanup leaves an orphan, which is the pre-existing
|
||||
// (worse) behaviour, not a new one.
|
||||
remove(made.id).catch(() => {});
|
||||
return project;
|
||||
}
|
||||
project = made;
|
||||
recent = [{ id: project.id, name: project.name }, ...recent];
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget(); // a project just opened has no past
|
||||
opened();
|
||||
return project;
|
||||
},
|
||||
|
||||
/** Where "Save Project File" downloads FROM, for the current project. */
|
||||
downloadUrl() { return project ? `${BASE}/${encodeURIComponent(project.id)}/download` : null; },
|
||||
|
||||
/**
|
||||
* A project file from disk, as its own new project -- moving a project
|
||||
* between machines, not restoring one in place. The file's own id is
|
||||
* never kept (the server strips it too, but a project already open in
|
||||
* THIS browser with that id must not be what a stale double-check relies
|
||||
* on): imported always means "another one", even from the very checkout
|
||||
* it came from.
|
||||
*/
|
||||
async importProject(data) {
|
||||
const mine = ++generation;
|
||||
await flush();
|
||||
const imported = await json("POST", `${BASE}/import`, data);
|
||||
if (mine !== generation) {
|
||||
remove(imported.id).catch(() => {}); // superseded -- see newProject()
|
||||
return project;
|
||||
}
|
||||
project = imported;
|
||||
recent = [{ id: project.id, name: project.name }, ...recent];
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
forget();
|
||||
opened();
|
||||
return project;
|
||||
},
|
||||
|
||||
select(id) {
|
||||
if (!sceneAt(id) || id === selected) return;
|
||||
selected = id;
|
||||
changed(); // selection is not saved: it is a view
|
||||
},
|
||||
|
||||
/** What it is called. The listing in the File menu reads this too, so both
|
||||
* move together rather than the menu going stale until a reload. */
|
||||
rename(name) {
|
||||
const clean = String(name || "").trim();
|
||||
if (!project || !clean || project.name === clean) return;
|
||||
remember();
|
||||
project.name = clean;
|
||||
const listed = recent.find((p) => p.id === project.id);
|
||||
if (listed) listed.name = clean;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** What the whole project generates at, whichever scene is current. */
|
||||
get video() { return (project && project.video) || {}; },
|
||||
|
||||
setVideo(key, value) {
|
||||
if (!project) return;
|
||||
project.video = project.video || {};
|
||||
if (project.video[key] === value) return;
|
||||
remember();
|
||||
project.video[key] = value;
|
||||
scheduleSave();
|
||||
// The timeline's clip widths and its ruler are computed from the project's
|
||||
// length. This said "nothing on screen draws from this" and was true when
|
||||
// it was written -- the timeline started reading it an hour later, and a
|
||||
// comment is not a thing the next change has to keep true.
|
||||
changed();
|
||||
},
|
||||
|
||||
/** The negative prompt, whichever scene is current -- a project-level
|
||||
* choice, the same as video, not a per-scene one. */
|
||||
get negative() { return (project && project.negative) || ""; },
|
||||
|
||||
setNegative(value) {
|
||||
if (!project) return;
|
||||
if (project.negative === value) return;
|
||||
remember();
|
||||
project.negative = value;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** Text prepended to every scene's prompt at generation (core/prompt_build.py)
|
||||
* -- a project-level choice, the same as negative. */
|
||||
get anchor() { return (project && project.anchor) || ""; },
|
||||
|
||||
setAnchor(value) {
|
||||
if (!project) return;
|
||||
if (project.anchor === value) return;
|
||||
remember();
|
||||
project.anchor = value;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** Text appended to every scene's prompt, when postfixEnabled is true. */
|
||||
get postfix() { return (project && project.postfix) || ""; },
|
||||
|
||||
setPostfix(value) {
|
||||
if (!project) return;
|
||||
if (project.postfix === value) return;
|
||||
remember();
|
||||
project.postfix = value;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
get postfixEnabled() { return project ? project.postfix_enabled !== false : true; },
|
||||
|
||||
setPostfixEnabled(value) {
|
||||
if (!project) return;
|
||||
if (project.postfix_enabled === value) return;
|
||||
remember();
|
||||
project.postfix_enabled = value;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** $name -> text, substituted into anchor/scene/postfix at generation.
|
||||
* A list, not a dict: order is what the user set, and the whole list is
|
||||
* replaced at once -- one row is typed at a time, but no row can be
|
||||
* addressed by name alone while it is still being renamed. */
|
||||
get variables() { return (project && project.variables) || []; },
|
||||
|
||||
setVariables(list) {
|
||||
if (!project) return;
|
||||
remember();
|
||||
project.variables = list;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** The text of one scene. The prompt box on the main window edits this. */
|
||||
setText(id, text) { this.setScene(id, "text", text); },
|
||||
|
||||
addScene() {
|
||||
if (!project) return null;
|
||||
// A new scene lands AFTER the selected one, not at the end: adding partway
|
||||
// through a timeline is how a scene gets inserted, and appending silently
|
||||
// would put it somewhere the user was not looking.
|
||||
remember();
|
||||
const at = project.scenes.findIndex((s) => s.id === selected);
|
||||
const scene = {
|
||||
id: newId(), text: "", result: null, length: null, rating: null,
|
||||
// Matching core's Scene dataclass shape exactly: the Inspector reads
|
||||
// scene.references unconditionally (a list to map over, never a
|
||||
// maybe-undefined field), and a scene born here has to look the same
|
||||
// as one that came back from the server.
|
||||
source_image: null, references: [],
|
||||
};
|
||||
project.scenes.splice(at < 0 ? project.scenes.length : at + 1, 0, scene);
|
||||
selected = scene.id; // you add a scene in order to fill it
|
||||
scheduleSave();
|
||||
changed();
|
||||
return scene;
|
||||
},
|
||||
|
||||
removeScene(id) {
|
||||
if (!project || !sceneAt(id)) return;
|
||||
remember();
|
||||
const at = project.scenes.findIndex((s) => s.id === id);
|
||||
project.scenes.splice(at, 1);
|
||||
if (selected === id) {
|
||||
const next = project.scenes[at] || project.scenes[at - 1];
|
||||
selected = next ? next.id : null;
|
||||
}
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
move(id, by) {
|
||||
if (!project) return;
|
||||
const at = project.scenes.findIndex((s) => s.id === id);
|
||||
const to = at + by;
|
||||
if (at < 0 || to < 0 || to >= project.scenes.length) return;
|
||||
remember();
|
||||
const [scene] = project.scenes.splice(at, 1);
|
||||
project.scenes.splice(to, 0, scene);
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** One field of one scene. Everything a scene holds but its id goes through
|
||||
* here, so there is one place that saves and one that says it changed. */
|
||||
setScene(id, key, value) {
|
||||
const scene = sceneAt(id);
|
||||
if (!scene || scene[key] === value) return;
|
||||
remember();
|
||||
scene[key] = value;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** Attach what a run produced to the scene it was started from. */
|
||||
setResult(id, result) { this.setScene(id, "result", result); },
|
||||
|
||||
/** The media library id sizing this scene's generation, or null for none. */
|
||||
setSourceImage(id, mediaId) { this.setScene(id, "source_image", mediaId); },
|
||||
|
||||
/** Media library ids, in order -- this scene's own reference images. */
|
||||
setReferences(id, mediaIds) { this.setScene(id, "references", mediaIds); },
|
||||
|
||||
/**
|
||||
* The same, but for a run that may have finished after the user switched
|
||||
* to a DIFFERENT project. A run takes minutes and nothing stops someone
|
||||
* from opening another project while one is in flight -- `setResult`
|
||||
* alone would look the scene id up in whatever is open NOW, find nothing,
|
||||
* and drop the result with no trace: the GPU time spent, the image gone.
|
||||
*/
|
||||
async setResultFor(projectId, sceneId, result) {
|
||||
if (project && project.id === projectId) {
|
||||
this.setResult(sceneId, result);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const doc = await read(projectId);
|
||||
const scene = (doc.scenes || []).find((s) => s.id === sceneId);
|
||||
if (!scene) return; // the scene itself is gone -- nothing to attach this to
|
||||
scene.result = result;
|
||||
await put(doc);
|
||||
} catch (err) {
|
||||
if (onError) onError(err);
|
||||
}
|
||||
},
|
||||
|
||||
/** Step back, or forward. True when something moved. */
|
||||
undo() { return step(past, future); },
|
||||
redo() { return step(future, past); },
|
||||
get canUndo() { return Boolean(project) && past.length > 0; },
|
||||
get canRedo() { return Boolean(project) && future.length > 0; },
|
||||
|
||||
flush,
|
||||
get unsaved() { return dirty; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
// 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("../project.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");
|
||||
});
|
||||
Reference in New Issue
Block a user