|
1 | 1 | import { createTestingPinia } from '@pinia/testing' |
2 | 2 | import { setActivePinia } from 'pinia' |
3 | | -import { beforeEach, describe, expect, it } from 'vitest' |
| 3 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
4 | 4 |
|
5 | 5 | import type { |
6 | 6 | INodeInputSlot, |
7 | 7 | INodeOutputSlot |
8 | 8 | } from '@/lib/litegraph/src/interfaces' |
9 | 9 | import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' |
| 10 | +import { useLinkStore } from '@/stores/linkStore' |
10 | 11 |
|
11 | 12 | function connectedPair() { |
12 | 13 | const graph = new LGraph() |
@@ -159,3 +160,122 @@ describe('legacy slot link creation and plain-object slots (uncovered)', () => { |
159 | 160 | expect(target.isInputConnected(0)).toBe(false) |
160 | 161 | }) |
161 | 162 | }) |
| 163 | + |
| 164 | +function autogrowChain(inputCount: number, connectedSlots: number[]) { |
| 165 | + const graph = new LGraph() |
| 166 | + const source = new LGraphNode('Source') |
| 167 | + source.addOutput('out', 'STRING') |
| 168 | + graph.add(source) |
| 169 | + |
| 170 | + const target = new LGraphNode('PromptChain') |
| 171 | + for (let i = 0; i < inputCount; i++) { |
| 172 | + target.addInput(`inputs.in_${i}`, 'STRING') |
| 173 | + } |
| 174 | + graph.add(target) |
| 175 | + for (const slot of connectedSlots) source.connect(0, target, slot) |
| 176 | + |
| 177 | + return { graph, source, target } |
| 178 | +} |
| 179 | + |
| 180 | +/** mobcat40/ComfyUI-PromptChain js/lib/order-chain.js:123 `updateInputLabels`. */ |
| 181 | +function replaceSlotsWithLabelledCopies(node: LGraphNode) { |
| 182 | + for (const [index, slot] of node.inputs.entries()) { |
| 183 | + if (!slot.name.includes('in_')) continue |
| 184 | + slot.label = slot.link == null ? 'in' : 'PromptChain' |
| 185 | + node.inputs[index] = { ...slot } |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +/** mobcat40/ComfyUI-PromptChain js/main.js:507 `trimEmptyAutogrowSlots`. */ |
| 190 | +function trimEmptyAutogrowSlots(node: LGraphNode) { |
| 191 | + const { inputs } = node |
| 192 | + const autogrow = inputs.filter((input) => input.name.startsWith('inputs.in_')) |
| 193 | + if (autogrow.length <= 1) return |
| 194 | + |
| 195 | + const lastConnected = autogrow.findLastIndex((input) => input.link != null) |
| 196 | + const keepCount = Math.max(1, lastConnected + 2) |
| 197 | + if (keepCount >= autogrow.length) return |
| 198 | + |
| 199 | + for (const input of autogrow.slice(keepCount)) { |
| 200 | + const index = inputs.indexOf(input) |
| 201 | + if (index !== -1) inputs.splice(index, 1) |
| 202 | + } |
| 203 | +} |
| 204 | + |
| 205 | +describe('comfyui-promptchain indexed slot replacement', () => { |
| 206 | + beforeEach(() => { |
| 207 | + setActivePinia(createTestingPinia({ stubActions: false })) |
| 208 | + }) |
| 209 | + |
| 210 | + it('keeps the link store correct when the pack replaces every slot', () => { |
| 211 | + const { graph, target } = autogrowChain(4, [0, 1, 2]) |
| 212 | + |
| 213 | + replaceSlotsWithLabelledCopies(target) |
| 214 | + target.removeInput(0) |
| 215 | + |
| 216 | + expect(target.inputs).toHaveLength(3) |
| 217 | + expect(graph.links.size).toBe(2) |
| 218 | + expect(target.isInputConnected(0)).toBe(true) |
| 219 | + expect(target.isInputConnected(1)).toBe(true) |
| 220 | + expect(target.isInputConnected(2)).toBe(false) |
| 221 | + }) |
| 222 | + |
| 223 | + it('never rejects the endpoint batch, though a rejection is detectable', () => { |
| 224 | + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) |
| 225 | + const rejected = () => |
| 226 | + consoleError.mock.calls.filter( |
| 227 | + ([message]) => message === 'Failed to replace node inputs' |
| 228 | + ).length |
| 229 | + |
| 230 | + const live = autogrowChain(4, [0, 1, 2]) |
| 231 | + replaceSlotsWithLabelledCopies(live.target) |
| 232 | + trimEmptyAutogrowSlots(live.target) |
| 233 | + replaceSlotsWithLabelledCopies(live.target) |
| 234 | + live.target.removeInput(0) |
| 235 | + expect(rejected()).toBe(0) |
| 236 | + |
| 237 | + const forced = autogrowChain(4, [0, 1, 2]) |
| 238 | + const layoutBefore = forced.target.inputs.map((input) => input.name) |
| 239 | + vi.spyOn(useLinkStore(), 'updateEndpoints').mockReturnValue({ |
| 240 | + ok: false, |
| 241 | + error: { code: 'occupied-target', message: 'forced' } |
| 242 | + }) |
| 243 | + forced.target.removeInput(0) |
| 244 | + |
| 245 | + expect(rejected()).toBe(1) |
| 246 | + expect(forced.target.inputs.map((input) => input.name)).toEqual( |
| 247 | + layoutBefore |
| 248 | + ) |
| 249 | + |
| 250 | + consoleError.mockRestore() |
| 251 | + }) |
| 252 | + |
| 253 | + it.fails('reads the live link id back through a spread copy', () => { |
| 254 | + const { target } = autogrowChain(2, [0]) |
| 255 | + const linkId = target.getInputLink(0)!.id |
| 256 | + |
| 257 | + const copy: INodeInputSlot = { ...target.inputs[0] } |
| 258 | + target.inputs[0] = copy |
| 259 | + |
| 260 | + expect(target.inputs[0].link).toBe(linkId) |
| 261 | + }) |
| 262 | + |
| 263 | + it.fails('keeps connected inputs when the pack re-reads slot.link', () => { |
| 264 | + const { graph, target } = autogrowChain(4, [0, 1, 2]) |
| 265 | + |
| 266 | + replaceSlotsWithLabelledCopies(target) |
| 267 | + trimEmptyAutogrowSlots(target) |
| 268 | + |
| 269 | + expect(target.inputs).toHaveLength(4) |
| 270 | + expect(graph.links.size).toBe(3) |
| 271 | + }) |
| 272 | + |
| 273 | + it('keeps every input when the same trim reads live slots', () => { |
| 274 | + const { graph, target } = autogrowChain(4, [0, 1, 2]) |
| 275 | + |
| 276 | + trimEmptyAutogrowSlots(target) |
| 277 | + |
| 278 | + expect(target.inputs).toHaveLength(4) |
| 279 | + expect(graph.links.size).toBe(3) |
| 280 | + }) |
| 281 | +}) |
0 commit comments