Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/bin.test.js
T
digital-garbage b85c79a6a2 app, core: save/load a result, save/load a project, generate every scene
Player: Save to bin (whatever the viewer shows, if it has a real file
identity) and Save frame (canvas-capture the video on screen, upload
through ComfyUI's own /upload/image, no new route needed). Both add
without navigating the viewer -- an upload is a real round trip, and
the result is added to the bin, not shown as a stale finished pick.

Save/Load Project File: a real download route with Content-Disposition,
and an import route that never trusts an id in the file -- an import
always lands as its own project, never overwrites one already here.

Generate All: every scene in order, waiting for each to finish before
starting the next -- not a montage (v5 has no render/stitch stage),
N separate generations each landing on its own scene.

Both project-switching (open/newProject/importProject) and the earlier
run-reattach path shared a bug class this session kept finding: state
captured at one moment, applied at a later async moment with no check
that nothing had moved on in between. Closed it properly this time --
a generation counter every switch bumps and checks, including start()
(the page's own handlers are live before it resolves) and undo/redo
(also "a different project is open now"), and scheduleSave() captures
the object an edit actually touched instead of re-reading a variable
that can point somewhere else by the time its debounce timer fires.

Four adversarial rounds against this range, each fixed before landing.
2026-09-06 22:53:34 +03:00

200 lines
7.4 KiB
JavaScript

// The bin: what a session has produced, and what is on screen.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom, fire } from "../../composer/tests/_dom.js";
let createBin, keyOf, kindOf, recallView;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
({ createBin, keyOf, kindOf, recallView } = await import("../bin.js"));
});
test.after(() => teardownDom());
const file = (filename, extra = {}) => ({ filename, subfolder: "", type: "output", ...extra });
function bin(opts = {}) {
const opened = [];
const handle = createBin({ persist: false, onOpen: (item) => opened.push(item), ...opts });
document.body.replaceChildren(handle.host.node);
// Not a spread of the handle: `items`, `selected` and `view` are getters, and
// spreading one reads it ONCE -- every assertion would then be about the bin
// as it was before the test did anything to it.
return {
opened, control: handle.control, node: handle.host.node,
absorb: (...a) => handle.absorb(...a),
open: (...a) => handle.open(...a),
setView: (...a) => handle.setView(...a),
get items() { return handle.items; },
get selected() { return handle.selected; },
get view() { return handle.view; },
cells: () => [...handle.host.node.querySelectorAll(".cx-cell, .cx-media-row")],
};
}
test("a result arrives, is shown, and is the one selected", () => {
const b = bin();
b.absorb([file("a.png")]);
assert.equal(b.items.length, 1);
assert.equal(b.opened.length, 1);
assert.equal(b.opened[0].url, "/view?filename=a.png&subfolder=&type=output");
assert.equal(b.opened[0].kind, "image");
assert.equal(b.selected, keyOf(file("a.png")));
assert.equal(b.cells().length, 1);
assert.equal(b.cells()[0].getAttribute("aria-selected"), "true");
});
test("the same file arriving twice is one entry", () => {
// A reload adopts a finished run and replays its outputs, so the same three
// fields arrive again. Keyed by arrival, the bin would fill with copies of
// whatever was generated before the last reload.
const b = bin();
b.absorb([file("a.png")]);
b.absorb([file("a.png")]);
b.absorb([file("a.png"), file("b.png")]);
assert.deepEqual(b.items.map((i) => i.label), ["b.png", "a.png"]);
});
test("a state carrying nothing new leaves the user's choice alone", () => {
// absorb() runs on EVERY state change -- a progress message is a state
// change. Redrawing unconditionally walked a click on an older result back to
// the newest one within milliseconds.
const b = bin();
b.absorb([file("first.png"), file("second.png")]);
assert.equal(b.items[0].label, "second.png");
b.open(keyOf(file("first.png")));
const chosen = b.selected;
b.opened.length = 0;
b.absorb([file("first.png"), file("second.png")]); // the same run, again
b.absorb([]);
b.absorb();
assert.equal(b.selected, chosen, "an unchanged state moved the selection");
assert.deepEqual(b.opened, [], "an unchanged state re-showed a result");
});
test("a result from a NEW run takes the view back", () => {
const b = bin();
b.absorb([file("old.png")]);
b.open(keyOf(file("old.png")));
b.opened.length = 0;
b.absorb([file("new.png")]);
assert.equal(b.selected, keyOf(file("new.png")));
assert.equal(b.opened.length, 1);
assert.equal(b.opened[0].label, "new.png");
});
test("the newest of a batch is the one shown", () => {
const b = bin();
b.absorb([file("one.png"), file("two.png"), file("three.png")]);
assert.equal(b.opened.length, 1, "every file in a batch was shown in turn");
assert.equal(b.opened[0].label, "three.png");
assert.deepEqual(b.items.map((i) => i.label), ["three.png", "two.png", "one.png"]);
});
test("open: false adds a file to the bin without moving what the viewer shows", () => {
// A save action's own round trip (an upload) can finish well after the user
// has moved on to looking at something else -- absorb()'s normal auto-open
// exists for a RUN's own result appearing, not for that. `open: false` is
// what a save uses instead.
const b = bin();
b.absorb([file("already-open.png")]); // the normal path: this IS opened
assert.equal(b.opened.length, 1);
b.absorb([file("saved-later.png")], { open: false });
assert.equal(b.items.length, 2, "the file was not added to the bin at all");
assert.equal(b.opened.length, 1, "opened a second time despite open: false");
});
test("open: false still dedupes -- a file already in the bin is not added twice", () => {
const b = bin();
b.absorb([file("a.png")]);
const added = b.absorb([file("a.png")], { open: false });
assert.equal(added.length, 0);
assert.equal(b.items.length, 1);
});
test("a video is known as one, and never becomes a <video> in the bin", () => {
// v4 put live <video> elements in the bin and Chrome's six-per-origin
// connection pool wedged the whole API behind them: the app stopped answering
// while the bin loaded.
const b = bin();
b.absorb([file("clip.mp4"), file("clip.webm"), file("still.png")]);
assert.equal(kindOf(file("clip.mp4")), "video");
assert.equal(b.items.find((i) => i.label === "clip.mp4").kind, "video");
assert.equal(b.node.querySelector("video"), null);
const sources = [...b.node.querySelectorAll("img")].map((i) => i.getAttribute("src"));
assert.deepEqual(sources.filter((s) => /\.(mp4|webm)/.test(s)), [],
"a video file was handed to an <img> as a thumbnail");
});
test("changing the view keeps the items and the selection", () => {
const b = bin();
b.absorb([file("a.png"), file("b.png")]);
b.open(keyOf(file("a.png")));
for (const view of ["list", "icons", "grid"]) {
b.setView(view);
assert.equal(b.view, view);
assert.equal(b.items.length, 2, `${view} lost the items`);
assert.equal(b.cells().length, 2, `${view} drew the wrong number of entries`);
assert.equal(b.selected, keyOf(file("a.png")), `${view} lost the selection`);
const on = b.cells().filter((c) => c.getAttribute("aria-selected") === "true");
assert.equal(on.length, 1, `${view} did not draw the selection`);
}
});
test("each view lets a result be picked", () => {
for (const view of ["grid", "list", "icons"]) {
const b = bin({ view });
b.absorb([file("a.png"), file("b.png")]);
b.opened.length = 0;
fire(b.cells()[1], "click");
assert.equal(b.opened.length, 1, `${view} does not activate`);
assert.equal(b.opened[0].label, "a.png", `${view} activated the wrong entry`);
}
});
test("a view name nobody offers is refused, not drawn", () => {
const b = bin({ view: "mosaic" });
assert.equal(b.view, "grid");
assert.equal(b.setView("mosaic"), "grid");
assert.equal(b.view, "grid");
});
test("the view is remembered, and a stored name nobody offers is not", () => {
window.localStorage.removeItem("funpack.bin.view");
assert.equal(recallView(), "grid");
const b = createBin({ view: "list" });
b.setView("icons");
assert.equal(window.localStorage.getItem("funpack.bin.view"), "icons");
assert.equal(recallView(), "icons");
window.localStorage.setItem("funpack.bin.view", "mosaic");
assert.equal(recallView(), "grid");
window.localStorage.removeItem("funpack.bin.view");
});
test("the view control and the bin cannot disagree", () => {
const b = bin({ view: "grid" });
b.setView("list");
assert.equal(b.control.value, "list");
});
test("an empty bin says so", () => {
const b = bin();
assert.match(b.node.textContent, /Results appear here/);
assert.equal(b.cells().length, 0);
});