// @ts-check
/// <reference path="../node_modules/@types/jest/index.d.ts" />

const { start, createDefaultWorkflow, getNodeDef, checkBeforeAndAfterReload } = require("../utils");
const lg = require("../utils/litegraph");

describe("group node", () => {
	beforeEach(() => {
		lg.setup(global);
	});

	afterEach(() => {
		lg.teardown(global);
	});

	/**
	 *
	 * @param {*} app
	 * @param {*} graph
	 * @param {*} name
	 * @param {*} nodes
	 * @returns { Promise<InstanceType<import("../utils/ezgraph")["EzNode"]>> }
	 */
	async function convertToGroup(app, graph, name, nodes) {
		// Select the nodes we are converting
		for (const n of nodes) {
			n.select(true);
		}

		expect(Object.keys(app.canvas.selected_nodes).sort((a, b) => +a - +b)).toEqual(
			nodes.map((n) => n.id + "").sort((a, b) => +a - +b)
		);

		global.prompt = jest.fn().mockImplementation(() => name);
		const groupNode = await nodes[0].menu["Convert to Group Node"].call(false);

		// Check group name was requested
		expect(window.prompt).toHaveBeenCalled();

		// Ensure old nodes are removed
		for (const n of nodes) {
			expect(n.isRemoved).toBeTruthy();
		}

		expect(groupNode.type).toEqual("workflow/" + name);

		return graph.find(groupNode);
	}

	/**
	 * @param { Record<string, string | number> | number[] } idMap
	 * @param { Record<string, Record<string, unknown>> } valueMap
	 */
	function getOutput(idMap = {}, valueMap = {}) {
		if (idMap instanceof Array) {
			idMap = idMap.reduce((p, n) => {
				p[n] = n + "";
				return p;
			}, {});
		}
		const expected = {
			1: { inputs: { ckpt_name: "model1.safetensors", ...valueMap?.[1] }, class_type: "CheckpointLoaderSimple" },
			2: { inputs: { text: "positive", clip: ["1", 1], ...valueMap?.[2] }, class_type: "CLIPTextEncode" },
			3: { inputs: { text: "negative", clip: ["1", 1], ...valueMap?.[3] }, class_type: "CLIPTextEncode" },
			4: { inputs: { width: 512, height: 512, batch_size: 1, ...valueMap?.[4] }, class_type: "EmptyLatentImage" },
			5: {
				inputs: {
					seed: 0,
					steps: 20,
					cfg: 8,
					sampler_name: "euler",
					scheduler: "normal",
					denoise: 1,
					model: ["1", 0],
					positive: ["2", 0],
					negative: ["3", 0],
					latent_image: ["4", 0],
					...valueMap?.[5],
				},
				class_type: "KSampler",
			},
			6: { inputs: { samples: ["5", 0], vae: ["1", 2], ...valueMap?.[6] }, class_type: "VAEDecode" },
			7: { inputs: { filename_prefix: "ComfyUI", images: ["6", 0], ...valueMap?.[7] }, class_type: "SaveImage" },
		};

		// Map old IDs to new at the top level
		const mapped = {};
		for (const oldId in idMap) {
			mapped[idMap[oldId]] = expected[oldId];
			delete expected[oldId];
		}
		Object.assign(mapped, expected);

		// Map old IDs to new inside links
		for (const k in mapped) {
			for (const input in mapped[k].inputs) {
				const v = mapped[k].inputs[input];
				if (v instanceof Array) {
					if (v[0] in idMap) {
						v[0] = idMap[v[0]] + "";
					}
				}
			}
		}

		return mapped;
	}

	test("can be created from selected nodes", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty]);

		// Ensure links are now to the group node
		expect(group.inputs).toHaveLength(2);
		expect(group.outputs).toHaveLength(3);

		expect(group.inputs.map((i) => i.input.name)).toEqual(["clip", "CLIPTextEncode clip"]);
		expect(group.outputs.map((i) => i.output.name)).toEqual(["LATENT", "CONDITIONING", "CLIPTextEncode CONDITIONING"]);

		// ckpt clip to both clip inputs on the group
		expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
			[group.id, 0],
			[group.id, 1],
		]);

		// group conditioning to sampler
		expect(group.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
			[nodes.sampler.id, 1],
		]);
		// group conditioning 2 to sampler
		expect(
			group.outputs["CLIPTextEncode CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])
		).toEqual([[nodes.sampler.id, 2]]);
		// group latent to sampler
		expect(group.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
			[nodes.sampler.id, 3],
		]);
	});

	test("maintains all output links on conversion", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		const save2 = ez.SaveImage(...nodes.decode.outputs);
		const save3 = ez.SaveImage(...nodes.decode.outputs);
		// Ensure an output with multiple links maintains them on convert to group
		const group = await convertToGroup(app, graph, "test", [nodes.sampler, nodes.decode]);
		expect(group.outputs[0].connections.length).toBe(3);
		expect(group.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id);
		expect(group.outputs[0].connections[1].targetNode.id).toBe(save2.id);
		expect(group.outputs[0].connections[2].targetNode.id).toBe(save3.id);

		// and they're still linked when converting back to nodes
		const newNodes = group.menu["Convert to nodes"].call();
		const decode = graph.find(newNodes.find((n) => n.type === "VAEDecode"));
		expect(decode.outputs[0].connections.length).toBe(3);
		expect(decode.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id);
		expect(decode.outputs[0].connections[1].targetNode.id).toBe(save2.id);
		expect(decode.outputs[0].connections[2].targetNode.id).toBe(save3.id);
	});
	test("can be be converted back to nodes", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		const toConvert = [nodes.pos, nodes.neg, nodes.empty, nodes.sampler];
		const group = await convertToGroup(app, graph, "test", toConvert);

		// Edit some values to ensure they are set back onto the converted nodes
		expect(group.widgets["text"].value).toBe("positive");
		group.widgets["text"].value = "pos";
		expect(group.widgets["CLIPTextEncode text"].value).toBe("negative");
		group.widgets["CLIPTextEncode text"].value = "neg";
		expect(group.widgets["width"].value).toBe(512);
		group.widgets["width"].value = 1024;
		expect(group.widgets["sampler_name"].value).toBe("euler");
		group.widgets["sampler_name"].value = "ddim";
		expect(group.widgets["control_after_generate"].value).toBe("randomize");
		group.widgets["control_after_generate"].value = "fixed";

		/** @type { Array<any> } */
		group.menu["Convert to nodes"].call();

		// ensure widget values are set
		const pos = graph.find(nodes.pos.id);
		expect(pos.node.type).toBe("CLIPTextEncode");
		expect(pos.widgets["text"].value).toBe("pos");
		const neg = graph.find(nodes.neg.id);
		expect(neg.node.type).toBe("CLIPTextEncode");
		expect(neg.widgets["text"].value).toBe("neg");
		const empty = graph.find(nodes.empty.id);
		expect(empty.node.type).toBe("EmptyLatentImage");
		expect(empty.widgets["width"].value).toBe(1024);
		const sampler = graph.find(nodes.sampler.id);
		expect(sampler.node.type).toBe("KSampler");
		expect(sampler.widgets["sampler_name"].value).toBe("ddim");
		expect(sampler.widgets["control_after_generate"].value).toBe("fixed");

		// validate links
		expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
			[pos.id, 0],
			[neg.id, 0],
		]);

		expect(pos.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
			[nodes.sampler.id, 1],
		]);

		expect(neg.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
			[nodes.sampler.id, 2],
		]);

		expect(empty.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
			[nodes.sampler.id, 3],
		]);
	});
	test("it can embed reroutes as inputs", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);

		// Add and connect a reroute to the clip text encodes
		const reroute = ez.Reroute();
		nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]);
		reroute.outputs[0].connectTo(nodes.pos.inputs[0]);
		reroute.outputs[0].connectTo(nodes.neg.inputs[0]);

		// Convert to group and ensure we only have 1 input of the correct type
		const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty, reroute]);
		expect(group.inputs).toHaveLength(1);
		expect(group.inputs[0].input.type).toEqual("CLIP");

		expect((await graph.toPrompt()).output).toEqual(getOutput());
	});
	test("it can embed reroutes as outputs", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);

		// Add a reroute with no output so we output IMAGE even though its used internally
		const reroute = ez.Reroute();
		nodes.decode.outputs.IMAGE.connectTo(reroute.inputs[0]);

		// Convert to group and ensure there is an IMAGE output
		const group = await convertToGroup(app, graph, "test", [nodes.decode, nodes.save, reroute]);
		expect(group.outputs).toHaveLength(1);
		expect(group.outputs[0].output.type).toEqual("IMAGE");
		expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.decode.id, nodes.save.id]));
	});
	test("it can embed reroutes as pipes", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);

		// Use reroutes as a pipe
		const rerouteModel = ez.Reroute();
		const rerouteClip = ez.Reroute();
		const rerouteVae = ez.Reroute();
		nodes.ckpt.outputs.MODEL.connectTo(rerouteModel.inputs[0]);
		nodes.ckpt.outputs.CLIP.connectTo(rerouteClip.inputs[0]);
		nodes.ckpt.outputs.VAE.connectTo(rerouteVae.inputs[0]);

		const group = await convertToGroup(app, graph, "test", [rerouteModel, rerouteClip, rerouteVae]);

		expect(group.outputs).toHaveLength(3);
		expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]);

		expect(group.outputs).toHaveLength(3);
		expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]);

		group.outputs[0].connectTo(nodes.sampler.inputs.model);
		group.outputs[1].connectTo(nodes.pos.inputs.clip);
		group.outputs[1].connectTo(nodes.neg.inputs.clip);
	});
	test("can handle reroutes used internally", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);

		let reroutes = [];
		let prevNode = nodes.ckpt;
		for (let i = 0; i < 5; i++) {
			const reroute = ez.Reroute();
			prevNode.outputs[0].connectTo(reroute.inputs[0]);
			prevNode = reroute;
			reroutes.push(reroute);
		}
		prevNode.outputs[0].connectTo(nodes.sampler.inputs.model);

		const group = await convertToGroup(app, graph, "test", [...reroutes, ...Object.values(nodes)]);
		expect((await graph.toPrompt()).output).toEqual(getOutput());

		group.menu["Convert to nodes"].call();
		expect((await graph.toPrompt()).output).toEqual(getOutput());
	});
	test("creates with widget values from inner nodes", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);

		nodes.ckpt.widgets.ckpt_name.value = "model2.ckpt";
		nodes.pos.widgets.text.value = "hello";
		nodes.neg.widgets.text.value = "world";
		nodes.empty.widgets.width.value = 256;
		nodes.empty.widgets.height.value = 1024;
		nodes.sampler.widgets.seed.value = 1;
		nodes.sampler.widgets.control_after_generate.value = "increment";
		nodes.sampler.widgets.steps.value = 8;
		nodes.sampler.widgets.cfg.value = 4.5;
		nodes.sampler.widgets.sampler_name.value = "uni_pc";
		nodes.sampler.widgets.scheduler.value = "karras";
		nodes.sampler.widgets.denoise.value = 0.9;

		const group = await convertToGroup(app, graph, "test", [
			nodes.ckpt,
			nodes.pos,
			nodes.neg,
			nodes.empty,
			nodes.sampler,
		]);

		expect(group.widgets["ckpt_name"].value).toEqual("model2.ckpt");
		expect(group.widgets["text"].value).toEqual("hello");
		expect(group.widgets["CLIPTextEncode text"].value).toEqual("world");
		expect(group.widgets["width"].value).toEqual(256);
		expect(group.widgets["height"].value).toEqual(1024);
		expect(group.widgets["seed"].value).toEqual(1);
		expect(group.widgets["control_after_generate"].value).toEqual("increment");
		expect(group.widgets["steps"].value).toEqual(8);
		expect(group.widgets["cfg"].value).toEqual(4.5);
		expect(group.widgets["sampler_name"].value).toEqual("uni_pc");
		expect(group.widgets["scheduler"].value).toEqual("karras");
		expect(group.widgets["denoise"].value).toEqual(0.9);

		expect((await graph.toPrompt()).output).toEqual(
			getOutput([nodes.ckpt.id, nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id], {
				[nodes.ckpt.id]: { ckpt_name: "model2.ckpt" },
				[nodes.pos.id]: { text: "hello" },
				[nodes.neg.id]: { text: "world" },
				[nodes.empty.id]: { width: 256, height: 1024 },
				[nodes.sampler.id]: {
					seed: 1,
					steps: 8,
					cfg: 4.5,
					sampler_name: "uni_pc",
					scheduler: "karras",
					denoise: 0.9,
				},
			})
		);
	});
	test("group inputs can be reroutes", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);

		const reroute = ez.Reroute();
		nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]);

		reroute.outputs[0].connectTo(group.inputs[0]);
		reroute.outputs[0].connectTo(group.inputs[1]);

		expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id]));
	});
	test("group outputs can be reroutes", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);

		const reroute1 = ez.Reroute();
		const reroute2 = ez.Reroute();
		group.outputs[0].connectTo(reroute1.inputs[0]);
		group.outputs[1].connectTo(reroute2.inputs[0]);

		reroute1.outputs[0].connectTo(nodes.sampler.inputs.positive);
		reroute2.outputs[0].connectTo(nodes.sampler.inputs.negative);

		expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id]));
	});
	test("groups can connect to each other", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		const group1 = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
		const group2 = await convertToGroup(app, graph, "test2", [nodes.empty, nodes.sampler]);

		group1.outputs[0].connectTo(group2.inputs["positive"]);
		group1.outputs[1].connectTo(group2.inputs["negative"]);

		expect((await graph.toPrompt()).output).toEqual(
			getOutput([nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id])
		);
	});
	test("groups can connect to each other via internal reroutes", async () => {
		const { ez, graph, app } = await start();

		const latent = ez.EmptyLatentImage();
		const vae = ez.VAELoader();
		const latentReroute = ez.Reroute();
		const vaeReroute = ez.Reroute();

		latent.outputs[0].connectTo(latentReroute.inputs[0]);
		vae.outputs[0].connectTo(vaeReroute.inputs[0]);

		const group1 = await convertToGroup(app, graph, "test", [latentReroute, vaeReroute]);
		group1.menu.Clone.call();
		expect(app.graph._nodes).toHaveLength(4);
		const group2 = graph.find(app.graph._nodes[3]);
		expect(group2.node.type).toEqual("workflow/test");
		expect(group2.id).not.toEqual(group1.id);

		group1.outputs.VAE.connectTo(group2.inputs.VAE);
		group1.outputs.LATENT.connectTo(group2.inputs.LATENT);

		const decode = ez.VAEDecode(group2.outputs.LATENT, group2.outputs.VAE);
		const preview = ez.PreviewImage(decode.outputs[0]);

		const output = {
			[latent.id]: { inputs: { width: 512, height: 512, batch_size: 1 }, class_type: "EmptyLatentImage" },
			[vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" },
			[decode.id]: { inputs: { samples: [latent.id + "", 0], vae: [vae.id + "", 0] }, class_type: "VAEDecode" },
			[preview.id]: { inputs: { images: [decode.id + "", 0] }, class_type: "PreviewImage" },
		};
		expect((await graph.toPrompt()).output).toEqual(output);

		// Ensure missing connections dont cause errors
		group2.inputs.VAE.disconnect();
		delete output[decode.id].inputs.vae;
		expect((await graph.toPrompt()).output).toEqual(output);
	});
	test("displays generated image on group node", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		let group = await convertToGroup(app, graph, "test", [
			nodes.pos,
			nodes.neg,
			nodes.empty,
			nodes.sampler,
			nodes.decode,
			nodes.save,
		]);

		const { api } = require("../../web/scripts/api");

		api.dispatchEvent(new CustomEvent("execution_start", {}));
		api.dispatchEvent(new CustomEvent("executing", { detail: `${nodes.save.id}` }));
		// Event should be forwarded to group node id
		expect(+app.runningNodeId).toEqual(group.id);
		expect(group.node["imgs"]).toBeFalsy();
		api.dispatchEvent(
			new CustomEvent("executed", {
				detail: {
					node: `${nodes.save.id}`,
					output: {
						images: [
							{
								filename: "test.png",
								type: "output",
							},
						],
					},
				},
			})
		);

		// Trigger paint
		group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas);

		expect(group.node["images"]).toEqual([
			{
				filename: "test.png",
				type: "output",
			},
		]);

		// Reload
		const workflow = JSON.stringify((await graph.toPrompt()).workflow);
		await app.loadGraphData(JSON.parse(workflow));
		group = graph.find(group);

		// Trigger inner nodes to get created
		group.node["getInnerNodes"]();

		// Check it works for internal node ids
		api.dispatchEvent(new CustomEvent("execution_start", {}));
		api.dispatchEvent(new CustomEvent("executing", { detail: `${group.id}:5` }));
		// Event should be forwarded to group node id
		expect(+app.runningNodeId).toEqual(group.id);
		expect(group.node["imgs"]).toBeFalsy();
		api.dispatchEvent(
			new CustomEvent("executed", {
				detail: {
					node: `${group.id}:5`,
					output: {
						images: [
							{
								filename: "test2.png",
								type: "output",
							},
						],
					},
				},
			})
		);

		// Trigger paint
		group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas);

		expect(group.node["images"]).toEqual([
			{
				filename: "test2.png",
				type: "output",
			},
		]);
	});
	test("allows widgets to be converted to inputs", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
		group.widgets[0].convertToInput();

		const primitive = ez.PrimitiveNode();
		primitive.outputs[0].connectTo(group.inputs["text"]);
		primitive.widgets[0].value = "hello";

		expect((await graph.toPrompt()).output).toEqual(
			getOutput([nodes.pos.id, nodes.neg.id], {
				[nodes.pos.id]: { text: "hello" },
			})
		);
	});
	test("can be copied", async () => {
		const { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);

		const group1 = await convertToGroup(app, graph, "test", [
			nodes.pos,
			nodes.neg,
			nodes.empty,
			nodes.sampler,
			nodes.decode,
			nodes.save,
		]);

		group1.widgets["text"].value = "hello";
		group1.widgets["width"].value = 256;
		group1.widgets["seed"].value = 1;

		// Clone the node
		group1.menu.Clone.call();
		expect(app.graph._nodes).toHaveLength(3);
		const group2 = graph.find(app.graph._nodes[2]);
		expect(group2.node.type).toEqual("workflow/test");
		expect(group2.id).not.toEqual(group1.id);

		// Reconnect ckpt
		nodes.ckpt.outputs.MODEL.connectTo(group2.inputs["model"]);
		nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["clip"]);
		nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["CLIPTextEncode clip"]);
		nodes.ckpt.outputs.VAE.connectTo(group2.inputs["vae"]);

		group2.widgets["text"].value = "world";
		group2.widgets["width"].value = 1024;
		group2.widgets["seed"].value = 100;

		let i = 0;
		expect((await graph.toPrompt()).output).toEqual({
			...getOutput([nodes.empty.id, nodes.pos.id, nodes.neg.id, nodes.sampler.id, nodes.decode.id, nodes.save.id], {
				[nodes.empty.id]: { width: 256 },
				[nodes.pos.id]: { text: "hello" },
				[nodes.sampler.id]: { seed: 1 },
			}),
			...getOutput(
				{
					[nodes.empty.id]: `${group2.id}:${i++}`,
					[nodes.pos.id]: `${group2.id}:${i++}`,
					[nodes.neg.id]: `${group2.id}:${i++}`,
					[nodes.sampler.id]: `${group2.id}:${i++}`,
					[nodes.decode.id]: `${group2.id}:${i++}`,
					[nodes.save.id]: `${group2.id}:${i++}`,
				},
				{
					[nodes.empty.id]: { width: 1024 },
					[nodes.pos.id]: { text: "world" },
					[nodes.sampler.id]: { seed: 100 },
				}
			),
		});

		graph.arrange();
	});
	test("is embedded in workflow", async () => {
		let { ez, graph, app } = await start();
		const nodes = createDefaultWorkflow(ez, graph);
		let group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
		const workflow = JSON.stringify((await graph.toPrompt()).workflow);

		// Clear the environment
		({ ez, graph, app } = await start({
			resetEnv: true,
		}));
		// Ensure the node isnt registered
		expect(() => ez["workflow/test"]).toThrow();

		// Reload the workflow
		await app.loadGraphData(JSON.parse(workflow));

		// Ensure the node is found
		group = graph.find(group);

		// Generate prompt and ensure it is as expected
		expect((await graph.toPrompt()).output).toEqual(
			getOutput({
				[nodes.pos.id]: `${group.id}:0`,
				[nodes.neg.id]: `${group.id}:1`,
			})
		);
	});
	test("shows missing node error on missing internal node when loading graph data", async () => {
		const { graph } = await start();

		const dialogShow = jest.spyOn(graph.app.ui.dialog, "show");
		await graph.app.loadGraphData({
			last_node_id: 3,
			last_link_id: 1,
			nodes: [
				{
					id: 3,
					type: "workflow/testerror",
				},
			],
			links: [],
			groups: [],
			config: {},
			extra: {
				groupNodes: {
					testerror: {
						nodes: [
							{
								type: "NotKSampler",
							},
							{
								type: "NotVAEDecode",
							},
						],
					},
				},
			},
		});

		expect(dialogShow).toBeCalledTimes(1);
		const call = dialogShow.mock.calls[0][0].innerHTML;
		expect(call).toContain("the following node types were not found");
		expect(call).toContain("NotKSampler");
		expect(call).toContain("NotVAEDecode");
		expect(call).toContain("workflow/testerror");
	});
	test("maintains widget inputs on conversion back to nodes", async () => {
		const { ez, graph, app } = await start();
		let pos = ez.CLIPTextEncode({ text: "positive" });
		pos.node.title = "Positive";
		let neg = ez.CLIPTextEncode({ text: "negative" });
		neg.node.title = "Negative";
		pos.widgets.text.convertToInput();
		neg.widgets.text.convertToInput();

		let primitive = ez.PrimitiveNode();
		primitive.outputs[0].connectTo(pos.inputs.text);
		primitive.outputs[0].connectTo(neg.inputs.text);

		const group = await convertToGroup(app, graph, "test", [pos, neg, primitive]);
		// This will use a primitive widget named 'value'
		expect(group.widgets.length).toBe(1);
		expect(group.widgets["value"].value).toBe("positive");

		const newNodes = group.menu["Convert to nodes"].call();
		pos = graph.find(newNodes.find((n) => n.title === "Positive"));
		neg = graph.find(newNodes.find((n) => n.title === "Negative"));
		primitive = graph.find(newNodes.find((n) => n.type === "PrimitiveNode"));

		expect(pos.inputs).toHaveLength(2);
		expect(neg.inputs).toHaveLength(2);
		expect(primitive.outputs[0].connections).toHaveLength(2);

		expect((await graph.toPrompt()).output).toEqual({
			1: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" },
			2: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" },
		});
	});
	test("correctly handles widget inputs", async () => {
		const { ez, graph, app } = await start();
		const upscaleMethods = (await getNodeDef("ImageScaleBy")).input.required["upscale_method"][0];

		const image = ez.LoadImage();
		const scale1 = ez.ImageScaleBy(image.outputs[0]);
		const scale2 = ez.ImageScaleBy(image.outputs[0]);
		const preview1 = ez.PreviewImage(scale1.outputs[0]);
		const preview2 = ez.PreviewImage(scale2.outputs[0]);
		scale1.widgets.upscale_method.value = upscaleMethods[1];
		scale1.widgets.upscale_method.convertToInput();

		const group = await convertToGroup(app, graph, "test", [scale1, scale2]);
		expect(group.inputs.length).toBe(3);
		expect(group.inputs[0].input.type).toBe("IMAGE");
		expect(group.inputs[1].input.type).toBe("IMAGE");
		expect(group.inputs[2].input.type).toBe("COMBO");

		// Ensure links are maintained
		expect(group.inputs[0].connection?.originNode?.id).toBe(image.id);
		expect(group.inputs[1].connection?.originNode?.id).toBe(image.id);
		expect(group.inputs[2].connection).toBeFalsy();

		// Ensure primitive gets correct type
		const primitive = ez.PrimitiveNode();
		primitive.outputs[0].connectTo(group.inputs[2]);
		expect(primitive.widgets.value.widget.options.values).toBe(upscaleMethods);
		expect(primitive.widgets.value.value).toBe(upscaleMethods[1]); // Ensure value is copied
		primitive.widgets.value.value = upscaleMethods[1];
		
		await checkBeforeAndAfterReload(graph, async (r) => {
			const scale1id = r ? `${group.id}:0` : scale1.id;
			const scale2id = r ? `${group.id}:1` : scale2.id;
			// Ensure widget value is applied to prompt
			expect((await graph.toPrompt()).output).toStrictEqual({
				[image.id]: { inputs: { image: "example.png", upload: "image" }, class_type: "LoadImage" },
				[scale1id]: {
					inputs: { upscale_method: upscaleMethods[1], scale_by: 1, image: [`${image.id}`, 0] },
					class_type: "ImageScaleBy",
				},
				[scale2id]: {
					inputs: { upscale_method: "nearest-exact", scale_by: 1, image: [`${image.id}`, 0] },
					class_type: "ImageScaleBy",
				},
				[preview1.id]: { inputs: { images: [`${scale1id}`, 0] }, class_type: "PreviewImage" },
				[preview2.id]: { inputs: { images: [`${scale2id}`, 0] }, class_type: "PreviewImage" },
			});
		});
	});
	test("adds widgets in node execution order", async () => {
		const { ez, graph, app } = await start();
		const scale = ez.LatentUpscale();
		const save = ez.SaveImage();
		const empty = ez.EmptyLatentImage();
		const decode = ez.VAEDecode();

		scale.outputs.LATENT.connectTo(decode.inputs.samples);
		decode.outputs.IMAGE.connectTo(save.inputs.images);
		empty.outputs.LATENT.connectTo(scale.inputs.samples);

		const group = await convertToGroup(app, graph, "test", [scale, save, empty, decode]);
		const widgets = group.widgets.map((w) => w.widget.name);
		expect(widgets).toStrictEqual([
			"width",
			"height",
			"batch_size",
			"upscale_method",
			"LatentUpscale width",
			"LatentUpscale height",
			"crop",
			"filename_prefix",
		]);
	});
	test("adds output for external links when converting to group", async () => {
		const { ez, graph, app } = await start();
		const img = ez.EmptyLatentImage();
		let decode = ez.VAEDecode(...img.outputs);
		const preview1 = ez.PreviewImage(...decode.outputs);
		const preview2 = ez.PreviewImage(...decode.outputs);

		const group = await convertToGroup(app, graph, "test", [img, decode, preview1]);

		// Ensure we have an output connected to the 2nd preview node
		expect(group.outputs.length).toBe(1);
		expect(group.outputs[0].connections.length).toBe(1);
		expect(group.outputs[0].connections[0].targetNode.id).toBe(preview2.id);

		// Convert back and ensure bothe previews are still connected
		group.menu["Convert to nodes"].call();
		decode = graph.find(decode);
		expect(decode.outputs[0].connections.length).toBe(2);
		expect(decode.outputs[0].connections[0].targetNode.id).toBe(preview1.id);
		expect(decode.outputs[0].connections[1].targetNode.id).toBe(preview2.id);
	});
	test("adds output for external links when converting to group when nodes are not in execution order", async () => {
		const { ez, graph, app } = await start();
		const sampler = ez.KSampler();
		const ckpt = ez.CheckpointLoaderSimple();
		const empty = ez.EmptyLatentImage();
		const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" });
		const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" });
		const decode1 = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE);
		const save = ez.SaveImage(decode1.outputs.IMAGE);
		ckpt.outputs.MODEL.connectTo(sampler.inputs.model);
		pos.outputs.CONDITIONING.connectTo(sampler.inputs.positive);
		neg.outputs.CONDITIONING.connectTo(sampler.inputs.negative);
		empty.outputs.LATENT.connectTo(sampler.inputs.latent_image);

		const encode = ez.VAEEncode(decode1.outputs.IMAGE);
		const vae = ez.VAELoader();
		const decode2 = ez.VAEDecode(encode.outputs.LATENT, vae.outputs.VAE);
		const preview = ez.PreviewImage(decode2.outputs.IMAGE);
		vae.outputs.VAE.connectTo(encode.inputs.vae);

		const group = await convertToGroup(app, graph, "test", [vae, decode1, encode, sampler]);

		expect(group.outputs.length).toBe(3);
		expect(group.outputs[0].output.name).toBe("VAE");
		expect(group.outputs[0].output.type).toBe("VAE");
		expect(group.outputs[1].output.name).toBe("IMAGE");
		expect(group.outputs[1].output.type).toBe("IMAGE");
		expect(group.outputs[2].output.name).toBe("LATENT");
		expect(group.outputs[2].output.type).toBe("LATENT");

		expect(group.outputs[0].connections.length).toBe(1);
		expect(group.outputs[0].connections[0].targetNode.id).toBe(decode2.id);
		expect(group.outputs[0].connections[0].targetInput.index).toBe(1);

		expect(group.outputs[1].connections.length).toBe(1);
		expect(group.outputs[1].connections[0].targetNode.id).toBe(save.id);
		expect(group.outputs[1].connections[0].targetInput.index).toBe(0);

		expect(group.outputs[2].connections.length).toBe(1);
		expect(group.outputs[2].connections[0].targetNode.id).toBe(decode2.id);
		expect(group.outputs[2].connections[0].targetInput.index).toBe(0);

		expect((await graph.toPrompt()).output).toEqual({
			...getOutput({ 1: ckpt.id, 2: pos.id, 3: neg.id, 4: empty.id, 5: sampler.id, 6: decode1.id, 7: save.id }),
			[vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: vae.node.type },
			[encode.id]: { inputs: { pixels: ["6", 0], vae: [vae.id + "", 0] }, class_type: encode.node.type },
			[decode2.id]: { inputs: { samples: [encode.id + "", 0], vae: [vae.id + "", 0] }, class_type: decode2.node.type },
			[preview.id]: { inputs: { images: [decode2.id + "", 0] }, class_type: preview.node.type },
		});
	});
	test("works with IMAGEUPLOAD widget", async () => {
		const { ez, graph, app } = await start();
		const img = ez.LoadImage();
		const preview1 = ez.PreviewImage(img.outputs[0]);

		const group = await convertToGroup(app, graph, "test", [img, preview1]);
		const widget = group.widgets["upload"];
		expect(widget).toBeTruthy();
		expect(widget.widget.type).toBe("button");
	});
	test("internal primitive populates widgets for all linked inputs", async () => {
		const { ez, graph, app } = await start();
		const img = ez.LoadImage();
		const scale1 = ez.ImageScale(img.outputs[0]);
		const scale2 = ez.ImageScale(img.outputs[0]);
		ez.PreviewImage(scale1.outputs[0]);
		ez.PreviewImage(scale2.outputs[0]);

		scale1.widgets.width.convertToInput();
		scale2.widgets.height.convertToInput();

		const primitive = ez.PrimitiveNode();
		primitive.outputs[0].connectTo(scale1.inputs.width);
		primitive.outputs[0].connectTo(scale2.inputs.height);

		const group = await convertToGroup(app, graph, "test", [img, primitive, scale1, scale2]);
		group.widgets.value.value = 100;
		expect((await graph.toPrompt()).output).toEqual({
			1: {
				inputs: { image: img.widgets.image.value, upload: "image" },
				class_type: "LoadImage",
			},
			2: {
				inputs: { upscale_method: "nearest-exact", width: 100, height: 512, crop: "disabled", image: ["1", 0] },
				class_type: "ImageScale",
			},
			3: {
				inputs: { upscale_method: "nearest-exact", width: 512, height: 100, crop: "disabled", image: ["1", 0] },
				class_type: "ImageScale",
			},
			4: { inputs: { images: ["2", 0] }, class_type: "PreviewImage" },
			5: { inputs: { images: ["3", 0] }, class_type: "PreviewImage" },
		});
	});
	test("primitive control widgets values are copied on convert", async () => {
		const { ez, graph, app } = await start();
		const sampler = ez.KSampler();
		sampler.widgets.seed.convertToInput();
		sampler.widgets.sampler_name.convertToInput();

		let p1 = ez.PrimitiveNode();
		let p2 = ez.PrimitiveNode();
		p1.outputs[0].connectTo(sampler.inputs.seed);
		p2.outputs[0].connectTo(sampler.inputs.sampler_name);

		p1.widgets.control_after_generate.value = "increment";
		p2.widgets.control_after_generate.value = "decrement";
		p2.widgets.control_filter_list.value = "/.*/";

		p2.node.title = "p2";

		const group = await convertToGroup(app, graph, "test", [sampler, p1, p2]);
		expect(group.widgets.control_after_generate.value).toBe("increment");
		expect(group.widgets["p2 control_after_generate"].value).toBe("decrement");
		expect(group.widgets["p2 control_filter_list"].value).toBe("/.*/");

		group.widgets.control_after_generate.value = "fixed";
		group.widgets["p2 control_after_generate"].value = "randomize";
		group.widgets["p2 control_filter_list"].value = "/.+/";

		group.menu["Convert to nodes"].call();
		p1 = graph.find(p1);
		p2 = graph.find(p2);

		expect(p1.widgets.control_after_generate.value).toBe("fixed");
		expect(p2.widgets.control_after_generate.value).toBe("randomize");
		expect(p2.widgets.control_filter_list.value).toBe("/.+/");
	});
	test("internal reroutes work with converted inputs and merge options", async () => {
		const { ez, graph, app } = await start();
		const vae = ez.VAELoader();
		const latent = ez.EmptyLatentImage();
		const decode = ez.VAEDecode(latent.outputs.LATENT, vae.outputs.VAE);
		const scale = ez.ImageScale(decode.outputs.IMAGE);
		ez.PreviewImage(scale.outputs.IMAGE);

		const r1 = ez.Reroute();
		const r2 = ez.Reroute();

		latent.widgets.width.value = 64;
		latent.widgets.height.value = 128;

		latent.widgets.width.convertToInput();
		latent.widgets.height.convertToInput();
		latent.widgets.batch_size.convertToInput();

		scale.widgets.width.convertToInput();
		scale.widgets.height.convertToInput();

		r1.inputs[0].input.label = "hbw";
		r1.outputs[0].connectTo(latent.inputs.height);
		r1.outputs[0].connectTo(latent.inputs.batch_size);
		r1.outputs[0].connectTo(scale.inputs.width);

		r2.inputs[0].input.label = "wh";
		r2.outputs[0].connectTo(latent.inputs.width);
		r2.outputs[0].connectTo(scale.inputs.height);

		const group = await convertToGroup(app, graph, "test", [r1, r2, latent, decode, scale]);

		expect(group.inputs[0].input.type).toBe("VAE");
		expect(group.inputs[1].input.type).toBe("INT");
		expect(group.inputs[2].input.type).toBe("INT");

		const p1 = ez.PrimitiveNode();
		const p2 = ez.PrimitiveNode();
		p1.outputs[0].connectTo(group.inputs[1]);
		p2.outputs[0].connectTo(group.inputs[2]);

		expect(p1.widgets.value.widget.options?.min).toBe(16); // width/height min
		expect(p1.widgets.value.widget.options?.max).toBe(4096); // batch max
		expect(p1.widgets.value.widget.options?.step).toBe(80); // width/height step * 10

		expect(p2.widgets.value.widget.options?.min).toBe(16); // width/height min
		expect(p2.widgets.value.widget.options?.max).toBe(8192); // width/height max
		expect(p2.widgets.value.widget.options?.step).toBe(80); // width/height step * 10

		expect(p1.widgets.value.value).toBe(128);
		expect(p2.widgets.value.value).toBe(64);

		p1.widgets.value.value = 16;
		p2.widgets.value.value = 32;

		await checkBeforeAndAfterReload(graph, async (r) => {
			const id = (v) => (r ? `${group.id}:` : "") + v;
			expect((await graph.toPrompt()).output).toStrictEqual({
				1: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" },
				[id(2)]: { inputs: { width: 32, height: 16, batch_size: 16 }, class_type: "EmptyLatentImage" },
				[id(3)]: { inputs: { samples: [id(2), 0], vae: ["1", 0] }, class_type: "VAEDecode" },
				[id(4)]: {
					inputs: { upscale_method: "nearest-exact", width: 16, height: 32, crop: "disabled", image: [id(3), 0] },
					class_type: "ImageScale",
				},
				5: { inputs: { images: [id(4), 0] }, class_type: "PreviewImage" },
			});
		});
	});
	test("converted inputs with linked widgets map values correctly on creation", async () => {
		const { ez, graph, app } = await start();
		const k1 = ez.KSampler();
		const k2 = ez.KSampler();
		k1.widgets.seed.convertToInput();
		k2.widgets.seed.convertToInput();

		const rr = ez.Reroute();
		rr.outputs[0].connectTo(k1.inputs.seed);
		rr.outputs[0].connectTo(k2.inputs.seed);

		const group = await convertToGroup(app, graph, "test", [k1, k2, rr]);
		expect(group.widgets.steps.value).toBe(20);
		expect(group.widgets.cfg.value).toBe(8);
		expect(group.widgets.scheduler.value).toBe("normal");
		expect(group.widgets["KSampler steps"].value).toBe(20);
		expect(group.widgets["KSampler cfg"].value).toBe(8);
		expect(group.widgets["KSampler scheduler"].value).toBe("normal");
	});
	test("allow multiple of the same node type to be added", async () => {
		const { ez, graph, app } = await start();
		const nodes = [...Array(10)].map(() => ez.ImageScaleBy());
		const group = await convertToGroup(app, graph, "test", nodes);
		expect(group.inputs.length).toBe(10);
		expect(group.outputs.length).toBe(10);
		expect(group.widgets.length).toBe(20);
		expect(group.widgets.map((w) => w.widget.name)).toStrictEqual(
			[...Array(10)]
				.map((_, i) => `${i > 0 ? "ImageScaleBy " : ""}${i > 1 ? i + " " : ""}`)
				.flatMap((p) => [`${p}upscale_method`, `${p}scale_by`])
		);
	});
});