Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/timeline.test.js
T
digital-garbageandClaude Sonnet 5 a600fe3e29 fix: real timeline -- full-width layout, playhead reentrancy, panel overlap
Round 1 + round 2 of adversarial review on the single-track timeline
(2092c8b) found and fixed:

- Clip position now stays absolute (seconds*pxPerSecond) instead of the
  MIN_CLIP_PX floor cascading into every later clip -- a floored short
  clip no longer pushes everything after it out of sync with the ruler.
- pendingSeek is cleared unconditionally right after project.select(),
  closing a gap where a no-op select() (clicking within the already
  selected clip) left it dangling for a later, unrelated draw to
  wrongly replay.
- draw() now always resyncs the playhead to the current scene's start
  on any non-track-click selection change, rather than only when the
  old value fell outside the new scene's span -- the old check let a
  stale playhead survive a wholesale project switch whenever it
  coincidentally still fit the new project's first scene.
- The timeline moved from the centre column's own inner split to an
  outer split above/below the whole three-column workspace, matching
  v4's actual layout (full-width timeline under resizable columns,
  not squeezed inside one of them).
- That restructuring put the Properties column under real height
  pressure for the first time, exposing a latent bug: .cx-stack's
  base rule set min-block-size:0 on every stack, letting a plain
  grouping stack (not meant to scroll) get flex-crushed below its own
  content height, so its overflow painted over the next row ("Settings"
  over "Loosen the starting image"). Removed from the base rule --
  .cx-stack-fill already carries it for the one case that should
  actually give up height to scroll.
- Documented, rather than "fixed" by reintroducing the rejected
  cascading-position bug: several under-floor clips in a row still
  visually occlude each other (later DOM paints over earlier). Click-
  seek still resolves correctly since it reads time, not paint order.
- Guarded framesOf() against a negative/NaN scene.length the same way
  fps() already guards its own input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:29:44 +03:00

341 lines
14 KiB
JavaScript

