Skip to content

Commit 57cd114

Browse files
committed
test: cover session and UI stores
1 parent 3377b8e commit 57cd114

10 files changed

Lines changed: 1104 additions & 7 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { createTestingPinia } from '@pinia/testing'
2+
import { setActivePinia } from 'pinia'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
6+
import { useExtensionStore } from '@/stores/extensionStore'
7+
8+
describe('actionBarButtonStore', () => {
9+
beforeEach(() => {
10+
setActivePinia(createTestingPinia({ stubActions: false }))
11+
})
12+
13+
it('collects action bar buttons from registered extensions', () => {
14+
const extensionStore = useExtensionStore()
15+
const onClick = vi.fn()
16+
extensionStore.registerExtension({
17+
name: 'buttons',
18+
actionBarButtons: [{ icon: 'icon-[lucide--plus]', onClick }]
19+
})
20+
extensionStore.registerExtension({ name: 'plain' })
21+
22+
const store = useActionBarButtonStore()
23+
24+
expect(store.buttons).toEqual([{ icon: 'icon-[lucide--plus]', onClick }])
25+
})
26+
})

src/stores/appModeStore.test.ts

Lines changed: 296 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createTestingPinia } from '@pinia/testing'
22
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
33
import { setActivePinia } from 'pinia'
4-
import { nextTick } from 'vue'
4+
import { nextTick, reactive } from 'vue'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66

77
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -56,9 +56,13 @@ vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
5656
resolveNode: mockResolveNode
5757
}))
5858

59+
const mockCanvas = vi.hoisted(() => ({
60+
state: undefined as { readOnly: boolean } | undefined
61+
}))
62+
5963
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
6064
useCanvasStore: () => ({
61-
getCanvas: () => ({ read_only: false })
65+
getCanvas: () => ({ state: mockCanvas.state })
6266
})
6367
}))
6468

@@ -162,6 +166,7 @@ describe('appModeStore', () => {
162166
ChangeTracker.isLoadingGraph = false
163167
mockResolveNode.mockReturnValue(undefined)
164168
mockSettings.reset()
169+
mockCanvas.state = undefined
165170
vi.mocked(app.rootGraph).nodes = [{ id: toNodeId(1) } as LGraphNode]
166171
workflowStore = useWorkflowStore()
167172
store = useAppModeStore()
@@ -365,6 +370,83 @@ describe('appModeStore', () => {
365370
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
366371
})
367372

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+
368450
it('drops legacy entries whose widget no longer exists', () => {
369451
const node1 = nodeWithWidgets(1, ['prompt'])
370452
vi.mocked(app.rootGraph).nodes = [node1]
@@ -399,6 +481,32 @@ describe('appModeStore', () => {
399481
expect(store.selectedOutputs).toEqual([toNodeId(1)])
400482
})
401483

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+
402510
it('reloads selections on configured event', async () => {
403511
const node1 = nodeWithWidgets(1, ['seed'])
404512

@@ -481,7 +589,7 @@ describe('appModeStore', () => {
481589
expect(
482590
store.pruneLinearData({
483591
inputs: [[1, 'seed']],
484-
outputs: [toNodeId(1)]
592+
outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')]
485593
})
486594
).toEqual({
487595
inputs: [[1, 'seed']],
@@ -641,6 +749,17 @@ describe('appModeStore', () => {
641749
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
642750
})
643751

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+
644763
it('calls captureCanvasState when input is selected', async () => {
645764
const workflow = createBuilderWorkflow()
646765
workflowStore.activeWorkflow = workflow
@@ -755,6 +874,24 @@ describe('appModeStore', () => {
755874

756875
expect(store.selectedInputs).toEqual([[promptEntity, 'prompt']])
757876
})
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+
})
758895
})
759896

760897
describe('autoEnableVueNodes', () => {
@@ -819,6 +956,47 @@ describe('appModeStore', () => {
819956
expect.anything()
820957
)
821958
})
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+
})
8221000
})
8231001

8241002
describe('legacy selectedInput tuple migration', () => {
@@ -907,6 +1085,121 @@ describe('appModeStore', () => {
9071085
])
9081086
})
9091087

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+
9101203
it('warns and drops a tuple whose target widget no longer resolves', () => {
9111204
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
9121205
vi.mocked(app.rootGraph).id = rootGraphId

0 commit comments

Comments
 (0)