Rainbow wave, Bounce, Typewriter, Decode, Molten, Chrome shine, Groovy 70s and Hologram join Neon pulse, Glitch, Retro, Simple pulse and Minimal, which now each add to v1's drawing: a surge, tears, a tracking band, a swell and an underline that slides. Every cycle rests on the whole title, and nothing swings brighter more than twice a second at the fastest speed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1053 lines
64 KiB
TypeScript
1053 lines
64 KiB
TypeScript
/**
|
||
* The node layers (src/extensions/node-animations.ts) on the fake LiteGraph:
|
||
* each effect draws its part on its node's body once native filled it, clipped
|
||
* to the body and under the widgets, and its part around the body under every
|
||
* node, for every node whose effect reaches the screen. The body casts v1's
|
||
* halo. The styled title takes the native title's place. Node modes fade the
|
||
* effects as native fades the node; collapsed nodes get none, and Vue mode the
|
||
* particles alone. A failing effect leaves the canvas as it found it, and the
|
||
* guard turns it off.
|
||
*/
|
||
|
||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||
import { clock, dummy, guard, layers, live } from '@/core/engine';
|
||
import { N, preview } from '@/core/settings';
|
||
import * as colors from '@/core/colors';
|
||
import { OVER, UNDER, drawNodeFx, type Part } from '@/nodes/effects';
|
||
import { app, fake } from '../helpers/fake-app';
|
||
import { CanvasRecorder, fmt, parseColor } from '../helpers/recording-context';
|
||
import { FakeCanvas, FakeNode, frames, page, progress, run, send, start, type GraphSpec } from '../helpers/fake-litegraph';
|
||
import { WHOLE } from './characterize/v1-inputs';
|
||
|
||
/**
|
||
* Each call to drawNodeFx: its context, node and sides, and where its lines and paints start and end on `rec`.
|
||
* With `fail` set the calls move the canvas and throw instead of drawing.
|
||
*/
|
||
const probe = vi.hoisted(() => ({
|
||
rec: undefined as CanvasRecorder | undefined,
|
||
calls: [] as { ctx: CanvasRenderingContext2D; node: unknown; sides: number; only: boolean; line: number; from: number; to: number }[],
|
||
fail: false,
|
||
}));
|
||
|
||
vi.mock('@/nodes/effects', async (importOriginal) => {
|
||
const effects = await importOriginal<typeof import('@/nodes/effects')>();
|
||
return {
|
||
...effects,
|
||
drawNodeFx: (...args: Parameters<typeof effects.drawNodeFx>) => {
|
||
const [ctx, , node, , , , part, , only] = args;
|
||
const [line, from] = [probe.rec?.lines.length ?? 0, probe.rec?.paints.length ?? 0];
|
||
if (probe.fail) {
|
||
ctx.translate(1, 1);
|
||
ctx.globalAlpha = 0.5;
|
||
throw new Error('boom');
|
||
}
|
||
effects.drawNodeFx(...args);
|
||
probe.calls.push({ ctx, node, sides: part.sides, only, line, from, to: probe.rec?.paints.length ?? 0 });
|
||
},
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
probe.fail = false;
|
||
});
|
||
|
||
const LINKS_OFF = { '🔗 Enhanced Links.Animate': 0 };
|
||
const PARTICLE_SHOW = '📦 Enhanced Nodes.Particle.Show';
|
||
const PARTICLES = { [PARTICLE_SHOW]: true };
|
||
const TITLES = { '📦 Enhanced Nodes.Text.Animation.Enabled': true };
|
||
const KSAMPLER: GraphSpec['nodes'][number] = { pos: [300, 300], size: [220, 120], title: 'KSampler' };
|
||
|
||
/** Each node effect by its Animate value, and the particles of a node without one. */
|
||
const EFFECTS: [name: string, animate: number][] = [['Gentle Pulse', 1], ['Neon Nexus', 2], ['Cosmic Ripple', 3], ['Sacred Rosette', 4], ['Flower of Life', 5], ['Jelly', 6], ['Circuit Trace', 7], ['Aurora Veil', 8], ['Ember Crown', 9], ['Particles alone', 0]];
|
||
|
||
/** One frame on the front canvas: its lines, and the effect calls it made, with their lines' indexes among those. */
|
||
function frame(canvas: FakeCanvas) {
|
||
probe.rec = canvas.front;
|
||
probe.calls = [];
|
||
const first = canvas.front.lines.length;
|
||
frames(1);
|
||
return { lines: canvas.front.lines.slice(first), calls: probe.calls.map((call) => ({ ...call, line: call.line - first })) };
|
||
}
|
||
|
||
/** Where the fake fills a node's body in a frame's lines: the fill() after drawNodeShape sets its colours. */
|
||
const bodyFill = (lines: string[], from = 0) => lines.indexOf('fill()', lines.indexOf('strokeStyle = #333333', from));
|
||
|
||
/** The bodies native fills, in its colour; and the halos, fills off to the side whose shadow alone lands on their node. */
|
||
const bodies = (paints: CanvasRecorder['paints']) => paints.filter((p) => p.op === 'fill' && p.style === '#353535' && p.shadowOffsetX >= 0);
|
||
const halos = (paints: CanvasRecorder['paints']) => paints.filter((p) => p.op === 'fill' && p.shadowOffsetX < 0);
|
||
|
||
/**
|
||
* How far paints reach past a box (left, top, right, bottom) on each of those sides: their boxes with half
|
||
* their line, and their shadow moved by its offset and grown by its blur; but for a shape filled far off the canvas
|
||
* that only its shadow lands from, as Sacred Rosette's dots cast their glow, its shadow alone.
|
||
*/
|
||
function overhang(paints: CanvasRecorder['paints'], [left, top, right, bottom]: number[]): number[] {
|
||
const out = [-Infinity, -Infinity, -Infinity, -Infinity];
|
||
for (const p of paints) {
|
||
if (!p.box) continue;
|
||
const line = p.op.startsWith('stroke') ? p.lineWidth / 2 : 0;
|
||
const blur = parseColor(p.shadowColor)![3] > 0 ? p.shadowBlur : -Infinity;
|
||
const [x0, y0, x1, y1] = p.box;
|
||
const [sx0, sy0, sx1, sy1] = [x0 + p.shadowOffsetX - blur, y0 + p.shadowOffsetY - blur, x1 + p.shadowOffsetX + blur, y1 + p.shadowOffsetY + blur];
|
||
const alone = Math.abs(p.shadowOffsetX) >= 1e4;
|
||
out[0] = Math.max(out[0], line + left - (alone ? sx0 : Math.min(x0, sx0)));
|
||
out[1] = Math.max(out[1], line + top - (alone ? sy0 : Math.min(y0, sy0)));
|
||
out[2] = Math.max(out[2], line + (alone ? sx1 : Math.max(x1, sx1)) - right);
|
||
out[3] = Math.max(out[3], line + (alone ? sy1 : Math.max(y1, sy1)) - bottom);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
describe('what node effects make', () => {
|
||
/** `n` nodes of one size, all on screen. */
|
||
const row = (n: number): GraphSpec => ({ nodes: Array.from({ length: n }, (_, i) => ({ pos: [60 + (i % 5) * 300, 80 + Math.floor(i / 5) * 260], size: [220, 120] as [number, number] })) });
|
||
|
||
/** The gradients the front passes of five frames made, from the same moment on; with `still`, frames whose front is dirty though nothing moves. */
|
||
function gradients(canvas: FakeCanvas, still = false): number {
|
||
[clock.nodePhase, clock.particleTime, clock.nodeTime] = [2.1, 3.3, 3.3];
|
||
const from = canvas.front.lines.length;
|
||
for (let i = 0; i < 5; i++) {
|
||
if (still) canvas.setDirty(true, false);
|
||
frames(1);
|
||
}
|
||
return canvas.front.lines.slice(from).filter((line) => /= create(Radial|Linear)Gradient\(/.test(line)).length;
|
||
}
|
||
|
||
for (const [name, animate] of EFFECTS) {
|
||
it(`${name} makes its gradients once a frame for all the nodes of a palette and size, and none more while it holds still`, async () => {
|
||
const settings = { ...LINKS_OFF, ...PARTICLES, '📦 Enhanced Nodes.Animate': animate, '📦 Enhanced Nodes.Particle.Color.Mode': 'rainbow' };
|
||
const made: number[] = [];
|
||
for (const n of [1, 10]) {
|
||
const canvas = await start(settings, row(n));
|
||
frames(2);
|
||
made.push(gradients(canvas));
|
||
}
|
||
expect(made[1], name).toBe(made[0]);
|
||
// Flower of Life and Circuit Trace stroke and fill in their colours alone.
|
||
if (animate !== 5 && animate !== 7) expect(made[0], name).toBeGreaterThan(0);
|
||
preview('📦 Enhanced Nodes.Static.Mode', true);
|
||
const canvas = app.canvas as unknown as FakeCanvas;
|
||
frames(2);
|
||
expect(gradients(canvas, true), `${name}, still`).toBe(0);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('node effects', () => {
|
||
for (const [name, animate] of EFFECTS) {
|
||
it(`${name} draws on the body after its fill, clipped to it, under the widgets, and around it before any node`, async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, '📦 Enhanced Nodes.Animate': animate }, { nodes: [KSAMPLER] });
|
||
const { lines, calls } = frame(canvas);
|
||
const fill = bodyFill(lines);
|
||
const under = calls.filter((call) => call.line < fill);
|
||
const [over, ...more] = calls.filter((call) => call.line > fill);
|
||
expect(more).toEqual([]);
|
||
expect(over.sides).toBe(OVER);
|
||
expect(over.line).toBeLessThan(lines.indexOf('fillRect(10, 10, 200, 20)'));
|
||
// Clipped to the path native filled the body with.
|
||
expect(lines[lines.lastIndexOf('clip()', over.line) - 1]).toBe(lines[fill - 1]);
|
||
// Around the body only where the whole effect reaches past it: within the screen, without the body.
|
||
const whole = new CanvasRecorder();
|
||
drawNodeFx(whole.ctx, animate, canvas.graph._nodes[0], 220, 120, clock, WHOLE, 1, animate === 0);
|
||
// Jelly and Aurora Veil draw on their body alone, but for the selection frame, which reach() counts whether or not the node is selected.
|
||
const reaches = Math.max(...overhang(whole.paints, [0, -30, 220, 120])) > 0 || animate === 6 || animate === 8;
|
||
expect(under).toHaveLength(reaches ? 1 : 0);
|
||
if (!reaches) return;
|
||
expect(under[0].sides).toBe(UNDER);
|
||
const clip = lines.lastIndexOf('clip("evenodd")', under[0].line);
|
||
expect(lines.slice(clip - 2, clip)).toEqual(['rect(-300, -300, 1600, 900)', lines[fill - 1]]);
|
||
expect(under[0].ctx).toBe(canvas.ctx);
|
||
});
|
||
}
|
||
|
||
it('draw between their two parts what they draw whole, each element in the parts it reaches', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, '📦 Enhanced Nodes.Color.Hover.Show': true }, { nodes: [KSAMPLER] });
|
||
const [node] = canvas.graph._nodes;
|
||
[node.selected, node.mouseOver] = [true, true];
|
||
for (const [name, animate] of EFFECTS) {
|
||
preview('📦 Enhanced Nodes.Animate', animate);
|
||
const paints = (sides: number) => {
|
||
const rec = new CanvasRecorder();
|
||
const part = { sides, x0: 0, y0: -30, x1: 220, y1: 120, round: 8 };
|
||
drawNodeFx(rec.ctx, animate, node, 220, 120, clock, sides === (UNDER | OVER) ? WHOLE : part, 1, animate === 0);
|
||
// A gradient's label counts the gradients made before it, which a part makes fewer of.
|
||
return rec.paints.map((p) => JSON.stringify({ ...p, style: p.style.replace(/^grad\d+$/, 'gradient') }));
|
||
};
|
||
if (animate === 4 || animate === 5 || animate === 7 || animate === 9) {
|
||
// Sacred Rosette strokes its chords, and each layer's circles of a colour, as one path, Flower of Life
|
||
// its circles and its rings, Circuit Trace its pins, traces and pulses, and Ember Crown fills its
|
||
// flames: the parts draw the pieces of those paths that reach them.
|
||
const pieces = (sides: number) => {
|
||
const rec = new CanvasRecorder();
|
||
const part = { sides, x0: 0, y0: -30, x1: 220, y1: 120, round: 8 };
|
||
drawNodeFx(rec.ctx, animate, node, 220, 120, clock, sides === (UNDER | OVER) ? WHOLE : part, 1, false);
|
||
return new Set(rec.lines.filter((line) => /^(arc|lineTo|bezierCurveTo)\(/.test(line)));
|
||
};
|
||
const [whole, under, over] = [UNDER | OVER, UNDER, OVER].map(pieces);
|
||
for (const piece of [...under, ...over]) expect(whole.has(piece), `${name}: ${piece}`).toBe(true);
|
||
expect(new Set([...under, ...over]), name).toEqual(whole);
|
||
continue;
|
||
}
|
||
const [whole, under, over] = [UNDER | OVER, UNDER, OVER].map(paints);
|
||
// Each part draws only what the whole does, in its order, and together they draw all of it.
|
||
const within = (part: string[]) => {
|
||
let at = 0;
|
||
for (const p of part) {
|
||
at = whole.indexOf(p, at) + 1;
|
||
if (!at) return false;
|
||
}
|
||
return true;
|
||
};
|
||
expect(within(under), `${name}, under`).toBe(true);
|
||
expect(within(over), `${name}, over`).toBe(true);
|
||
expect(new Set([...under, ...over]), name).toEqual(new Set(whole));
|
||
expect(under.length + over.length, name).toBeLessThan(2 * whole.length);
|
||
}
|
||
});
|
||
|
||
it('clip to the body in its shape, and to a box in low quality, as native fills it', async () => {
|
||
const paths: string[] = [];
|
||
for (const [shape, lowQuality] of [[1, false], [2, false], [3, false], [4, false], [2, true]] as const) {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 4 }, { nodes: [{ ...KSAMPLER, shape }] });
|
||
canvas.low_quality = lowQuality;
|
||
const { lines, calls } = frame(canvas);
|
||
const fill = bodyFill(lines);
|
||
const over = calls.find((call) => call.sides === OVER)!;
|
||
expect(lines[lines.lastIndexOf('clip()', over.line) - 1]).toBe(lines[fill - 1]);
|
||
paths.push(lines[fill - 1]);
|
||
}
|
||
expect(paths).toEqual([
|
||
'rect(0, -30, 220, 150)', 'roundRect(0, -30, 220, 150, [8])', 'arc(110, 60, 110, 0, 6.28)', 'roundRect(0, -30, 220, 150, [8, 8, 0, 0])',
|
||
'rect(0, -30, 220, 150)',
|
||
]);
|
||
});
|
||
|
||
it('cast v1\'s halo under the effect: the body\'s shadow in the effect\'s primary colour, 10 × Glow, on native\'s offset', async () => {
|
||
for (const scale of [1, 0.5]) {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 4, '📦 Enhanced Nodes.Glow': 0.8 }, { nodes: [KSAMPLER] });
|
||
canvas.ds.scale = scale;
|
||
const { calls } = frame(canvas);
|
||
const [halo] = halos(canvas.front.paints);
|
||
const [body] = bodies(canvas.front.paints);
|
||
// Filled in the body's colour, at the node's alpha, past the right of the screen, so that only its shadow shows, and lands where the body's would.
|
||
expect([halo.style, halo.globalAlpha, halo.shadowColor, halo.shadowBlur], `zoom ${scale}`).toEqual(['#353535', 1, '#ffcc00', 8]);
|
||
expect(halo.box![0], `zoom ${scale}`).toBeGreaterThan(1600);
|
||
expect([halo.box![0] + halo.shadowOffsetX, halo.box![1] + halo.shadowOffsetY, halo.box![2] + halo.shadowOffsetX, halo.box![3] + halo.shadowOffsetY].map((x) => fmt(x)))
|
||
.toEqual([body.box![0] + 2 * scale, body.box![1] + 2 * scale, body.box![2] + 2 * scale, body.box![3] + 2 * scale].map((x) => fmt(x)));
|
||
// Before the effect draws round the body, which the body then casts no shadow over.
|
||
expect(canvas.front.paints.indexOf(halo)).toBeLessThan(calls[0].from);
|
||
expect(body.shadowColor).toBe('rgba(0, 0, 0, 0)');
|
||
}
|
||
});
|
||
|
||
it('cast the halo and draw the effect down to Perf.MinZoom, in low quality too, and neither below it', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 4 }, { nodes: [KSAMPLER] });
|
||
canvas.low_quality = true;
|
||
for (const [scale, drawn] of [[0.4, true], [0.2, false]] as const) {
|
||
canvas.ds.scale = scale;
|
||
const paints = canvas.front.paints.length;
|
||
const { calls } = frame(canvas);
|
||
expect([halos(canvas.front.paints.slice(paints)).length > 0, calls.length > 0], `zoom ${scale}`).toEqual([drawn, drawn]);
|
||
}
|
||
});
|
||
|
||
it('draw their glows centred, whatever offset native left', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 3, ...PARTICLES }, { nodes: [KSAMPLER] });
|
||
frame(canvas);
|
||
const over = probe.calls.at(-1)!;
|
||
// Native drawNode offset the shadows by 2; the corona glows around each particle.
|
||
const glows = canvas.front.paints.slice(over.from, over.to).filter((p) => p.shadowBlur > 0);
|
||
expect(glows.length).toBeGreaterThan(0);
|
||
expect(glows.every((p) => p.shadowOffsetX === 0 && p.shadowOffsetY === 0)).toBe(true);
|
||
});
|
||
|
||
it('fade with the node as native fades it: bypassed, muted and ghost nodes, with the styled title', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, ...TITLES, '📦 Enhanced Nodes.Animate': 1, '📦 Enhanced Nodes.Color.Hover.Show': true }, {
|
||
nodes: [{ pos: [100, 100] }, { pos: [400, 100], mode: 4 }, { pos: [100, 400], mode: 2 }, { pos: [400, 400], ghost: true }],
|
||
});
|
||
for (const node of canvas.graph._nodes) [node.mouseOver, node.selected] = [true, true];
|
||
const alphas = [1, 0.2, 0.4, 0.3];
|
||
const { calls } = frame(canvas);
|
||
const paints = canvas.front.paints;
|
||
const scaled = (painted: CanvasRecorder['paints'], alpha: number) => painted.map((p) => p.globalAlpha * alpha);
|
||
// Native draws each body at that alpha.
|
||
expect(bodies(paints).map((p) => p.globalAlpha)).toEqual(alphas);
|
||
expect(halos(paints).map((p) => p.globalAlpha)).toEqual(alphas);
|
||
// Under the nodes and on them, the same drawing as on the node at full alpha, with each of its alphas scaled.
|
||
expect(calls.map((call) => canvas.graph._nodes.indexOf(call.node as FakeNode))).toEqual([0, 1, 2, 3, 0, 1, 2, 3]);
|
||
const drawn = (i: number) => paints.slice(calls[i].from, calls[i].to);
|
||
expect(new Set(drawn(4).map((p) => p.globalAlpha)).size).toBeGreaterThan(5);
|
||
for (const layer of [0, 4]) {
|
||
for (const [i, alpha] of alphas.entries()) {
|
||
drawn(layer + i).forEach((p, j) => expect(p.globalAlpha, `call ${layer + i}, paint ${j}`).toBeCloseTo(scaled(drawn(layer), alpha)[j], 9));
|
||
}
|
||
}
|
||
// Gentle Pulse's hover outline and v1's frame among them.
|
||
expect(drawn(4).filter((p) => p.op === 'strokeRect').map((p) => p.style)).toEqual(['rgba(255, 255, 21, 0.7)', '#88ccff']);
|
||
// Neon draws each title four times, the last at an alpha of its own.
|
||
const titles = paints.filter((p) => p.op === 'fillText');
|
||
expect(titles.map((p) => p.globalAlpha)).toEqual(alphas.flatMap((alpha) => scaled(titles.slice(0, 4), alpha)));
|
||
});
|
||
|
||
it('draw nothing on a collapsed node, nor its halo', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, '📦 Enhanced Nodes.Animate': 1, '📦 Enhanced Nodes.Color.Hover.Show': true }, {
|
||
nodes: [{ pos: [300, 300], collapsed: true }],
|
||
});
|
||
canvas.graph._nodes[0].mouseOver = true;
|
||
const { lines, calls } = frame(canvas);
|
||
expect(calls).toEqual([]);
|
||
expect(lines.filter((line) => line.startsWith('strokeRect('))).toEqual([]);
|
||
expect(canvas.front.paints.find((p) => p.op === 'fill' && p.style === '#353535')!.shadowColor).toBe('rgba(0, 0, 0, 0.5)');
|
||
expect([live.nodes, live.particles]).toEqual([0, 0]);
|
||
});
|
||
|
||
it('draw nothing, nor halos, styled titles or fireworks, with Node effects off, which keeps every node setting', async () => {
|
||
const spec: GraphSpec = { nodes: [{ ...KSAMPLER, output: true }] };
|
||
const selected = async (settings: Record<string, unknown>) => {
|
||
const canvas = await start(settings, spec);
|
||
canvas.graph._nodes[0].selected = canvas.graph._nodes[0].mouseOver = true;
|
||
return canvas;
|
||
};
|
||
const native = frame(await selected(LINKS_OFF)).lines;
|
||
const canvas = await selected({ ...LINKS_OFF, ...PARTICLES, ...TITLES, '📦 Enhanced Nodes.Animate': 2, '📦 Enhanced Nodes.Color.Hover.Show': true, '📦 Enhanced Nodes.Enabled': false });
|
||
const off = frame(canvas);
|
||
expect(off.calls).toEqual([]);
|
||
expect(off.lines).toEqual(native);
|
||
run('off', [1]);
|
||
frames(1);
|
||
expect([live.nodes, live.particles, live.bursts]).toEqual([0, 0, 0]);
|
||
expect([N.animate, N.particleShow, N.textAnimationEnabled]).toEqual([2, true, true]);
|
||
preview('📦 Enhanced Nodes.Enabled', true);
|
||
expect(frame(canvas).calls).toHaveLength(2);
|
||
expect(fake.posts).toEqual([]);
|
||
});
|
||
|
||
it('draw particles only, and only the halo with no particles, with Show base effect off', async () => {
|
||
for (const [particles, drawn] of [[true, 2], [false, 0]] as const) {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 2, '📦 Enhanced Nodes.Animations.Enabled': false, '📦 Enhanced Nodes.Particle.Show': particles }, { nodes: [KSAMPLER] });
|
||
// Nor v1's selection frame, which belongs to the base effect.
|
||
canvas.graph._nodes[0].selected = true;
|
||
const { calls, lines } = frame(canvas);
|
||
expect(calls, `particles ${particles}`).toHaveLength(drawn);
|
||
expect(lines.filter((line) => line.startsWith('strokeRect(')), `particles ${particles}`).toEqual([]);
|
||
expect(canvas.front.paints.find((p) => p.op === 'fill' && p.style === '#353535')!.shadowColor, `particles ${particles}`).toBe('#00ff88');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('titles', () => {
|
||
/** The texts of a frame's fillText calls, and where it drew them. */
|
||
const texts = (lines: string[]) => lines.filter((line) => line.startsWith('fillText('));
|
||
|
||
it("take the native title's place, over the node's effect, while native draws its own on the hidden canvas", async () => {
|
||
const native = vi.spyOn(FakeNode.prototype, 'drawTitleText');
|
||
const canvas = await start({ ...LINKS_OFF, ...TITLES, '📦 Enhanced Nodes.Animate': 2, '📦 Enhanced Nodes.Text.Style': 'retro' }, {
|
||
nodes: [KSAMPLER, { pos: [700, 300], collapsed: true, title: 'Load' }],
|
||
});
|
||
clock.nodePhase = 0;
|
||
const { lines, calls } = frame(canvas);
|
||
// Retro draws a copy a pixel either side, the title, and its tracking band: after the title box, on the bar's middle, in native's font; on a collapsed node too.
|
||
expect(texts(lines)).toEqual([
|
||
'fillText("KSampler", 29, -15)', 'fillText("KSampler", 31, -15)', 'fillText("KSampler", 30, -15)', 'fillText("KSampler", 31.5, -15)',
|
||
'fillText("Load", 29, -15)', 'fillText("Load", 31, -15)', 'fillText("Load", 30, -15)', 'fillText("Load", 31.5, -15)',
|
||
]);
|
||
expect(lines).toEqual(expect.arrayContaining(['font = 14px Inter', 'textAlign = left', 'textBaseline = middle']));
|
||
expect(native.mock.calls.map(([ctx]) => ctx === dummy())).toEqual([true, true]);
|
||
// KSampler's, the first text the frame paints, after its effect's part on the body.
|
||
const over = calls.find((call) => call.sides === OVER)!;
|
||
expect(canvas.front.paints.findIndex((p, i) => i >= calls[0].from && p.op === 'fillText')).toBeGreaterThanOrEqual(over.to);
|
||
});
|
||
|
||
it("show native's text: with the pin, cut to the room the title bar leaves, and to 20 characters when collapsed", async () => {
|
||
const canvas = await start(LINKS_OFF, {
|
||
nodes: [
|
||
{ pos: [100, 100], size: [140, 80], title: 'A long title for a narrow node' }, { pos: [100, 400], collapsed: true, title: 'Another title that runs on and on' },
|
||
{ pos: [500, 100], title: 'KSampler' },
|
||
],
|
||
});
|
||
canvas.graph._nodes[0].flags.pinned = true;
|
||
canvas.graph._nodes[2].flags.pinned = true;
|
||
const natives = texts(frame(canvas).lines).map((line) => /"(.*)"/.exec(line)![1]);
|
||
expect(natives).toEqual(['A long t...', 'Another title that r', 'KSampler📌']);
|
||
preview('📦 Enhanced Nodes.Text.Animation.Enabled', true);
|
||
canvas.setDirty(true, true);
|
||
// Neon draws the title four times.
|
||
expect(texts(frame(canvas).lines).map((line) => /"(.*)"/.exec(line)![1])).toEqual(natives.flatMap((text) => [text, text, text, text]));
|
||
// Title buttons take their room too.
|
||
canvas.graph._nodes[0].size = [400, 80];
|
||
canvas.graph._nodes[0].title_buttons = [{ visible: true, getWidth: () => 200 }];
|
||
canvas.setDirty(true, true);
|
||
expect(texts(frame(canvas).lines)[0]).toBe('fillText("A long title for a ...", 30, -15)');
|
||
});
|
||
|
||
it("take Title color with custom node colours, else the accent of their node's effect", async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...TITLES, '📦 Enhanced Nodes.Animate': 3, '📦 Enhanced Nodes.Text.Style': 'retro', '📦 Enhanced Nodes.Text.Color': '#123456' }, { nodes: [KSAMPLER] });
|
||
const colour = () => {
|
||
const from = canvas.front.paints.length;
|
||
canvas.setDirty(true, true);
|
||
frames(1);
|
||
// Retro's title, under its tracking band.
|
||
return canvas.front.paints.slice(from).filter((p) => p.op === 'fillText').at(-2)!.style;
|
||
};
|
||
expect(colour()).toBe('#ff40ff');
|
||
canvas.graph._nodes[0].properties.enhanced_fx_style = 2;
|
||
expect(colour()).toBe('#00ff44');
|
||
preview('📦 Enhanced Nodes.Color.Mode', 'custom');
|
||
expect(colour()).toBe('#123456');
|
||
});
|
||
|
||
it("move on nodes without an effect, and hold v1's still pose when node effects do", async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...TITLES, '📦 Enhanced Nodes.Text.Style': 'retro' }, { nodes: [KSAMPLER] });
|
||
frames(3);
|
||
expect(live.nodes).toBe(1);
|
||
preview('📦 Enhanced Nodes.Static.Mode', true);
|
||
const { lines } = frame(canvas);
|
||
// Retro's copies sit sin(5 × phase) / 2 px off the title, at v1's still phase, π/4.
|
||
expect(texts(lines)[0]).toBe(`fillText("KSampler", 29, ${fmt(-15 + Math.sin((5 * Math.PI) / 4) * 0.5)})`);
|
||
expect(live.nodes).toBe(0);
|
||
});
|
||
|
||
it('are left to native for a node that draws its own, one without a title, in low quality and with Animated titles off', async () => {
|
||
const native = vi.spyOn(FakeNode.prototype, 'drawTitleText');
|
||
const canvas = await start({ ...LINKS_OFF, ...TITLES }, { nodes: [{ pos: [100, 100], title: '' }, { pos: [400, 100], title: 'Own' }] });
|
||
const own = vi.fn();
|
||
const [untitled] = canvas.graph._nodes;
|
||
canvas.graph._nodes[1].onDrawTitleText = own;
|
||
expect(texts(frame(canvas).lines)).toEqual(['fillText("", 30, -10)']);
|
||
expect(own).toHaveBeenCalledTimes(1);
|
||
// Native draws no title in low quality, titled or not.
|
||
[canvas.low_quality, untitled.title] = [true, 'Titled'];
|
||
canvas.setDirty(true, true);
|
||
expect(texts(frame(canvas).lines)).toEqual([]);
|
||
[canvas.low_quality, untitled.title] = [false, ''];
|
||
canvas.graph._nodes[1].onDrawTitleText = undefined;
|
||
preview('📦 Enhanced Nodes.Text.Animation.Enabled', false);
|
||
expect(texts(frame(canvas).lines)).toEqual(['fillText("", 30, -10)', 'fillText("Own", 30, -10)']);
|
||
expect(native.mock.calls.every(([ctx]) => ctx === canvas.ctx)).toBe(true);
|
||
});
|
||
|
||
it('are native where the styled title fails, and once the guard turns it off', async () => {
|
||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
const native = vi.spyOn(FakeNode.prototype, 'drawTitleText');
|
||
const canvas = await start({ ...LINKS_OFF, ...TITLES }, { nodes: [KSAMPLER] });
|
||
// The styled title fails as it starts, reading Title glow, which nothing else reads.
|
||
let tries = 0;
|
||
Object.defineProperty(N, 'textGlow', { configurable: true, get: () => { tries++; throw new Error('boom'); } });
|
||
try {
|
||
for (let i = 0; i < 6; i++) {
|
||
canvas.setDirty(true, false);
|
||
frames(1);
|
||
}
|
||
} finally {
|
||
Object.defineProperty(N, 'textGlow', { configurable: true, enumerable: true, writable: true, value: 0.5 });
|
||
}
|
||
// Each frame shows native's title: drawn again on the canvas after the styled one failed, then alone.
|
||
expect(native.mock.calls.map(([ctx]) => (ctx === canvas.ctx ? 'canvas' : 'hidden'))).toEqual([...Array(5).fill(['hidden', 'canvas']).flat(), 'canvas']);
|
||
expect([tries, guard.off('Animated titles')]).toEqual([5, true]);
|
||
expect(warn).toHaveBeenCalledOnce();
|
||
canvas.front.assertBalanced();
|
||
});
|
||
|
||
it('count as moving only where the styled title is drawn', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...TITLES, '📦 Enhanced Nodes.Animate': 1, '📦 Enhanced Nodes.Animations.Enabled': false }, {
|
||
nodes: [{ pos: [100, 100] }, { pos: [400, 100] }],
|
||
});
|
||
const [own, bare] = canvas.graph._nodes;
|
||
own.onDrawTitleText = vi.fn();
|
||
// As a legacy Reroute node has: TitleMode.NO_TITLE.
|
||
bare.title_mode = 1;
|
||
frames(5);
|
||
expect(live.nodes).toBe(0);
|
||
page.requests = 0;
|
||
frames(30);
|
||
expect(page.requests).toBe(0);
|
||
bare.title_mode = 0;
|
||
canvas.setDirty(true, true);
|
||
frames(1);
|
||
expect(live.nodes).toBe(1);
|
||
// In low quality native draws no title, so none moves.
|
||
canvas.low_quality = true;
|
||
canvas.setDirty(true, true);
|
||
frames(1);
|
||
expect(live.nodes).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('the over-layer and the title', () => {
|
||
it('hand the originals every argument, once each', async () => {
|
||
const shape = vi.spyOn(FakeCanvas.prototype, 'drawNodeShape');
|
||
const title = vi.spyOn(FakeNode.prototype, 'drawTitleText');
|
||
const canvas = await start({ ...LINKS_OFF, ...TITLES, '📦 Enhanced Nodes.Animate': 2 }, { nodes: [KSAMPLER] });
|
||
frame(canvas);
|
||
const [node] = canvas.graph._nodes;
|
||
expect(shape.mock.calls).toEqual([[node, canvas.ctx, [220, 120], '#333', '#353535', false]]);
|
||
expect(title.mock.calls).toEqual([[dummy(), { scale: 1, default_title_color: '#999', low_quality: false }]]);
|
||
});
|
||
|
||
it('leave the canvas as they found it when an effect fails, which the guard turns off after 5 failures in 10 s', async () => {
|
||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 1 }, { nodes: [KSAMPLER] });
|
||
probe.fail = true;
|
||
frame(canvas);
|
||
// Under and on the body it failed; the widget is still drawn where it belongs, at full alpha.
|
||
canvas.front.assertBalanced();
|
||
expect(canvas.front.paints.at(-1)).toMatchObject({ op: 'fillRect', box: [310, 310, 510, 330], globalAlpha: 1 });
|
||
frames(3);
|
||
expect(guard.off('Node effect')).toBe(true);
|
||
expect(fake.toasts).toEqual([expect.objectContaining({ summary: 'Node effect turned off' })]);
|
||
expect(warn).toHaveBeenCalledTimes(1);
|
||
|
||
// Off: the nodes are native, halo and all, nothing moves and the ticker stops, until a setting changes.
|
||
probe.fail = false;
|
||
canvas.setDirty(true, true);
|
||
expect(frame(canvas).calls).toEqual([]);
|
||
expect(canvas.front.paints.filter((p) => p.op === 'fill' && p.style === '#353535').at(-1)!.shadowColor).toBe('rgba(0, 0, 0, 0.5)');
|
||
expect(live.nodes).toBe(0);
|
||
page.requests = 0;
|
||
frames(30);
|
||
expect(page.requests).toBe(0);
|
||
preview('📦 Enhanced Nodes.Glow', 0.7);
|
||
expect(frame(canvas).calls).toHaveLength(2);
|
||
});
|
||
|
||
it('restore what an effect saved when it fails inside its own save', async () => {
|
||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, '📦 Enhanced Nodes.Animate': 1, '📦 Enhanced Nodes.Color.Particle': '#ffb300' }, { nodes: [KSAMPLER] });
|
||
// A colour the canvas would not take, for the fireflies Gentle Pulse draws after its save().
|
||
vi.spyOn(colors, 'particleColour').mockReturnValue('nothing');
|
||
frame(canvas);
|
||
canvas.front.assertBalanced();
|
||
expect(canvas.front.paints.at(-1)).toMatchObject({ op: 'fillRect', box: [310, 310, 510, 330], globalAlpha: 1 });
|
||
});
|
||
});
|
||
|
||
describe('the under-layer', () => {
|
||
it('draws the particles alone in Vue mode, around the DOM node, without the halo or titles, and nothing without them, so the ticker stops', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, ...TITLES, '📦 Enhanced Nodes.Animate': 1 }, { nodes: [KSAMPLER] });
|
||
frames(5);
|
||
(globalThis as unknown as { LiteGraph: { vueNodesMode: boolean } }).LiteGraph.vueNodesMode = true;
|
||
canvas.setDirty(true, true);
|
||
// The halo is cast before the effect is drawn, so the whole frame is looked at.
|
||
const from = canvas.front.paints.length;
|
||
const { calls } = frame(canvas);
|
||
expect(calls.map(({ sides, only }) => [sides, only])).toEqual([[UNDER, true]]);
|
||
expect([halos(canvas.front.paints.slice(from)), live.nodes, live.particles]).toEqual([[], 1, 1]);
|
||
preview(PARTICLE_SHOW, false);
|
||
expect(frame(canvas).calls).toEqual([]);
|
||
expect([live.nodes, live.particles]).toEqual([0, 0]);
|
||
page.requests = 0;
|
||
frames(60);
|
||
expect(page.requests).toBe(0);
|
||
});
|
||
|
||
// Every slider at its top, with the node selected and hovered, gives each effect its widest drawing.
|
||
const RANGES: Record<string, Record<string, unknown>> = {
|
||
defaults: {},
|
||
smallest: { '📦 Enhanced Nodes.Animation.Size': 0.5, '📦 Enhanced Nodes.Quality': 1, '📦 Enhanced Nodes.Particle.Size': 0.1, '📦 Enhanced Nodes.Glow': 0 },
|
||
largest: {
|
||
'📦 Enhanced Nodes.Intensity': 2, '📦 Enhanced Nodes.Animation.Glow': 1, '📦 Enhanced Nodes.Glow': 1, '📦 Enhanced Nodes.Animation.Size': 3,
|
||
'📦 Enhanced Nodes.Quality': 3, '📦 Enhanced Nodes.Particle.Size': 3, '📦 Enhanced Nodes.Particle.Intensity': 2, '📦 Enhanced Nodes.Particle.Glow': 1,
|
||
'📦 Enhanced Nodes.Color.Hover.Show': true,
|
||
},
|
||
};
|
||
|
||
it('takes every node whose effect reaches the screen, however far off it its body is', async () => {
|
||
for (const [name, animate] of EFFECTS) {
|
||
for (const [range, settings] of Object.entries(RANGES)) {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, ...settings, '📦 Enhanced Nodes.Animate': animate }, { nodes: [{ pos: [0, 0] }] });
|
||
const [node] = canvas.graph._nodes;
|
||
[node.selected, node.mouseOver] = [true, true];
|
||
for (const [w, h] of [[140, 46], [220, 120], [320, 260]]) {
|
||
node.size = [w, h];
|
||
canvas.setDirty(true, true);
|
||
frames(1);
|
||
// How far the whole effect reaches past the node's box, title bar included, at a few moments.
|
||
const reach = [-Infinity, -Infinity, -Infinity, -Infinity];
|
||
for (const phase of [0.7, 2.1, 3.6, 5.2]) {
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, animate, node, w, h, { nodePhase: phase, particleTime: phase * 1.7, nodeTime: phase * 1.3 }, WHOLE, 1, animate === 0);
|
||
overhang(rec.paints, [0, -30, w, h]).forEach((r, side) => (reach[side] = Math.max(reach[side], r)));
|
||
}
|
||
// With the body off screen, its drawing one pixel onto it from each side it reaches past the area native draws the node in:
|
||
// renderArea is 4 px wider on the left, 6 on the right, 5 taller at the bottom, and 4 at the top.
|
||
for (const [side, r] of reach.entries()) {
|
||
if (r <= [4, 4, 6, 5][side]) continue;
|
||
const [x, y] = [[-r + 1 - 1600, h / 2 - 450], [w / 2 - 800, -30 - r + 1 - 900], [w + r - 1, h / 2 - 450], [w / 2 - 800, h + r - 1]][side];
|
||
canvas.ds.offset = [-x, -y];
|
||
canvas.setDirty(true, true);
|
||
frames(1);
|
||
expect(canvas.visible_nodes, `${name}, ${range}, ${w}x${h}, side ${side}: its body is off screen`).toEqual([]);
|
||
probe.rec = new CanvasRecorder();
|
||
probe.calls = [];
|
||
layers.nodes!(canvas as never, probe.rec.ctx);
|
||
expect(probe.calls, `${name}, ${range}, ${w}x${h}, side ${side} reaches ${r.toFixed(1)} px`).toHaveLength(1);
|
||
}
|
||
canvas.ds.offset = [0, 0];
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it('leaves out a node whose effect stays off screen', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, '📦 Enhanced Nodes.Animate': 1 }, { nodes: [{ pos: [5000, 5000] }] });
|
||
expect(frame(canvas).calls).toEqual([]);
|
||
expect([live.nodes, live.particles]).toEqual([0, 0]);
|
||
});
|
||
});
|
||
|
||
describe('the node menu', () => {
|
||
type Item = { content: string; has_submenu?: boolean; submenu?: { options: (Item | null)[] }; callback?: () => void };
|
||
const menu = (node: FakeNode) => (fake.extensions.find((e) => e.name === 'enhanced.node.animations') as unknown as { getNodeMenuItems(node: FakeNode): Item[] }).getNodeMenuItems(node);
|
||
const labels = (item: Item) => item.submenu!.options.map((option) => option?.content ?? null);
|
||
const pick = (node: FakeNode, menuName: string, label: string) => menu(node).find((item) => item.content === menuName)!.submenu!.options.find((option) => option?.content.replace('✓ ', '') === label)!.callback!();
|
||
|
||
it('offers Node FX, Node background and Completion FX, each one submenu level, with the choice ticked and Use global naming the global one', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 2 }, { nodes: [KSAMPLER] });
|
||
const [node] = canvas.graph._nodes;
|
||
const items = menu(node);
|
||
// Then Go to connected node (tests/unit/navigation.test.ts).
|
||
expect(items.map((item) => [item.content, item.has_submenu])).toEqual([['Node FX', true], ['Node background', true], ['Completion FX', true], ['Go to connected node', true]]);
|
||
expect(labels(items[0])).toEqual(['✓ Use global (Neon Nexus)', 'Off', 'Gentle Pulse', 'Neon Nexus', 'Cosmic Ripple', 'Sacred Rosette', 'Flower of Life', 'Jelly', 'Circuit Trace', 'Aurora Veil', 'Ember Crown', null, 'Particles only']);
|
||
expect(labels(items[1])).toEqual(['✓ Use global (None)', 'None', 'Plasma', 'Aurora', 'Starfield', 'Liquid', 'Smoke', 'Grid', 'Lava', 'Caustics']);
|
||
expect(labels(items[2])).toEqual(['✓ Use global', 'Primary (anchor here)', 'Exclude']);
|
||
for (const item of items) expect(item.submenu!.options.every((option) => !option?.has_submenu && !option?.submenu)).toBe(true);
|
||
node.properties = { enhanced_fx_style: 3, enhanced_fx_particles_only: true, enhanced_fx_completion: 'exclude', enhanced_fx_background: 'lava' };
|
||
expect(labels(menu(node)[0])).toEqual(['Use global (Neon Nexus)', 'Off', 'Gentle Pulse', 'Neon Nexus', '✓ Cosmic Ripple', 'Sacred Rosette', 'Flower of Life', 'Jelly', 'Circuit Trace', 'Aurora Veil', 'Ember Crown', null, '✓ Particles only']);
|
||
expect(labels(menu(node)[1])).toEqual(['Use global (None)', 'None', 'Plasma', 'Aurora', 'Starfield', 'Liquid', 'Smoke', 'Grid', 'Lava', 'Caustics'].map((label) => (label === 'Lava' ? '✓ Lava' : label)));
|
||
expect(labels(menu(node)[2])).toEqual(['Use global', 'Primary (anchor here)', '✓ Exclude']);
|
||
});
|
||
|
||
it('write a pick to the node as one change the history can undo, and Use global takes it back out', async () => {
|
||
const canvas = await start(LINKS_OFF, { nodes: [KSAMPLER] });
|
||
const [node] = canvas.graph._nodes;
|
||
pick(node, 'Node FX', 'Neon Nexus');
|
||
pick(node, 'Node FX', 'Particles only');
|
||
pick(node, 'Completion FX', 'Primary (anchor here)');
|
||
expect(node.properties).toEqual({ enhanced_fx_style: 2, enhanced_fx_particles_only: true, enhanced_fx_completion: 'primary' });
|
||
expect(canvas.calls.filter((call) => call.startsWith('emit'))).toEqual(Array(3).fill(['emitBeforeChange', 'emitAfterChange']).flat());
|
||
pick(node, 'Node FX', 'Use global (Off)');
|
||
pick(node, 'Completion FX', 'Use global');
|
||
expect(node.properties).toEqual({});
|
||
});
|
||
|
||
it('set Node FX for every selected node, in one change, when the node is one of them, and tick what they all have', async () => {
|
||
const canvas = await start(LINKS_OFF, { nodes: [KSAMPLER, { pos: [700, 300] }, { pos: [300, 700] }] });
|
||
const [first, second, other] = canvas.graph._nodes;
|
||
for (const node of [first, second]) {
|
||
node.selected = true;
|
||
canvas.selectedItems.add(node);
|
||
}
|
||
second.properties.enhanced_fx_style = 2;
|
||
const [fx, background, completion] = menu(first);
|
||
expect([fx.content, background.content]).toEqual(['Node FX (2 selected)', 'Node background (2 selected)']);
|
||
// They have no effect in common, and both draw their whole effect.
|
||
expect(labels(fx).filter((label) => label?.startsWith('✓'))).toEqual([]);
|
||
pick(first, 'Node FX (2 selected)', 'Cosmic Ripple');
|
||
pick(first, 'Node FX (2 selected)', 'Particles only');
|
||
expect([first.properties, second.properties, other.properties]).toEqual([{ enhanced_fx_style: 3, enhanced_fx_particles_only: true }, { enhanced_fx_style: 3, enhanced_fx_particles_only: true }, {}]);
|
||
expect(canvas.calls.filter((call) => call.startsWith('emit'))).toEqual(Array(2).fill(['emitBeforeChange', 'emitAfterChange']).flat());
|
||
expect(labels(menu(second)[0])).toEqual(['Use global (Off)', 'Off', 'Gentle Pulse', 'Neon Nexus', '✓ Cosmic Ripple', 'Sacred Rosette', 'Flower of Life', 'Jelly', 'Circuit Trace', 'Aurora Veil', 'Ember Crown', null, '✓ Particles only']);
|
||
// Completion FX is the node's own, and a node outside the selection gets a menu of its own.
|
||
completion.submenu!.options.find((option) => option?.content === 'Exclude')!.callback!();
|
||
expect([first.properties.enhanced_fx_completion, second.properties.enhanced_fx_completion]).toEqual(['exclude', undefined]);
|
||
expect(menu(other)[0].content).toBe('Node FX');
|
||
});
|
||
|
||
it('give a node its own effect: its style, None for nothing, and its Particles only', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, ...PARTICLES, '📦 Enhanced Nodes.Animate': 1 }, { nodes: [KSAMPLER, { pos: [700, 300] }, { pos: [300, 700] }] });
|
||
const [own, none, only] = canvas.graph._nodes;
|
||
own.properties.enhanced_fx_style = 3;
|
||
none.properties.enhanced_fx_style = 0;
|
||
only.properties.enhanced_fx_particles_only = true;
|
||
const calls = frame(canvas).calls.filter((call) => call.sides === OVER);
|
||
expect(calls.map((call) => [canvas.graph._nodes.indexOf(call.node as FakeNode), call.only])).toEqual([[0, false], [2, true]]);
|
||
// Cosmic Ripple's halo on the first, native's shadow on the second, Gentle Pulse's halo on the third.
|
||
expect(halos(canvas.front.paints).map((p) => p.shadowColor)).toEqual(['#ff00ff', '#44aaff']);
|
||
expect(bodies(canvas.front.paints).map((p) => p.shadowColor)).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.5)', 'rgba(0, 0, 0, 0)']);
|
||
});
|
||
|
||
it('ignore what a workflow holds there that is not a choice, and write nothing as nodes are drawn or the menu is built', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 2 }, { nodes: [KSAMPLER, { pos: [700, 300] }, { pos: [300, 700] }] });
|
||
const [odd, kept, bare] = canvas.graph._nodes;
|
||
odd.properties = { enhanced_fx_style: 99, enhanced_fx_particles_only: 'yes', enhanced_fx_completion: 'loud' };
|
||
kept.properties = { enhanced_fx_style: 4, enhanced_fx_completion: 'primary', other: 1 };
|
||
const before = JSON.stringify([odd.properties, kept.properties]);
|
||
const from = canvas.front.paints.length;
|
||
const { calls } = frame(canvas);
|
||
// The odd one draws Animate's Neon Nexus, whole; the other its Sacred Rosette; the one loaded with nothing set Animate's too.
|
||
expect(halos(canvas.front.paints.slice(from)).map((p) => p.shadowColor)).toEqual(['#00ff88', '#ffcc00', '#00ff88']);
|
||
expect(calls.filter((call) => call.sides === OVER).map((call) => call.only)).toEqual([false, false, false]);
|
||
menu(odd);
|
||
menu(kept);
|
||
menu(bare);
|
||
frames(3);
|
||
expect(JSON.stringify([odd.properties, kept.properties])).toBe(before);
|
||
expect(bare.properties).toEqual({});
|
||
expect(canvas.calls.filter((call) => call.startsWith('emit'))).toEqual([]);
|
||
expect(labels(menu(odd)[0])[0]).toBe('✓ Use global (Neon Nexus)');
|
||
});
|
||
});
|
||
|
||
describe('Flower of Life', () => {
|
||
const FLOWER = 5;
|
||
/** The node clock `t` s in at speed 1. */
|
||
const at = (t: number) => ({ nodePhase: ((2 * Math.PI) / 15) * t, particleTime: t, nodeTime: t });
|
||
/** What Flower of Life draws whole on the KSampler, 220 × 120, `t` s into its cycle. */
|
||
async function drawn(t: number, settings: Record<string, unknown> = {}) {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': FLOWER, ...settings }, { nodes: [KSAMPLER] });
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, FLOWER, canvas.graph._nodes[0], 220, 120, at(t), WHOLE, 1, false);
|
||
return rec;
|
||
}
|
||
/** The arcs it strokes, as centre, radius and sweep. */
|
||
const arcs = (rec: CanvasRecorder) => rec.lines.flatMap((line) => {
|
||
const m = /^arc\(([^,]+), ([^,]+), ([^,]+), ([^,]+), ([^,)]+)\)$/.exec(line);
|
||
return m ? [{ x: Number(m[1]), y: Number(m[2]), r: Number(m[3]), sweep: Number(m[5]) - Number(m[4]) }] : [];
|
||
});
|
||
/** Its circles' radius on the KSampler at Animation.Size 1: 0.14 of the shorter side. */
|
||
const R = 0.14 * 120;
|
||
|
||
it("draws nineteen circles a radius across, each through the middles of those beside it, inside a ring at Balanced, the Seed of Life's seven at Fast", async () => {
|
||
const circles = (rec: CanvasRecorder) => arcs(rec).filter((a) => Math.abs(a.r - R) < 0.01);
|
||
const flower = circles(await drawn(8));
|
||
expect(flower).toHaveLength(19);
|
||
const near = (a: { x: number; y: number }) => flower.filter((b) => b !== a && Math.abs(Math.hypot(a.x - b.x, a.y - b.y) - R) < 0.05).length;
|
||
// The middle circle and the Seed of Life's six have six neighbours a radius off; of the outer twelve, those between
|
||
// two others four, and the six at its corners three.
|
||
expect(flower.map(near).sort((a, b) => b - a)).toEqual([6, 6, 6, 6, 6, 6, 6, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3]);
|
||
expect(arcs(await drawn(8)).filter((a) => Math.abs(a.r - 3 * R) < 0.01)).toHaveLength(1);
|
||
expect(arcs(await drawn(8, { '📦 Enhanced Nodes.Quality': 3 })).filter((a) => a.r > 2 * R).map((a) => a.r)).toEqual([3 * R, 3.12 * R].map((r) => Number(r.toFixed(2))));
|
||
expect(circles(await drawn(8, { '📦 Enhanced Nodes.Quality': 1 }))).toHaveLength(7);
|
||
expect(arcs(await drawn(8, { '📦 Enhanced Nodes.Quality': 1 })).filter((a) => a.r > 2 * R)).toEqual([]);
|
||
});
|
||
|
||
it('draws itself in, the Seed of Life over the first 1.5 s, the rest and the ring by 4 s, and fades out over the last second of 16', async () => {
|
||
const sweeps = async (t: number) => arcs(await drawn(t)).filter((a) => Math.abs(a.r - R) < 0.01).map((a) => a.sweep);
|
||
const [start, seed, later, whole] = await Promise.all([0.1, 1.5, 3, 4].map(sweeps));
|
||
expect(start).toHaveLength(1);
|
||
expect(start[0]).toBeLessThan(2 * Math.PI - 0.1);
|
||
// Whole turns, but for the trace's rounding of each angle.
|
||
const whole360 = (sweep: number) => Math.abs(sweep - 2 * Math.PI) < 0.02;
|
||
expect(seed.filter(whole360)).toHaveLength(7);
|
||
expect(later.length).toBeGreaterThan(7);
|
||
expect(later.length).toBeLessThan(19);
|
||
expect(whole.filter(whole360)).toHaveLength(19);
|
||
const alpha = async (t: number) => (await drawn(t)).paints.find((p) => p.op === 'stroke')!.globalAlpha;
|
||
expect(await alpha(15.5)).toBeCloseTo((await alpha(8)) / 2, 6);
|
||
// Then round again, from nothing.
|
||
expect(await sweeps(16.1)).toEqual(await sweeps(0.1));
|
||
});
|
||
|
||
it('holds still in full bloom, whatever the clock, without its shimmer', async () => {
|
||
const still = async (t: number) => {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': FLOWER, '📦 Enhanced Nodes.Static.Mode': true, '📦 Enhanced Nodes.Quality': 3 }, { nodes: [KSAMPLER] });
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, FLOWER, canvas.graph._nodes[0], 220, 120, at(t), WHOLE, 1, false);
|
||
return rec;
|
||
};
|
||
const pose = (await still(0.3)).trace();
|
||
expect((await still(9.7)).trace()).toBe(pose);
|
||
expect(arcs(await still(0.3)).filter((a) => Math.abs(a.r - R) < 0.01)).toHaveLength(19);
|
||
expect((await still(9.7)).paints.filter((p) => p.op === 'fill')).toEqual([]);
|
||
});
|
||
|
||
it('shimmers through its petals in bloom at Rich alone, a wave out from the middle', async () => {
|
||
const petals = async (t: number, quality: number) => (await drawn(t, { '📦 Enhanced Nodes.Quality': quality })).paints.filter((p) => p.op === 'fill');
|
||
expect(await petals(8, 2)).toEqual([]);
|
||
expect(await petals(2, 3)).toEqual([]);
|
||
// Near the middle as the wave starts, farther out later.
|
||
const middle = async (t: number) => {
|
||
const lit = await petals(t, 3);
|
||
expect(lit.length, `${t} s`).toBeGreaterThan(0);
|
||
return Math.max(...lit.map((p) => Math.hypot((p.box![0] + p.box![2]) / 2 - 110, (p.box![1] + p.box![3]) / 2 - 60)));
|
||
};
|
||
expect(await middle(4.2)).toBeLessThan(await middle(6));
|
||
});
|
||
|
||
it('runs its sparks round the circles drawn in, and none round those still drawing', async () => {
|
||
const sparks = async (t: number) => (await drawn(t, PARTICLES)).paints.filter((p) => p.op === 'fill' && Math.abs((p.box![2] - p.box![0]) / 2 - 1.2) < 0.01);
|
||
// Two a circle at Density 1: the Seed of Life's seven at 1.5 s, all nineteen at 4 s.
|
||
expect(await sparks(1.5)).toHaveLength(14);
|
||
expect(await sparks(4)).toHaveLength(38);
|
||
expect(await sparks(0.2)).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('Jelly', () => {
|
||
const JELLY = { ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 6 };
|
||
/** The shape a front pass gives the node: each transform() it sets, as its numbers. */
|
||
const shapes = (canvas: FakeCanvas) => {
|
||
canvas.setDirty(true, true);
|
||
return frame(canvas).lines.filter((line) => line.startsWith('transform(')).map((line) => line.slice(10, -1).split(', ').map(Number));
|
||
};
|
||
/** Front passes until the node keeps still, at most `most`: how many it took, and the shapes on the way. */
|
||
const settle = (canvas: FakeCanvas, most = 240) => {
|
||
const seen: number[][] = [];
|
||
for (let i = 0; i < most; i++) {
|
||
const now = shapes(canvas);
|
||
if (!now.length) return { passes: i, seen };
|
||
seen.push(now[0]);
|
||
}
|
||
return { passes: most, seen };
|
||
};
|
||
|
||
it('draws the node as native does while it keeps still', async () => {
|
||
const canvas = await start(JELLY, { nodes: [KSAMPLER] });
|
||
frames(2);
|
||
expect(shapes(canvas)).toEqual([]);
|
||
expect(live.nodes).toBe(0);
|
||
});
|
||
|
||
it('leans against a drag, keeping its area, sways back as it is let go, and settles still within two seconds', async () => {
|
||
const canvas = await start(JELLY, { nodes: [KSAMPLER] });
|
||
const [node] = canvas.graph._nodes;
|
||
frames(2);
|
||
// Dragged right, 300 px a second: its top lags behind, to the left.
|
||
let shape: number[] = [];
|
||
for (let i = 0; i < 30; i++) {
|
||
node.pos[0] += 5;
|
||
[shape] = shapes(canvas);
|
||
}
|
||
const [a, b, c, d] = shape;
|
||
expect(c).toBeGreaterThan(0.02);
|
||
expect(b).toBe(0);
|
||
expect(a * d).toBeCloseTo(1, 3);
|
||
// Let go, it swings past upright the other way before it settles.
|
||
const { passes, seen } = settle(canvas);
|
||
expect(seen.some(([, , lean]) => lean < 0)).toBe(true);
|
||
expect(passes).toBeLessThan(120);
|
||
expect(live.nodes).toBe(0);
|
||
});
|
||
|
||
it("squashes as it is selected, and as this client's run finishes it, but not while node effects hold still", async () => {
|
||
const canvas = await start(JELLY, { nodes: [KSAMPLER] });
|
||
const [node] = canvas.graph._nodes;
|
||
frames(2);
|
||
node.selected = true;
|
||
const { seen } = settle(canvas);
|
||
expect(seen.length).toBeGreaterThan(10);
|
||
// Taller and narrower, then wider, its area kept.
|
||
expect(seen.some(([, , , d]) => d > 1.005)).toBe(true);
|
||
expect(seen.some(([, , , d]) => d < 0.995)).toBe(true);
|
||
for (const [a, , , d] of seen) expect(a * d).toBeCloseTo(1, 3);
|
||
// A run through it: kicked as it stops running.
|
||
send('execution_start', { prompt_id: 'jelly', timestamp: 0 });
|
||
progress('jelly', { [node.id]: 'running' });
|
||
expect(shapes(canvas)).toEqual([]);
|
||
progress('jelly', { [node.id]: 'finished' });
|
||
expect(settle(canvas).seen.length).toBeGreaterThan(10);
|
||
send('execution_success', { prompt_id: 'jelly', timestamp: 0 });
|
||
// Static.
|
||
preview('📦 Enhanced Nodes.Static.Mode', true);
|
||
node.selected = false;
|
||
frames(1);
|
||
node.selected = true;
|
||
expect(settle(canvas).seen).toEqual([]);
|
||
preview('📦 Enhanced Nodes.Static.Mode', false);
|
||
});
|
||
|
||
it('moves the ends of its links with it as it wobbles, and puts them back as it settles', async () => {
|
||
const canvas = await start({ '📦 Enhanced Nodes.Animate': 6 }, { nodes: [{ pos: [100, 100] }, { pos: [500, 100] }], links: [{ from: 0, to: 1 }] });
|
||
const [, target] = canvas.graph._nodes;
|
||
frames(2);
|
||
/** Where the background pass begins and ends the link's path. */
|
||
const ends = () => {
|
||
canvas.setDirty(true, true);
|
||
const from = canvas.back.lines.length;
|
||
frames(1);
|
||
return canvas.back.lines.slice(from).filter((line) => /^(moveTo|bezierCurveTo)\(/.test(line)).join(' ');
|
||
};
|
||
const rest = ends();
|
||
target.selected = true;
|
||
frames(1);
|
||
let wobbled = false;
|
||
for (let i = 0; i < 10; i++) wobbled ||= ends() !== rest;
|
||
expect(wobbled).toBe(true);
|
||
settle(canvas);
|
||
expect(ends()).toBe(rest);
|
||
});
|
||
});
|
||
|
||
describe('Circuit Trace', () => {
|
||
const CIRCUIT = 7;
|
||
/** The node clock `t` s in at speed 1. */
|
||
const at = (t: number) => ({ nodePhase: ((2 * Math.PI) / 15) * t, particleTime: t, nodeTime: t });
|
||
/** What Circuit Trace draws whole on a 220 × 120 node with id `id`, `t` s in. */
|
||
async function drawn(t: number, settings: Record<string, unknown> = {}, id = 1) {
|
||
await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': CIRCUIT, ...settings }, { nodes: [KSAMPLER] });
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, CIRCUIT, { id, title: 'KSampler', size: [220, 120] }, 220, 120, at(t), WHOLE, 1, false);
|
||
return rec;
|
||
}
|
||
/** Its pads, where they are: the rings of 2.2 px it strokes at Animation.Size 1. */
|
||
const pads = (rec: CanvasRecorder) => rec.lines.flatMap((line) => {
|
||
const m = /^arc\(([^,]+), ([^,]+), 2\.2, /.exec(line);
|
||
return m ? [`${m[1]}, ${m[2]}`] : [];
|
||
});
|
||
/** How brightly each pad is lit: the fills in its accent, by where they are. */
|
||
const lit = (rec: CanvasRecorder) => new Map(rec.paints.filter((p) => p.op === 'fill' && p.style === '#ffd54f').map((p) => [`${fmt((p.box![0] + p.box![2]) / 2)}, ${fmt((p.box![1] + p.box![3]) / 2)}`, p.globalAlpha]));
|
||
|
||
it('lays a trace in from a pin for each 100 px of its sides and foot at Balanced, more at Rich, fewer at Fast, the same for a node every frame and its own for each node', async () => {
|
||
const balanced = pads(await drawn(1));
|
||
// 1.6 a 100 px of 2 × 120 + 220.
|
||
expect(balanced).toHaveLength(7);
|
||
expect(pads(await drawn(1, { '📦 Enhanced Nodes.Quality': 3 }))).toHaveLength(11);
|
||
expect(pads(await drawn(1, { '📦 Enhanced Nodes.Quality': 1 }))).toHaveLength(5);
|
||
expect(pads(await drawn(2.3))).toEqual(balanced);
|
||
expect(pads(await drawn(1, {}, 2))).not.toEqual(balanced);
|
||
// Each pad inside the body, clear of its edges.
|
||
for (const pad of balanced) {
|
||
const [x, y] = pad.split(', ').map(Number);
|
||
expect(x >= 6 && x <= 214 && y >= 6 && y <= 114, pad).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('lights each pad once a pulse, as the pulse gets to it, every 2.4 s at speed 1', async () => {
|
||
const seen = new Map<string, number[]>();
|
||
const frames = 72;
|
||
for (let i = 0; i < frames; i++) {
|
||
const rec = await drawn((2.4 * i) / frames);
|
||
for (const pad of pads(rec)) if (!seen.has(pad)) seen.set(pad, []);
|
||
const now = lit(rec);
|
||
for (const [pad, alphas] of seen) alphas.push(now.get(pad) ?? 0);
|
||
}
|
||
expect(seen.size).toBe(7);
|
||
for (const [pad, alphas] of seen) {
|
||
// Once round, the glow up to full and down to nothing.
|
||
expect(Math.max(...alphas), pad).toBeGreaterThan(0.85);
|
||
const rises = alphas.filter((a, i) => a > 0 && !(alphas[(i + frames - 1) % frames] > 0)).length;
|
||
expect(rises, pad).toBe(1);
|
||
}
|
||
});
|
||
|
||
it('holds still in one pose, whatever the clock', async () => {
|
||
const still = async (t: number) => (await drawn(t, { '📦 Enhanced Nodes.Static.Mode': true, [PARTICLE_SHOW]: true })).trace();
|
||
expect(await still(9.7)).toBe(await still(0.3));
|
||
});
|
||
|
||
it('stands its pins out of the edges, which the under part draws', async () => {
|
||
const canvas = await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': CIRCUIT }, { nodes: [KSAMPLER] });
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, CIRCUIT, canvas.graph._nodes[0], 220, 120, at(1), { sides: UNDER, x0: 0, y0: -30, x1: 220, y1: 120, round: 8 }, 1, false);
|
||
const pins = rec.lines.filter((line) => /^lineTo\((-5|225), /.test(line) || /^lineTo\([^,]+, 125\)$/.test(line));
|
||
expect(pins).toHaveLength(7);
|
||
});
|
||
});
|
||
|
||
describe('Aurora Veil', () => {
|
||
const AURORA = 8;
|
||
/** The node clock `t` s in at speed 1. */
|
||
const at = (t: number) => ({ nodePhase: ((2 * Math.PI) / 15) * t, particleTime: t, nodeTime: t });
|
||
/** What Aurora Veil draws on a 220 × 120 node, `t` s in: whole, or one side of its edge. */
|
||
async function drawn(t: number, settings: Record<string, unknown> = {}, part: Part = WHOLE) {
|
||
await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': AURORA, ...settings }, { nodes: [KSAMPLER] });
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, AURORA, { id: 1, title: 'KSampler', size: [220, 120] }, 220, 120, at(t), part, 1, false);
|
||
return rec;
|
||
}
|
||
/** Its curtains' washes and rays: where each hangs across the body, and how far down, × the 0.8 of the body the longest reaches. */
|
||
const rays = (rec: CanvasRecorder) => {
|
||
const out: { x: number; long: number }[] = [];
|
||
for (let i = 0; i < rec.lines.length; i++) {
|
||
const m = /^translate\(([^,]+), 0\)$/.exec(rec.lines[i]);
|
||
const n = /^scale\(1, ([^)]+)\)$/.exec(rec.lines[i + 1] ?? '');
|
||
if (m && n) out.push({ x: Number(m[1]), long: Number(n[1]) });
|
||
}
|
||
return out;
|
||
};
|
||
|
||
it('hangs four curtains of eight rays over a wash from the title bar at Balanced, three of six at Fast and five of ten at Rich, across the body', async () => {
|
||
const balanced = rays(await drawn(1));
|
||
expect(balanced).toHaveLength(4 + 32);
|
||
expect(rays(await drawn(1, { '📦 Enhanced Nodes.Quality': 1 }))).toHaveLength(3 + 18);
|
||
expect(rays(await drawn(1, { '📦 Enhanced Nodes.Quality': 3 }))).toHaveLength(5 + 50);
|
||
for (const ray of balanced) {
|
||
expect(ray.x).toBeGreaterThan(0);
|
||
expect(ray.x).toBeLessThan(220);
|
||
expect(ray.long).toBeGreaterThanOrEqual(0.55);
|
||
expect(ray.long).toBeLessThanOrEqual(1);
|
||
}
|
||
// From the top of the body, under the title bar.
|
||
expect((await drawn(1)).lines.filter((line) => line.startsWith('fillRect(')).every((line) => /^fillRect\([^,]+, 0, /.test(line))).toBe(true);
|
||
});
|
||
|
||
it('waves its hems and drifts with the clock, and holds still in one pose', async () => {
|
||
expect(rays(await drawn(2))).not.toEqual(rays(await drawn(1)));
|
||
const still = async (t: number) => (await drawn(t, { '📦 Enhanced Nodes.Static.Mode': true, [PARTICLE_SHOW]: true })).trace();
|
||
expect(await still(9.7)).toBe(await still(0.3));
|
||
});
|
||
|
||
it('draws nothing round the body', async () => {
|
||
const under = await drawn(1, { [PARTICLE_SHOW]: true, '📦 Enhanced Nodes.Selection.Glow': false }, { sides: UNDER, x0: 0, y0: -30, x1: 220, y1: 120, round: 8 });
|
||
expect(under.paints).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('Ember Crown', () => {
|
||
const EMBER = 9;
|
||
/** The node clock `t` s in at speed 1. */
|
||
const at = (t: number) => ({ nodePhase: ((2 * Math.PI) / 15) * t, particleTime: t, nodeTime: t });
|
||
/** What Ember Crown draws on a 220 × 120 node, `t` s in: whole, or one side of its edge. */
|
||
async function drawn(t: number, settings: Record<string, unknown> = {}, part: Part = WHOLE) {
|
||
await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': EMBER, ...settings }, { nodes: [KSAMPLER] });
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, EMBER, { id: 1, title: 'KSampler', size: [220, 120] }, 220, 120, at(t), part, 1, false);
|
||
return rec;
|
||
}
|
||
/** Its flames: each one's root, from moveTo(), and its tip, where its first curve ends. */
|
||
const flames = (rec: CanvasRecorder) => {
|
||
const out: { root: number; tipX: number; tipY: number }[] = [];
|
||
for (let i = 0; i < rec.lines.length; i++) {
|
||
const m = /^moveTo\(([^,]+), -30\)$/.exec(rec.lines[i]);
|
||
const c = /^bezierCurveTo\(.*, ([^,]+), ([^,)]+)\)$/.exec(rec.lines[i + 1] ?? '');
|
||
if (m && c) out.push({ root: Number(m[1]), tipX: Number(c[1]), tipY: Number(c[2]) });
|
||
}
|
||
return out;
|
||
};
|
||
const SHORT = { [PARTICLE_SHOW]: false };
|
||
|
||
it('stands a crown of flames on the top of the title bar, about one each 18 px at Balanced, closer at Rich, rising no more than 27.5 px', async () => {
|
||
// 220 / 18 × (0.6 + 0.2 × Quality).
|
||
expect(flames(await drawn(1, SHORT))).toHaveLength(12);
|
||
expect(flames(await drawn(1, { ...SHORT, '📦 Enhanced Nodes.Quality': 3 }))).toHaveLength(15);
|
||
expect(flames(await drawn(1, { ...SHORT, '📦 Enhanced Nodes.Quality': 1 }))).toHaveLength(10);
|
||
for (const f of flames(await drawn(1, SHORT))) {
|
||
expect(f.tipY).toBeLessThan(-30);
|
||
expect(f.tipY).toBeGreaterThanOrEqual(-30 - 27.5);
|
||
}
|
||
});
|
||
|
||
it('flickers and sways with the clock, holds still in one pose, and draws its flames round the body and its warmth on it', async () => {
|
||
expect(flames(await drawn(1.3, SHORT))).not.toEqual(flames(await drawn(1, SHORT)));
|
||
const still = async (t: number) => (await drawn(t, { '📦 Enhanced Nodes.Static.Mode': true, [PARTICLE_SHOW]: true })).trace();
|
||
expect(await still(9.7)).toBe(await still(0.3));
|
||
const side = (sides: number) => ({ sides, x0: 0, y0: -30, x1: 220, y1: 120, round: 8 });
|
||
const [under, over] = await Promise.all([drawn(1, SHORT, side(UNDER)), drawn(1, SHORT, side(OVER))]);
|
||
expect(flames(under)).toHaveLength(12);
|
||
expect(under.lines.some((line) => line.startsWith('fillRect('))).toBe(false);
|
||
expect(over.lines.filter((line) => line.startsWith('fillRect('))).toEqual(['fillRect(0, 0, 220, 48)']);
|
||
});
|
||
|
||
it('sends embers up from the flames, rising up to 45 px over the title bar and fading out as they go', async () => {
|
||
const embers = (rec: CanvasRecorder) => rec.paints.filter((p) => p.op === 'fill' && !p.style.startsWith('grad'));
|
||
const rec = await drawn(1, { [PARTICLE_SHOW]: true });
|
||
expect(embers(rec).length).toBeGreaterThan(0);
|
||
for (const e of embers(rec)) {
|
||
expect(e.box![3]).toBeLessThan(-30);
|
||
expect(e.box![1]).toBeGreaterThan(-30 - 4 - 45 - 2.2);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('Ember Crown on a node without a title bar', () => {
|
||
it('stands its flames on the top of the body', async () => {
|
||
await start({ ...LINKS_OFF, '📦 Enhanced Nodes.Animate': 9 }, { nodes: [KSAMPLER] });
|
||
const rec = new CanvasRecorder();
|
||
drawNodeFx(rec.ctx, 9, { id: 1, title: '', size: [75, 26], title_mode: 1 }, 75, 26, { nodePhase: 1, particleTime: 1, nodeTime: 1 }, WHOLE, 1, false);
|
||
const roots = rec.lines.filter((line) => line.startsWith('moveTo(')).map((line) => Number(line.slice(0, -1).split(', ')[1]));
|
||
expect(roots.length).toBeGreaterThan(0);
|
||
expect(roots.every((y) => y === 0)).toBe(true);
|
||
});
|
||
});
|