|
1 | 1 | import { createTestingPinia } from '@pinia/testing' |
2 | 2 | import { fromAny, fromPartial } from '@total-typescript/shoehorn' |
3 | 3 | import { setActivePinia } from 'pinia' |
4 | | -import { nextTick } from 'vue' |
| 4 | +import { nextTick, reactive } from 'vue' |
5 | 5 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
6 | 6 |
|
7 | 7 | import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' |
@@ -56,9 +56,13 @@ vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({ |
56 | 56 | resolveNode: mockResolveNode |
57 | 57 | })) |
58 | 58 |
|
| 59 | +const mockCanvas = vi.hoisted(() => ({ |
| 60 | + state: undefined as { readOnly: boolean } | undefined |
| 61 | +})) |
| 62 | + |
59 | 63 | vi.mock('@/renderer/core/canvas/canvasStore', () => ({ |
60 | 64 | useCanvasStore: () => ({ |
61 | | - getCanvas: () => ({ read_only: false }) |
| 65 | + getCanvas: () => ({ state: mockCanvas.state }) |
62 | 66 | }) |
63 | 67 | })) |
64 | 68 |
|
@@ -162,6 +166,7 @@ describe('appModeStore', () => { |
162 | 166 | ChangeTracker.isLoadingGraph = false |
163 | 167 | mockResolveNode.mockReturnValue(undefined) |
164 | 168 | mockSettings.reset() |
| 169 | + mockCanvas.state = undefined |
165 | 170 | vi.mocked(app.rootGraph).nodes = [{ id: toNodeId(1) } as LGraphNode] |
166 | 171 | workflowStore = useWorkflowStore() |
167 | 172 | store = useAppModeStore() |
@@ -365,6 +370,83 @@ describe('appModeStore', () => { |
365 | 370 | expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']]) |
366 | 371 | }) |
367 | 372 |
|
| 373 | + it('keeps canonical entity ids when the node still exists', () => { |
| 374 | + const node1 = nodeWithWidgets(1, []) |
| 375 | + vi.mocked(app.rootGraph).nodes = [node1] |
| 376 | + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => |
| 377 | + id === toNodeId(1) ? node1 : null |
| 378 | + ) |
| 379 | + |
| 380 | + store.loadSelections({ |
| 381 | + inputs: [[entityPrompt, 'prompt']] |
| 382 | + }) |
| 383 | + |
| 384 | + expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']]) |
| 385 | + }) |
| 386 | + |
| 387 | + it('drops canonical entity ids when their node is gone', () => { |
| 388 | + vi.mocked(app.rootGraph).nodes = [] |
| 389 | + vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null) |
| 390 | + |
| 391 | + store.loadSelections({ |
| 392 | + inputs: [[entityPrompt, 'prompt']] |
| 393 | + }) |
| 394 | + |
| 395 | + expect(store.selectedInputs).toEqual([]) |
| 396 | + }) |
| 397 | + |
| 398 | + it('drops locator inputs when the widget does not resolve', () => { |
| 399 | + const hostLocator = `${rootGraphId}:5` |
| 400 | + const hostNode = fromAny<LGraphNode, unknown>({ |
| 401 | + id: 5, |
| 402 | + isSubgraphNode: () => false, |
| 403 | + widgets: [{ name: 'other' }] |
| 404 | + }) |
| 405 | + vi.mocked(app.rootGraph).nodes = [hostNode] |
| 406 | + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => |
| 407 | + id === toNodeId(5) ? hostNode : null |
| 408 | + ) |
| 409 | + |
| 410 | + store.loadSelections({ |
| 411 | + inputs: [[hostLocator, 'prompt']] |
| 412 | + }) |
| 413 | + |
| 414 | + expect(store.selectedInputs).toEqual([]) |
| 415 | + }) |
| 416 | + |
| 417 | + it('drops malformed legacy input ids', () => { |
| 418 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) |
| 419 | + vi.mocked(app.rootGraph).nodes = [] |
| 420 | + |
| 421 | + store.loadSelections({ |
| 422 | + inputs: [[fromAny<SerializedNodeId, unknown>(null), 'prompt']] |
| 423 | + }) |
| 424 | + |
| 425 | + expect(store.selectedInputs).toEqual([]) |
| 426 | + expect(warnSpy).toHaveBeenCalledWith( |
| 427 | + expect.stringContaining('legacy selectedInput tuple'), |
| 428 | + expect.objectContaining({ storedId: null, widgetName: 'prompt' }) |
| 429 | + ) |
| 430 | + warnSpy.mockRestore() |
| 431 | + }) |
| 432 | + |
| 433 | + it('drops direct node inputs when the widget is missing', () => { |
| 434 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) |
| 435 | + const node1 = nodeWithWidgets(1, []) |
| 436 | + vi.mocked(app.rootGraph).nodes = [node1] |
| 437 | + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => |
| 438 | + id === toNodeId(1) ? node1 : null |
| 439 | + ) |
| 440 | + |
| 441 | + store.loadSelections({ |
| 442 | + inputs: [[1, 'prompt']] |
| 443 | + }) |
| 444 | + |
| 445 | + expect(store.selectedInputs).toEqual([]) |
| 446 | + expect(warnSpy).toHaveBeenCalled() |
| 447 | + warnSpy.mockRestore() |
| 448 | + }) |
| 449 | + |
368 | 450 | it('drops legacy entries whose widget no longer exists', () => { |
369 | 451 | const node1 = nodeWithWidgets(1, ['prompt']) |
370 | 452 | vi.mocked(app.rootGraph).nodes = [node1] |
@@ -399,6 +481,32 @@ describe('appModeStore', () => { |
399 | 481 | expect(store.selectedOutputs).toEqual([toNodeId(1)]) |
400 | 482 | }) |
401 | 483 |
|
| 484 | + it('drops malformed output ids on load', () => { |
| 485 | + store.loadSelections({ |
| 486 | + outputs: [fromAny<SerializedNodeId, unknown>('')] |
| 487 | + }) |
| 488 | + |
| 489 | + expect(store.selectedOutputs).toEqual([]) |
| 490 | + }) |
| 491 | + |
| 492 | + it('drops legacy subgraph input slots without widget ids', () => { |
| 493 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) |
| 494 | + const hostNode = Object.assign(Object.create(SubgraphNode.prototype), { |
| 495 | + id: 5, |
| 496 | + inputs: [{ name: 'Prompt' }] |
| 497 | + }) |
| 498 | + vi.mocked(app.rootGraph).nodes = [hostNode] |
| 499 | + vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null) |
| 500 | + |
| 501 | + store.loadSelections({ |
| 502 | + inputs: [[1, 'prompt']] |
| 503 | + }) |
| 504 | + |
| 505 | + expect(store.selectedInputs).toEqual([]) |
| 506 | + expect(warnSpy).toHaveBeenCalled() |
| 507 | + warnSpy.mockRestore() |
| 508 | + }) |
| 509 | + |
402 | 510 | it('reloads selections on configured event', async () => { |
403 | 511 | const node1 = nodeWithWidgets(1, ['seed']) |
404 | 512 |
|
@@ -481,7 +589,7 @@ describe('appModeStore', () => { |
481 | 589 | expect( |
482 | 590 | store.pruneLinearData({ |
483 | 591 | inputs: [[1, 'seed']], |
484 | | - outputs: [toNodeId(1)] |
| 592 | + outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')] |
485 | 593 | }) |
486 | 594 | ).toEqual({ |
487 | 595 | inputs: [[1, 'seed']], |
@@ -641,6 +749,17 @@ describe('appModeStore', () => { |
641 | 749 | expect(originalRootGraph.extra.linearData).toEqual(dataBefore) |
642 | 750 | }) |
643 | 751 |
|
| 752 | + it('does not write while graph loading is in progress', async () => { |
| 753 | + workflowStore.activeWorkflow = createBuilderWorkflow() |
| 754 | + ChangeTracker.isLoadingGraph = true |
| 755 | + await nextTick() |
| 756 | + |
| 757 | + store.selectedOutputs.push(toNodeId(1)) |
| 758 | + await nextTick() |
| 759 | + |
| 760 | + expect(app.rootGraph.extra.linearData).toBeUndefined() |
| 761 | + }) |
| 762 | + |
644 | 763 | it('calls captureCanvasState when input is selected', async () => { |
645 | 764 | const workflow = createBuilderWorkflow() |
646 | 765 | workflowStore.activeWorkflow = workflow |
@@ -755,6 +874,24 @@ describe('appModeStore', () => { |
755 | 874 |
|
756 | 875 | expect(store.selectedInputs).toEqual([[promptEntity, 'prompt']]) |
757 | 876 | }) |
| 877 | + |
| 878 | + it('ignores widgets without ids', () => { |
| 879 | + store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt']) |
| 880 | + |
| 881 | + store.removeSelectedInput(fromAny<IBaseWidget, unknown>({})) |
| 882 | + |
| 883 | + expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']]) |
| 884 | + }) |
| 885 | + |
| 886 | + it('ignores missing input ids', () => { |
| 887 | + store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt']) |
| 888 | + |
| 889 | + store.removeSelectedInput( |
| 890 | + fromAny<IBaseWidget, unknown>({ widgetId: 'g:2:prompt' }) |
| 891 | + ) |
| 892 | + |
| 893 | + expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']]) |
| 894 | + }) |
758 | 895 | }) |
759 | 896 |
|
760 | 897 | describe('autoEnableVueNodes', () => { |
@@ -819,6 +956,47 @@ describe('appModeStore', () => { |
819 | 956 | expect.anything() |
820 | 957 | ) |
821 | 958 | }) |
| 959 | + |
| 960 | + it('does not enable Vue nodes after leaving select mode', async () => { |
| 961 | + mockSettings.store['Comfy.VueNodes.Enabled'] = false |
| 962 | + workflowStore.activeWorkflow = createBuilderWorkflow('graph') |
| 963 | + |
| 964 | + store.enterBuilder() |
| 965 | + await nextTick() |
| 966 | + mockSettings.set.mockClear() |
| 967 | + store.exitBuilder() |
| 968 | + await nextTick() |
| 969 | + |
| 970 | + expect(mockSettings.set).not.toHaveBeenCalled() |
| 971 | + }) |
| 972 | + }) |
| 973 | + |
| 974 | + describe('read only canvas sync', () => { |
| 975 | + it('keeps canvas read-only while in select mode', async () => { |
| 976 | + mockCanvas.state = reactive({ readOnly: false }) |
| 977 | + workflowStore.activeWorkflow = createBuilderWorkflow('graph') |
| 978 | + |
| 979 | + store.enterBuilder() |
| 980 | + await nextTick() |
| 981 | + mockCanvas.state.readOnly = false |
| 982 | + await nextTick() |
| 983 | + |
| 984 | + expect(mockCanvas.state.readOnly).toBe(true) |
| 985 | + }) |
| 986 | + |
| 987 | + it('stops enforcing read-only after leaving select mode', async () => { |
| 988 | + mockCanvas.state = reactive({ readOnly: false }) |
| 989 | + workflowStore.activeWorkflow = createBuilderWorkflow('graph') |
| 990 | + |
| 991 | + store.enterBuilder() |
| 992 | + await nextTick() |
| 993 | + store.exitBuilder() |
| 994 | + await nextTick() |
| 995 | + mockCanvas.state.readOnly = false |
| 996 | + await nextTick() |
| 997 | + |
| 998 | + expect(mockCanvas.state.readOnly).toBe(false) |
| 999 | + }) |
822 | 1000 | }) |
823 | 1001 |
|
824 | 1002 | describe('legacy selectedInput tuple migration', () => { |
@@ -907,6 +1085,121 @@ describe('appModeStore', () => { |
907 | 1085 | ]) |
908 | 1086 | }) |
909 | 1087 |
|
| 1088 | + it('drops direct root-node widgets that cannot produce an entity id', () => { |
| 1089 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) |
| 1090 | + const sourceNodeId = 42 |
| 1091 | + const sourceWidgetName = 'text' |
| 1092 | + const rootNode = fromAny<LGraphNode, unknown>({ |
| 1093 | + id: sourceNodeId, |
| 1094 | + widgets: [{ name: sourceWidgetName }] |
| 1095 | + }) |
| 1096 | + vi.mocked(app.rootGraph).id = rootGraphId |
| 1097 | + vi.mocked(app.rootGraph).nodes = [rootNode] |
| 1098 | + vi.mocked(app.rootGraph).getNodeById = vi.fn( |
| 1099 | + (id: SerializedNodeId | null | undefined) => |
| 1100 | + id == sourceNodeId ? rootNode : null |
| 1101 | + ) |
| 1102 | + |
| 1103 | + const result = store.pruneLinearData({ |
| 1104 | + inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]], |
| 1105 | + outputs: [] |
| 1106 | + }) |
| 1107 | + |
| 1108 | + expect(result.inputs).toEqual([]) |
| 1109 | + expect(warnSpy).toHaveBeenCalledWith( |
| 1110 | + expect.stringContaining('legacy selectedInput tuple'), |
| 1111 | + expect.objectContaining({ |
| 1112 | + storedId: sourceNodeId, |
| 1113 | + widgetName: sourceWidgetName |
| 1114 | + }) |
| 1115 | + ) |
| 1116 | + warnSpy.mockRestore() |
| 1117 | + }) |
| 1118 | + |
| 1119 | + it('drops promoted inputs whose source target no longer matches', () => { |
| 1120 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) |
| 1121 | + const subgraphInputName = 'Prompt' |
| 1122 | + const sourceWidgetName = 'text' |
| 1123 | + |
| 1124 | + const subgraph = createTestSubgraph({ |
| 1125 | + inputs: [{ name: subgraphInputName, type: 'STRING' }] |
| 1126 | + }) |
| 1127 | + const interior = new LGraphNodeClass('Interior') |
| 1128 | + const interiorInput = interior.addInput(subgraphInputName, 'STRING') |
| 1129 | + interior.addWidget('string', sourceWidgetName, '', () => undefined) |
| 1130 | + interiorInput.widget = { name: sourceWidgetName } |
| 1131 | + subgraph.add(interior) |
| 1132 | + subgraph.inputNode.slots[0].connect(interiorInput, interior) |
| 1133 | + |
| 1134 | + const host = createTestSubgraphNode(subgraph, { id: 5 }) |
| 1135 | + const rootGraph = host.graph as LGraph |
| 1136 | + rootGraph.add(host) |
| 1137 | + host._internalConfigureAfterSlots() |
| 1138 | + |
| 1139 | + vi.mocked(app.rootGraph).id = rootGraph.id |
| 1140 | + vi.mocked(app.rootGraph).nodes = rootGraph.nodes |
| 1141 | + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => |
| 1142 | + rootGraph.getNodeById(id) |
| 1143 | + ) |
| 1144 | + |
| 1145 | + const result = store.pruneLinearData({ |
| 1146 | + inputs: [[interior.id, 'other-widget', { height: 120 }]], |
| 1147 | + outputs: [] |
| 1148 | + }) |
| 1149 | + |
| 1150 | + expect(result.inputs).toEqual([]) |
| 1151 | + expect(warnSpy).toHaveBeenCalled() |
| 1152 | + warnSpy.mockRestore() |
| 1153 | + }) |
| 1154 | + |
| 1155 | + it('drops legacy inputs when multiple promoted inputs match', () => { |
| 1156 | + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) |
| 1157 | + const subgraphInputName = 'Prompt' |
| 1158 | + const sourceWidgetName = 'text' |
| 1159 | + |
| 1160 | + const subgraph = createTestSubgraph({ |
| 1161 | + inputs: [{ name: subgraphInputName, type: 'STRING' }] |
| 1162 | + }) |
| 1163 | + const interior = new LGraphNodeClass('Interior') |
| 1164 | + const interiorInput = interior.addInput(subgraphInputName, 'STRING') |
| 1165 | + interior.addWidget('string', sourceWidgetName, '', () => undefined) |
| 1166 | + interiorInput.widget = { name: sourceWidgetName } |
| 1167 | + subgraph.add(interior) |
| 1168 | + subgraph.inputNode.slots[0].connect(interiorInput, interior) |
| 1169 | + |
| 1170 | + const firstHost = createTestSubgraphNode(subgraph, { id: 5 }) |
| 1171 | + const rootGraph = firstHost.graph as LGraph |
| 1172 | + const secondHost = createTestSubgraphNode(subgraph, { |
| 1173 | + id: 6, |
| 1174 | + parentGraph: rootGraph |
| 1175 | + }) |
| 1176 | + rootGraph.add(firstHost) |
| 1177 | + rootGraph.add(secondHost) |
| 1178 | + firstHost._internalConfigureAfterSlots() |
| 1179 | + secondHost._internalConfigureAfterSlots() |
| 1180 | + |
| 1181 | + vi.mocked(app.rootGraph).id = rootGraph.id |
| 1182 | + vi.mocked(app.rootGraph).nodes = rootGraph.nodes |
| 1183 | + vi.mocked(app.rootGraph).getNodeById = vi.fn((id) => |
| 1184 | + rootGraph.getNodeById(id) |
| 1185 | + ) |
| 1186 | + |
| 1187 | + const result = store.pruneLinearData({ |
| 1188 | + inputs: [[interior.id, sourceWidgetName, { height: 120 }]], |
| 1189 | + outputs: [] |
| 1190 | + }) |
| 1191 | + |
| 1192 | + expect(result.inputs).toEqual([]) |
| 1193 | + expect(warnSpy).toHaveBeenCalledWith( |
| 1194 | + expect.stringContaining('ambiguous legacy selectedInput tuple'), |
| 1195 | + expect.objectContaining({ |
| 1196 | + storedId: interior.id, |
| 1197 | + widgetName: sourceWidgetName |
| 1198 | + }) |
| 1199 | + ) |
| 1200 | + warnSpy.mockRestore() |
| 1201 | + }) |
| 1202 | + |
910 | 1203 | it('warns and drops a tuple whose target widget no longer resolves', () => { |
911 | 1204 | const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) |
912 | 1205 | vi.mocked(app.rootGraph).id = rootGraphId |
|
0 commit comments