Files
ausboss-ComfyUI-AusBoss/tests/settle.test.mjs
T
ausboss 50389b0cdb Improve crop controls, playback, and media selection (#288)
* Group crop controls and put video scrubbing below the preview

* Stop IN where the connected clip length still fits

* Add play and pause beside the crop scrubber and clarify resize padding

* Preview the current brush beside Size in Image Compare

* Center the crop and its padding together on each axis

* Polish crop controls and load images from Outputs

* Add crop controls preview
2026-11-05 20:07:33 -06:00

221 lines
9.4 KiB
JavaScript

// A note that waits for the hand to stop.
//
// The Crop + Rotate + Pad nodes show an amber note under the size when the
// resize stretches the picture. During a drag the answer changes every few
// pixels, and a note that followed it flashed in and out and pushed the rows
// under it up and down. js/shared/settle.mjs says when a run of changes is
// over; the first half of this file holds it to its rules with a clock the
// test turns by hand. The second half holds the editor to using it: the
// live proof is a real drag on a canvas (docs/live_testing.md).
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { createSettle } from "../js/shared/settle.mjs";
const DELAY = 200;
// Timers the test runs by hand: advance(ms) fires what falls due, in order.
function fakeClock() {
let now = 0;
let nextId = 1;
const timers = new Map();
return {
setTimer(run, delay) { const id = nextId++; timers.set(id, { at: now + delay, run }); return id; },
clearTimer(id) { timers.delete(id); },
advance(ms) {
const end = now + ms;
for (;;) {
const due = [...timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
if (!due) break;
timers.delete(due[0]);
now = due[1].at;
due[1].run();
}
now = end;
},
};
}
function rig() {
const clock = fakeClock();
const hand = { down: false };
let settled = 0;
const rest = createSettle({
delay: DELAY, held: () => hand.down, onSettle: () => { settled += 1; },
setTimer: clock.setTimer, clearTimer: clock.clearTimer,
});
return {
clock, rest,
settled: () => settled,
press() { hand.down = true; },
release() { hand.down = false; rest.release(); },
};
}
test("a change with the pointer up shows at once and owes nothing later", () => {
const { clock, rest, settled } = rig();
assert.equal(rest.touch(), true, "a click, a typed number, a new picture");
clock.advance(5000);
assert.equal(settled(), 0, "nothing was held back, so nothing settles");
assert.equal(rest.touch(), true, "and the next single change shows at once again");
});
test("nothing shows during a drag, and it settles once after the release", () => {
const { clock, rest, settled, press, release } = rig();
press();
for (let move = 0; move < 40; move++) {
assert.equal(rest.touch(), false, "every move of the drag is held back");
clock.advance(16);
}
assert.equal(settled(), 0);
release();
clock.advance(DELAY - 1);
assert.equal(settled(), 0, "not at the release itself");
clock.advance(1);
assert.equal(settled(), 1, "a moment after it");
clock.advance(5000);
assert.equal(settled(), 1, "and only once");
});
test("a hand that rests with the button down settles nothing", () => {
const { clock, rest, settled, press, release } = rig();
press();
rest.touch();
clock.advance(10000);
assert.equal(settled(), 0, "however long the pause");
release();
clock.advance(DELAY - 1);
assert.equal(settled(), 0, "the wait starts at the release, not at the last move");
clock.advance(1);
assert.equal(settled(), 1);
});
test("a second press before it settles keeps it waiting", () => {
const { clock, rest, settled, press, release } = rig();
press();
rest.touch();
release();
clock.advance(DELAY / 2);
press();
clock.advance(3000);
assert.equal(settled(), 0, "grabbed again in time: still waiting");
release();
clock.advance(DELAY);
assert.equal(settled(), 1);
});
test("a burst with no pointer shows its first value at once and its last one after", () => {
const { clock, rest, settled } = rig();
assert.equal(rest.touch(), true, "the first step of a held arrow key");
for (let step = 0; step < 12; step++) {
clock.advance(33);
assert.equal(rest.touch(), false, "the steps that follow wait");
}
clock.advance(DELAY - 1);
assert.equal(settled(), 0);
clock.advance(1);
assert.equal(settled(), 1, "the last value, once the keys are quiet");
assert.equal(rest.touch(), true, "after that a single change shows at once again");
});
test("a press that changes nothing owes nothing", () => {
const { clock, rest, settled, press, release } = rig();
press();
clock.advance(800);
release();
clock.advance(5000);
assert.equal(settled(), 0);
assert.equal(rest.touch(), true);
});
test("cancel drops what was waiting", () => {
const { clock, rest, settled, press, release } = rig();
press();
rest.touch();
rest.cancel();
release();
clock.advance(5000);
assert.equal(settled(), 0, "the picture is gone: no note to settle");
assert.equal(rest.touch(), true);
});
// --- The editor uses it ---------------------------------------------------------
const editor = readFileSync(new URL("../js/shared/transform_editor.mjs", import.meta.url), "utf-8");
// A top-level function of the editor, from its name to its closing brace.
const body = (name) => {
const start = editor.indexOf(`function ${name}(`);
assert.ok(start >= 0, `${name} is gone from transform_editor.mjs`);
const end = editor.indexOf("\n}\n", start);
return editor.slice(start, end < 0 ? undefined : end + 2);
};
const rule = (selector) => {
const start = editor.indexOf(`${selector}{`);
assert.ok(start >= 0, `no CSS rule for ${selector}`);
return editor.slice(start + selector.length + 1, editor.indexOf("}", start));
};
test("the readout asks before it shows a note, and never writes one itself", () => {
const sync = body("syncReadout");
assert.match(sync, /const now = changed && state\.settle\.touch\(\);/);
assert.match(sync, /if \(now\) showNote\(state, wanted\);/);
assert.doesNotMatch(sync, /Even out|createElement\("i"/, "the note is written in showNote only");
// A drag is the only thing that takes a note off the face before its time.
assert.match(sync, /else if \(changed && pointerHeld && noteKey !== state\.shownNoteKey\) hideNote\(state\);/);
assert.match(body("hideNote"), /visibility = "hidden"/, "hidden with its line kept, not removed");
assert.match(body("settleReadout"), /showNote\(state, state\.wantedNote/);
});
test("the editor's size box and status show the settled note too", () => {
assert.match(body("drawOutputSize"), /const warnings = shownNote\(state\)\.warnings;/);
assert.doesNotMatch(body("drawOutputSize"), /\{ tokens, warnings \}/);
const lines = body("sizeLines");
assert.match(lines, /const note = shownNote\(state\);/);
assert.doesNotMatch(lines, /sizeChainTokens/, "the status must not read the live warnings");
assert.match(body("updateModalInfo"), /status\.style\.minHeight = state\.unsettled \?/);
});
test("Even out works its padding out at the click", () => {
// The note can be a moment behind the values, so padding worked out when
// the note was written could be for other values.
const show = body("showNote");
const click = show.slice(show.indexOf('addEventListener("click"'));
assert.match(click, /const pads = stretchFix\(state\)\.pads;/);
});
test("one watch on the page tells every node when the pointer comes up", () => {
const watch = body("watchPointer");
assert.match(watch, /addEventListener\("pointerdown"/);
assert.match(watch, /addEventListener\("pointerup", up, true\)/);
assert.match(watch, /addEventListener\("pointercancel", up, true\)/);
assert.match(watch, /for \(const live of liveStates\) live\.settle\.release\(\);/);
assert.match(editor, /state\.settle = createSettle\(\{ delay: NOTE_SETTLE_MS, held: \(\) => pointerHeld, onSettle: \(\) => settleReadout\(state\) \}\);/);
});
test("the video timeline sits right under the preview and size line, ahead of the collapsible transform group", () => {
const install = body("installTransformNode");
const atPreview = install.indexOf("panel.append(preview, readout);");
const atTrim = install.indexOf('if (kind === "video") panel.append(buildTrim(state));');
const atGroup = install.indexOf("panel.append(buildTransformGroup(state, [modeRow.row, chipRow.row, ...canvasRows]));");
const atButtons = install.indexOf("panel.append(row);");
assert.ok(atPreview > 0 && atTrim > atPreview, "the video timeline must sit right under the preview and size line");
assert.ok(atGroup > atTrim, "the collapsible transform group must follow the timeline");
assert.ok(atButtons > atGroup, "the action buttons must sit at the bottom of the panel");
});
test("the collapsible transform group stores its state on node.properties and preserves stage height", () => {
const group = body("buildTransformGroup");
assert.match(group, /const stageHeight = state\.previewCanvas\?\.clientHeight \?\? 0;/);
assert.match(group, /node\.properties\[TRANSFORM_GROUP_OPEN_PROPERTY\] = !transformGroupOpen\(node\);/);
assert.match(group, /const before = measureChrome\(state\) \?\? 0;/);
assert.match(group, /const after = measureChrome\(state\) \?\? before;/);
assert.match(group, /state\.stageGrowth = \{ height: stageHeight, target: nextHeight \};/);
assert.match(body("installTransformNode"), /state\.panelResizeObserver\.observe\(preview\);\s*state\.panelResizeObserver\.observe\(panel\);/);
assert.match(body("panelBackingStale"), /canvas\.width !== Math\.round\(canvas\.clientWidth \* dpr\)/);
assert.match(body("editorSnapshot"), /key !== TRANSFORM_GROUP_OPEN_PROPERTY/);
assert.match(body("transformPanelFloor"), /transformGroupOpen\(node\) \? PANEL_CHROME_OPEN_ESTIMATE\[key\] : PANEL_CHROME_CLOSED_ESTIMATE\[key\]/);
assert.match(rule(".ausboss-transform-group-body[hidden]"), /display:none/);
});