// The timeline: real seconds, drag reorder, and the zoom that changes clip
// width.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
/** jsdom has no real DataTransfer -- a minimal stand-in, shared across the
* events of ONE drag gesture, the same way the browser shares one. */
function fakeDataTransfer() {
const store = {};
return {
effectAllowed: null,
setData(type, value) { store[type] = String(value); },
getData(type) { return store[type] || ""; },
get types() { return Object.keys(store); },
};
}
const dragFire = (node, type, dataTransfer) => {
const evt = new window.Event(type, { bubbles: true, cancelable: true });
evt.dataTransfer = dataTransfer;
node.dispatchEvent(evt);
};
let createTimeline;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
({ createTimeline } = await import("../timeline.js"));
});
test.after(() => teardownDom());
/** Just enough of the store for the timeline to read and act on. Video length
* is 24 frames at the 24fps fallback -- one second per scene by default,
* which keeps the maths in each test readable.
*
* `onChange` fires on every real mutation, exactly like the real store's
* `changed()` -- boot.js wires it straight to `timeline.draw()`, SYNCHRONOUSLY,
* which is what lets a draw() run in the middle of the track's own onSeek
* handler. A fixture whose select()/addScene()/etc. do not also fire this
* cannot reach that reentrancy at all, and a bug that only shows up there
* would pass every test and still be live in the real app.
*/
function fakeProject(scenes, video = { length: 24 }, { onChange } = {}) {
let selected = scenes[0] ? scenes[0].id : null;
const moved = [];
const fire = () => { if (onChange) onChange(); };
return {
get scenes() { return scenes; },
get selectedId() { return selected; },
get selected() { return scenes.find((s) => s.id === selected) || null; },
video,
moved,
addScene() {
const s = { id: `s${scenes.length}`, text: "", result: null, length: null };
scenes.push(s); selected = s.id; fire(); return s;
},
// Simulates opening a DIFFERENT project over the same store: the scenes
// array is replaced wholesale, same as the real store's open() does,
// while this fakeProject object itself -- and any timeline built against
// it -- stays the same one.
_openOtherProject(newScenes) {
scenes.length = 0;
scenes.push(...newScenes);
selected = newScenes[0] ? newScenes[0].id : null;
fire();
},
removeScene(id) {
const at = scenes.findIndex((s) => s.id === id);
if (at < 0) return;
scenes.splice(at, 1);
if (selected === id) selected = (scenes[at] || scenes[at - 1] || null)?.id ?? null;
fire();
},
move(id, by) {
moved.push({ id, by });
const at = scenes.findIndex((s) => s.id === id);
const to = at + by;
if (at < 0 || to < 0 || to >= scenes.length) return;
const [scene] = scenes.splice(at, 1);
scenes.splice(to, 0, scene);
fire();
},
select(id) { if (!scenes.some((s) => s.id === id) || id === selected) return; selected = id; fire(); },
};
}
const scenes = (n) => Array.from({ length: n }, (_, i) => ({ id: `s${i}`, text: "", result: null, length: null }));
test("dragging a clip onto another reorders by the distance dropped, not just one step", () => {
const project = fakeProject(scenes(4));
const t = createTimeline({ project });
document.body.append(t.node);
const cells = t.node.querySelectorAll(".cx-track-clip");
assert.equal(cells.length, 4);
// Drag the first clip (s0) onto the fourth cell's position.
const dt = fakeDataTransfer();
dragFire(cells[0], "dragstart", dt);
dragFire(cells[3], "dragover", dt);
dragFire(cells[3], "drop", dt);
assert.deepEqual(project.moved, [{ id: "s0", by: 3 }]);
assert.deepEqual(project.scenes.map((s) => s.id), ["s1", "s2", "s3", "s0"]);
t.destroy();
});
test("a redraw mid-drag (a remove fired from the keyboard, say) still resolves to the clip actually picked up", () => {
// The mouse held down and a keyboard shortcut are two different input
// channels -- nothing stops project.removeScene firing, and the track
// redrawing, while a drag is still in flight. Tracking BY POSITION would
// have this act on whatever now sits where the drag started, not on what
// was actually grabbed.
const project = fakeProject(scenes(4)); // s0 s1 s2 s3
const t = createTimeline({ project });
document.body.append(t.node);
const dt = fakeDataTransfer();
dragFire(t.node.querySelectorAll(".cx-track-clip")[1], "dragstart", dt); // grabs s1
project.removeScene("s0"); // s1 s2 s3 -- s1 is now cell 0
t.draw(); // the redraw a real remove triggers
const after = t.node.querySelectorAll(".cx-track-clip");
dragFire(after[2], "dragover", dt); // drop on whatever cell now shows s3
dragFire(after[2], "drop", dt);
assert.deepEqual(project.moved, [{ id: "s1", by: 2 }],
"moved the wrong clip -- position at dragstart, not the one actually dragged");
assert.deepEqual(project.scenes.map((s) => s.id), ["s2", "s3", "s1"]);
t.destroy();
});
test("dropping a clip on itself does nothing", () => {
const project = fakeProject(scenes(3));
const t = createTimeline({ project });
document.body.append(t.node);
const cells = t.node.querySelectorAll(".cx-track-clip");
const dt = fakeDataTransfer();
dragFire(cells[1], "dragstart", dt);
dragFire(cells[1], "drop", dt);
assert.deepEqual(project.moved, []);
t.destroy();
});
test("a drop outside any cell is not a reorder", () => {
const project = fakeProject(scenes(3));
const t = createTimeline({ project });
document.body.append(t.node);
const track = t.node.querySelector(".cx-track");
const cells = t.node.querySelectorAll(".cx-track-clip");
const dt = fakeDataTransfer();
dragFire(cells[0], "dragstart", dt);
dragFire(track, "drop", dt); // the track's own background, not a cell
assert.deepEqual(project.moved, []);
t.destroy();
});
test("dragend firing on a node a redraw already detached does not leave anything for an unrelated drop to pick up", () => {
const project = fakeProject(scenes(4)); // s0 s1 s2 s3
const t = createTimeline({ project });
document.body.append(t.node);
const started = fakeDataTransfer();
dragFire(t.node.querySelectorAll(".cx-track-clip")[1], "dragstart", started); // grabs s1
project.addScene(); // unrelated change, still redraws
t.draw();
const unrelated = fakeDataTransfer(); // a completely separate drag
const after = t.node.querySelectorAll(".cx-track-clip");
dragFire(after[3], "drop", unrelated);
assert.deepEqual(project.moved, [], "an unrelated drop moved a scene from an abandoned drag");
t.destroy();
});
test("zoom sets pixels-per-second and remembers it for next time", () => {
window.localStorage.clear();
const project = fakeProject(scenes(2));
const t = createTimeline({ project });
document.body.append(t.node);
const before = parseFloat(t.node.querySelector(".cx-track-clip").style.width);
t.node.querySelector('[role="radio"][aria-checked="false"]').click(); // any non-default level
const after = parseFloat(t.node.querySelector(".cx-track-clip").style.width);
assert.notEqual(after, before, "zoom did not change a clip's width");
assert.ok(window.localStorage.getItem("funpack.timeline.zoom"));
t.destroy();
});
test("zoom picked earlier is applied when the timeline is rebuilt", () => {
window.localStorage.setItem("funpack.timeline.zoom", "lg");
const project = fakeProject(scenes(2)); // 1s scenes, lg = 80px/s
const t = createTimeline({ project });
document.body.append(t.node);
assert.equal(t.node.querySelector(".cx-track-clip").style.width, "80px");
t.destroy();
window.localStorage.clear();
});
test("an excluded scene is dimmed in the track, an included one is not", () => {
const project = fakeProject(scenes(2));
project.scenes[1].excluded = true;
const t = createTimeline({ project });
document.body.append(t.node);
const cells = t.node.querySelectorAll(".cx-track-clip");
assert.equal(cells[0].classList.contains("cx-excluded"), false);
assert.equal(cells[1].classList.contains("cx-excluded"), true);
t.destroy();
});
// --- real time ---------------------------------------------------------
test("a clip's width comes from its own length in frames, not a fixed size", () => {
const s = scenes(2);
s[0].length = 48; // 2s at the 24fps fallback
s[1].length = 24; // 1s
const project = fakeProject(s);
const t = createTimeline({ project });
document.body.append(t.node);
const widths = [...t.node.querySelectorAll(".cx-track-clip")].map((c) => parseFloat(c.style.width));
// md zoom = 40px/s.
assert.deepEqual(widths, [80, 40]);
t.destroy();
});
test("a scene with no length of its own uses the project's", () => {
const s = scenes(1);
const project = fakeProject(s, { length: 48 }); // 2s at 24fps
const t = createTimeline({ project });
document.body.append(t.node);
assert.equal(parseFloat(t.node.querySelector(".cx-track-clip").style.width), 80);
t.destroy();
});
test("clicking a scene's clip selects it and reports the change", () => {
const seen = [];
const project = fakeProject(scenes(3)); // 1s scenes at 40px/s: s0 0-40, s1 40-80, s2 80-120
const t = createTimeline({ project, onSelect: (scene) => seen.push(scene && scene.id) });
document.body.append(t.node);
t.node.querySelector(".cx-track-inner")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 100 }));
assert.equal(project.selectedId, "s2");
assert.deepEqual(seen, ["s2"]);
t.destroy();
});
test("the playhead follows a selection made from outside the track", () => {
// Add/Remove/Move and a pick made elsewhere in the app all just change
// project.selectedId -- the same path this exercises directly, rather than
// depending on the fake store's own reselection fallback.
const project = fakeProject(scenes(3)); // 1s scenes: s0 0-1, s1 1-2, s2 2-3
const t = createTimeline({ project });
document.body.append(t.node);
t.node.querySelector(".cx-track-inner") // s2 starts at 80px (2s)
.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 80 }));
assert.equal(t.node.querySelector(".cx-track-playhead").style.insetInlineStart, "80px");
project.select("s0");
t.draw();
assert.equal(t.node.querySelector(".cx-track-playhead").style.insetInlineStart, "0px",
"the playhead did not follow a selection made outside the track");
t.destroy();
});
test("clicking mid-clip moves the playhead to that exact second without changing the selection", () => {
const project = fakeProject(scenes(2)); // s0 0-1s, s1 1-2s
const t = createTimeline({ project }); // starts selected on s0
document.body.append(t.node);
const inner = t.node.querySelector(".cx-track-inner");
// 20px at 40px/s = 0.5s, inside s0's own span -- a pure seek, no reselect.
inner.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 20 }));
assert.equal(project.selectedId, "s0", "a click within the selected clip changed the selection");
assert.equal(t.node.querySelector(".cx-track-playhead").style.insetInlineStart, "20px");
t.destroy();
});
// --- reentrancy: project.select()'s onChange redraws SYNCHRONOUSLY, exactly
// like boot.js wires it, so a draw can run in the middle of onSeek's own body
// before onSeek gets back around to setting the playhead itself. ------------
test("a seek clamped to the very end of the track lands there, not snapped back to that clip's start", () => {
let t;
const project = fakeProject(scenes(2), undefined, { onChange: () => t && t.draw() });
t = createTimeline({ project }); // s0 0-1s, s1 1-2s -- starts on s0
document.body.append(t.node);
const inner = t.node.querySelector(".cx-track-inner");
// Clicking past the track's own end clamps to its total span (80px = 2s),
// which selects s1 -- a REAL selection change, so project.select() fires
// its onChange and draw() runs reentrantly, before onSeek's own
// track.setPlayhead call. The clamped second is exactly s1's end, which
// fails a naive "playhead < span end" check.
inner.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 9999 }));
assert.equal(project.selectedId, "s1");
assert.equal(t.node.querySelector(".cx-track-playhead").style.insetInlineStart, "80px",
"a seek to the track's own end was snapped back to the newly selected clip's start");
t.destroy();
});
test("the playhead resets rather than surviving into an unrelated project by coincidence", () => {
// Same store, same timeline instance -- opening a different project
// replaces its scenes wholesale, not through a track seek, so nothing
// should carry the old playhead value forward, even when that value
// happens to still fall inside the new first scene's span.
let t;
const project = fakeProject(scenes(2), undefined, { onChange: () => t && t.draw() });
t = createTimeline({ project }); // s0 0-1s, s1 1-2s
document.body.append(t.node);
t.node.querySelector(".cx-track-inner")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 30 })); // seek to 0.75s in s0
assert.equal(t.node.querySelector(".cx-track-playhead").style.insetInlineStart, "30px");
// A different project, coincidentally with a first scene long enough that
// 0.75s would still fall inside it.
const other = scenes(1);
other[0].length = 480; // 20s at the 24fps fallback
project._openOtherProject(other);
assert.equal(t.node.querySelector(".cx-track-playhead").style.insetInlineStart, "0px",
"the old project's playhead position survived into a project it has nothing to do with");
t.destroy();
});