Files
ausboss-ComfyUI-AusBoss/tests/callout.test.mjs
ausboss d0658ea8b2 Workflow Switches and Callout: follow the copies after a paste
Copy and paste give every pasted node and group a new id, and so do
duplicate, Alt + drag and dragging a saved workflow onto the canvas. A
Workflow Switches card remembers its parts by id, so a whole workflow
pasted into a new tab read "empty" on every row and nothing could be
switched on; pasted into another workflow it could switch unrelated nodes
that had the old numbers. A Callout's links to a node or group broke the
same way.

The frontend's paste (LGraphCanvas._deserializeItems) returns which new
node each old id became. js/shared/paste_ids.mjs hands that on after every
paste, and a pasted card or note points at the copies of what it names.
An id the paste did not copy is kept, so a card pasted on its own still
switches the original parts.
2026-10-29 11:05:44 -05:00

294 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
ARROW_ANGLE, ARROW_CHOICES, SMALL_MARK, arrowText, fitFontSize, linkTarget, parseBlocks, pastedLink, repointLinks, targetId,
toggleSmall, urlLabel, wrapLink,
} from "../js/shared/callout.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
test("a blank line starts a new point and ## is ignored", () => {
const blocks = parseBlocks("## One\nsecond line\n\n\n## Two");
assert.equal(blocks.length, 2);
assert.equal(blocks[0].length, 2);
assert.equal(blocks[0][0][0].text, "One");
assert.equal(blocks[1][0][0].text, "Two");
});
test("**bold** marks a word and an unmatched ** stays text", () => {
const [[line]] = parseBlocks("a **big** idea");
assert.deepEqual(line.map((p) => [p.text, p.bold]), [["a ", false], ["big", true], [" idea", false]]);
const [[open]] = parseBlocks("two ** open");
assert.deepEqual(open, [{ text: "two ** open", bold: false }]);
assert.deepEqual(parseBlocks(" \n\n"), []);
assert.deepEqual(parseBlocks(undefined), []);
});
test("an arrow emoji becomes an arrow inside the line", () => {
const [[line]] = parseBlocks("Drag ⬆️ the handles");
assert.deepEqual(line, [
{ text: "Drag ", bold: false },
{ arrow: "up", bold: false },
{ text: " the handles", bold: false },
]);
});
test("every arrow spelling is read, with or without the emoji selector", () => {
const kinds = (text) => parseBlocks(text)[0][0].filter((p) => p.arrow).map((p) => p.arrow);
assert.deepEqual(kinds("⬆⬇⬅➡"), ["up", "down", "left", "right"]);
assert.deepEqual(kinds("⬆️⬇️⬅️➡️"), ["up", "down", "left", "right"]);
assert.deepEqual(kinds("↖↗↘↙"), ["upleft", "upright", "downright", "downleft"]);
assert.deepEqual(kinds("← ↑ → ↓"), ["left", "up", "right", "down"]);
assert.deepEqual(kinds("↔️ ↕️"), ["leftright", "updown"]);
assert.deepEqual(kinds("\u{1F446}\u{1F447}\u{1F448}\u{1F449}"), ["up", "down", "left", "right"]);
assert.deepEqual(kinds("a -> b <- c"), ["right", "left"]);
});
test("an arrow inside **bold** stays inside the bold", () => {
const [[line]] = parseBlocks("**here ➡️** now");
assert.deepEqual(line.map((p) => [p.text ?? p.arrow, p.bold]), [["here ", true], ["right", true], [" now", false]]);
});
test("the editor offers all eight directions and each one can be drawn", () => {
assert.equal(ARROW_CHOICES.length, 8);
for (const [kind, emoji] of ARROW_CHOICES) {
assert.ok(kind in ARROW_ANGLE, `${kind} has an angle`);
assert.equal(parseBlocks(emoji)[0][0][0].arrow, kind, `${kind} reads back as itself`);
}
});
test("the text grows to the largest size that fits", () => {
assert.equal(fitFontSize((px) => px <= 19), 19);
assert.equal(fitFontSize(() => true), 30);
assert.equal(fitFontSize(() => false), 12);
});
test("an arrow followed by #id is linked to that node", () => {
const [[line]] = parseBlocks("See \u27A1\uFE0F#12 and \u2B07\uFE0F#5:7. Then \u2B06\uFE0F #3 and -> done");
const arrows = line.filter((p) => p.arrow);
assert.deepEqual(arrows.map((p) => [p.arrow, p.link]), [["right", "12"], ["down", "5:7"], ["up", undefined], ["right", undefined]]);
// The link is not shown as text, and the full stop after a link stays text.
assert.ok(line.some((p) => p.text === ". Then "));
assert.ok(line.some((p) => p.text === " #3 and "));
});
test("arrowText writes the link the parser reads", () => {
assert.equal(arrowText("\u27A1\uFE0F", "9"), "\u27A1\uFE0F#9");
assert.equal(arrowText("\u27A1\uFE0F", 9), "\u27A1\uFE0F#9");
assert.equal(arrowText("\u27A1\uFE0F"), "\u27A1\uFE0F");
assert.equal(arrowText("\u27A1\uFE0F", ""), "\u27A1\uFE0F");
assert.deepEqual(parseBlocks(arrowText("\u2197\uFE0F", "4"))[0][0], [{ arrow: "upright", link: "4", bold: false }]);
});
test("a line that starts with six hashes is small print under the line above", () => {
const [point] = parseBlocks("**Auto prompt** writes the prompt.\n###### Off, your words go in as typed.\n###### It adds two seconds.");
assert.equal(point.length, 3);
assert.equal(point[0].small, undefined);
assert.equal(point[1].small, true);
assert.equal(point[2].small, true);
// The mark is not part of the text, and bold and arrows work inside it.
assert.deepEqual([...point[1]], [{ text: "Off, your words go in as typed.", bold: false }]);
const [[, linked]] = parseBlocks("Top\n###### see ➡️#20 for the **text**");
assert.equal(linked.small, true);
assert.deepEqual([...linked], [
{ text: "see ", bold: false },
{ arrow: "right", bold: false, link: "20" },
{ text: " for the ", bold: false },
{ text: "text", bold: true },
]);
// Small print can open a point, and a blank line still ends one.
const blocks = parseBlocks("###### a footnote\n\nNext point");
assert.equal(blocks.length, 2);
assert.equal(blocks[0][0].small, true);
assert.equal(blocks[1][0].small, undefined);
});
test("only six hashes and a space mean small print", () => {
const small = (text) => parseBlocks(text).flat().map((line) => Boolean(line.small));
assert.deepEqual(small("# a\n## b\n### c\n#### d\n##### e"), [false, false, false, false, false]);
assert.deepEqual(small("####### seven"), [false]);
assert.deepEqual(small("######none"), [false]);
assert.deepEqual(small("- a dash\n– a long dash\n-# a chat mark"), [false, false, false]);
assert.deepEqual(small(" ###### indented"), [true]);
// The mark with nothing after it is a blank line, as it always was.
assert.deepEqual(parseBlocks("one\n###### \ntwo").length, 2);
});
test("a note written before small print reads exactly as the release before read it", () => {
// The fixture holds what release 2.10.1 made of each text: dash lists,
// every heading mark but the six-hash one, and the pack's example notes.
const notes = JSON.parse(readFileSync(join(ROOT, "tests", "fixtures", "callout_released_notes.json"), "utf-8"));
assert.ok(notes.length >= 12);
for (const { text, blocks } of notes) {
const now = parseBlocks(text);
assert.deepEqual(JSON.parse(JSON.stringify(now)), blocks, text);
assert.ok(now.flat().every((line) => !line.small), text);
}
});
test("a release before small print shows the same words, all at one size", () => {
// What those releases did with a line: drop one to six leading hashes.
const released = (line) => line.replace(/^\s*#{1,6}\s+/, "").trim();
const text = `Top line\n${SMALL_MARK}the details`;
const now = parseBlocks(text)[0].map((line) => line.map((piece) => piece.text).join(""));
assert.deepEqual(text.split("\n").map(released), now);
});
test("the Small button marks the caret's line and takes the mark off again", () => {
const apply = (text, start, end = start) => {
const next = toggleSmall(text, start, end);
return { text: text.slice(0, next.from) + next.insert + text.slice(next.to), start: next.selectionStart, end: next.selectionEnd };
};
const text = "Top line\nthe details\n\nNext";
// Caret in "the de|tails": the line gets the mark and the caret stays on its letter.
const on = apply(text, 15);
assert.equal(on.text, "Top line\n###### the details\n\nNext");
assert.equal(on.text.slice(on.start, on.start + 5), "tails");
assert.equal(on.start, on.end);
const off = apply(on.text, on.start);
assert.equal(off.text, text);
assert.equal(off.start, 15);
// A selection over several lines: every written line, and the blank one is left alone.
const many = apply(text, 2, text.length);
assert.equal(many.text, "###### Top line\n###### the details\n\n###### Next");
assert.deepEqual([many.start, many.end], [0, many.text.length]);
assert.equal(apply(many.text, 0, many.text.length).text, text);
// Lines that are only partly small all become small; another heading mark is replaced.
assert.equal(apply("###### a\n## b", 0, 14).text, "###### a\n###### b");
// The first and the last line, a caret at either end, and an empty box.
assert.equal(apply("one\ntwo", 0).text, "###### one\ntwo");
assert.equal(apply("one\ntwo", 7).text, "one\n###### two");
assert.deepEqual(apply("", 0), { text: "", start: 0, end: 0 });
// A caret before the mark never lands in front of the line.
assert.equal(apply("###### x", 2).start, 0);
});
test("words in square brackets with an address after them are a link to that page", () => {
const [[line]] = parseBlocks("See [the video](https://youtu.be/abc) first");
assert.deepEqual(line, [
{ text: "See ", bold: false },
{ text: "the video", bold: false, url: "https://youtu.be/abc", run: 1 },
{ text: " first", bold: false },
]);
// The whole note can be one link, with bold and an arrow inside its words.
const [[whole]] = parseBlocks("[➡️ **Watch** the video](https://www.youtube.com/@name)");
assert.equal(whole.length, 4);
assert.ok(whole.every((piece) => piece.url === "https://www.youtube.com/@name" && piece.run === 1));
assert.deepEqual(whole.map((piece) => piece.arrow ?? piece.text), ["right", " ", "Watch", " the video"]);
assert.equal(whole[2].bold, true);
// Stars around a link are the same as stars inside it, and an address may hold round brackets.
const [[bold]] = parseBlocks("**[more](https://en.wikipedia.org/wiki/Mask_(computing))**");
assert.deepEqual(bold, [{ text: "more", bold: true, url: "https://en.wikipedia.org/wiki/Mask_(computing)", run: 1 }]);
// Two links on a line are two runs.
const [[two]] = parseBlocks("[a](https://a.example) and [b](http://b.example)");
assert.deepEqual(two.filter((piece) => piece.run).map((piece) => [piece.text, piece.run]), [["a", 1], ["b", 2]]);
});
test("a link can also bring a node or a group into view, and an arrow can point at a group", () => {
const [[line]] = parseBlocks("Set [the seed](#5) or open [the models](#g3) ⬇️#g3 ⬇️#12");
assert.deepEqual(line.filter((piece) => piece.run || piece.arrow), [
{ text: "the seed", bold: false, link: "5", run: 1 },
{ text: "the models", bold: false, group: "3", run: 2 },
{ arrow: "down", bold: false, group: "3" },
{ arrow: "down", bold: false, link: "12" },
]);
// An arrow inside linked words belongs to that link; one with its own link keeps it.
const [[mixed]] = parseBlocks("[go ➡️ there ⬇️#7](#g2)");
assert.deepEqual(mixed.map((piece) => [piece.arrow ?? piece.text, piece.group ?? null, piece.link ?? null]),
[["go ", "2", null], ["right", "2", null], [" there ", "2", null], ["down", null, "7"]]);
});
test("only http and https addresses, nodes and groups are links: anything else stays as typed", () => {
assert.deepEqual(linkTarget("https://example.com/a?b=1#c"), { url: "https://example.com/a?b=1#c" });
assert.deepEqual(linkTarget("HTTP://EXAMPLE.COM"), { url: "HTTP://EXAMPLE.COM" });
assert.deepEqual(linkTarget("#12"), { link: "12" });
assert.deepEqual(linkTarget("#12:34"), { link: "12:34" });
assert.deepEqual(linkTarget("#g3"), { group: "3" });
for (const bad of ["javascript:alert(1)", "data:text/html,x", "file:///etc/passwd", "ftp://a.b", "//a.b", "example.com", "#", "#a b", "", "https://", `https://a.b/${"x".repeat(2100)}`, 'https://a.b/"x', "https://a.b/<x>"]) {
assert.equal(linkTarget(bad), null, bad.slice(0, 40));
}
for (const text of ["[x](javascript:alert(1))", "[x](notalink)", "[x] (https://a.b)", "[](https://a.b)", "[x]()", "a [b] c (d)"]) {
const [[line]] = parseBlocks(text);
assert.deepEqual(line, [{ text, bold: false }], text);
}
});
test("a note without a link reads as it did, and small print can hold a link", () => {
const [[plain]] = parseBlocks("Drag ⬆️ the **handles** (both of them) [sic]");
assert.ok(plain.every((piece) => piece.run === undefined && piece.url === undefined && piece.group === undefined));
const [[, small]] = parseBlocks("Top\n###### see [the docs](https://a.example)");
assert.equal(small.small, true);
assert.equal(small[1].url, "https://a.example");
});
test("the tooltip names where a link goes", () => {
assert.equal(urlLabel("https://www.youtube.com/@ausbossdev"), "youtube.com/@ausbossdev");
assert.equal(urlLabel("https://example.com/"), "example.com");
assert.equal(urlLabel("https://example.com/a/b/?q=1"), "example.com/a/b");
assert.ok(urlLabel(`https://example.com/${"long/".repeat(30)}`).length <= 60);
});
test("the Link button wraps the selected words, or the word under the caret", () => {
const apply = (text, start, end, target) => {
const next = wrapLink(text, start, end, target);
const out = text.slice(0, next.from) + next.insert + text.slice(next.to);
return { text: out, selected: out.slice(next.selectionStart, next.selectionEnd) };
};
// No node picked: the address comes back selected, ready to be pasted over.
assert.deepEqual(apply("watch the video now", 6, 15), { text: "watch [the video](https://) now", selected: "https://" });
// A caret inside a word takes that word.
assert.deepEqual(apply("watch the video", 8, 8), { text: "watch [the](https://) video", selected: "https://" });
// A picked node or group is the target, and the caret lands after the link.
assert.deepEqual(apply("see the seed", 8, 12, "#5"), { text: "see the [seed](#5)", selected: "" });
assert.deepEqual(apply("the models", 4, 10, `#${targetId({ id: 3 }, true)}`), { text: "the [models](#g3)", selected: "" });
// Nothing to wrap: the word "link" stands in.
assert.deepEqual(apply("", 0, 0), { text: "[link](https://)", selected: "https://" });
assert.deepEqual(apply("a b", 2, 2), { text: "a [link](https://) b", selected: "https://" });
// A selection over two lines or over brackets is not wrapped: a new link is put at its start.
assert.equal(apply("one\ntwo", 0, 7).text, "[link](https://)one\ntwo");
assert.equal(targetId({ id: 12 }), "12");
assert.equal(targetId(null), "");
assert.equal(arrowText("⬇️", targetId({ id: 3 }, true)), "⬇️#g3");
});
test("an address pasted over selected words makes them a link", () => {
assert.equal(pastedLink("the video", "https://youtu.be/abc"), "[the video](https://youtu.be/abc)");
assert.equal(pastedLink("the video", " https://youtu.be/abc\n"), "[the video](https://youtu.be/abc)");
// Not a link: nothing selected, two lines, brackets, something that is no address, an address over an address.
assert.equal(pastedLink("", "https://a.example"), null);
assert.equal(pastedLink("a\nb", "https://a.example"), null);
assert.equal(pastedLink("[a]", "https://a.example"), null);
assert.equal(pastedLink("the video", "just words"), null);
assert.equal(pastedLink("https://old.example", "https://new.example"), null);
});
test("a link is a real link: a new tab, no way back to this page, and a press on it never drags the note", () => {
const source = readFileSync(join(ROOT, "js", "callout", "index.js"), "utf-8");
const run = source.slice(source.indexOf("function linkRun"), source.indexOf("// Bring a node or a group into view"));
assert.match(run, /link\.target = "_blank"/);
assert.match(run, /link\.rel = "noopener noreferrer"/);
assert.match(run, /pointerdown[^\n]*stopPropagation/);
// The address is only ever one the parser called http or https.
assert.match(run, /link\.href = first\.url/);
assert.equal(source.includes("window.open"), false);
});
test("a note pasted with what its links name follows the copies", () => {
const nodes = new Map([["5", 151], ["12", 152]]);
const groups = new Map([["3", 9]]);
const text = "⬇️#12 Type here\n\n[the Seed box](#5), [the models](#g3), [elsewhere](#40) and ->#40\n[a page](https://example.com/#5)";
assert.equal(
repointLinks(text, nodes, groups),
"⬇️#152 Type here\n\n[the Seed box](#151), [the models](#g9), [elsewhere](#40) and ->#40\n[a page](https://example.com/#5)",
);
// An arrow inside a link's words keeps its own node.
assert.equal(repointLinks("[→#12 here](#5)", nodes, groups), "[→#152 here](#151)");
// Nothing it names was copied, or no links at all: no change.
assert.equal(repointLinks("[elsewhere](#40) ⬆️ plain", nodes, groups), null);
assert.equal(repointLinks(undefined, nodes, groups), null);
});