mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
test: subgraph integration contracts and expanded Playwright coverage (#10123)
## Summary Add integration contract tests (unit) and expanded Playwright coverage for subgraph promotion, hydration, navigation, and lifecycle edge behaviors. ## Changes - **What**: 22 unit/integration tests across 9 files covering promotion store sync, widget view lifecycle, input link resolution, pseudo-widget cache, navigation viewport restore, and subgraph operations. 13 Playwright E2E tests covering proxyWidgets hydration stability, promoted source removal cleanup, pseudo-preview unpack/remove, multi-link representative round-trip, nested promotion retarget, and navigation state on workflow switch. - **Helpers**: Added `isPseudoPreviewEntry`, `getPseudoPreviewWidgets`, `getNonPreviewPromotedWidgets` to promotedWidgets helper. Added `SubgraphHelper.getNodeCount()`. ## Review Focus - Test-only PR — no production code changes - Validates existing subgraph behaviors are covered by regression tests before further feature work - Phase 4 (unit/integration contracts) and Phase 5 (Playwright expansion) of the subgraph test coverage plan ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10123-test-subgraph-integration-contracts-and-expanded-Playwright-coverage-3256d73d365081258023e3a763859e00) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
|
||||
"pos": [689.0083557128902, 467.9999999999997],
|
||||
"size": [431.8999938964844, 206.60000610351562],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "text", "2"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Inner Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [330, 367, 120, 40]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [983, 367, 120, 40]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [510, 166],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["11111111111"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [523, 438],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["22222222222"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Outer Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [467, 446, 120, 40]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [932, 446, 120, 40]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
|
||||
"pos": [647, 389],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "text"],
|
||||
["2", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 2.0975,
|
||||
"offset": [-581.4780189305006, -356.3000030517576]
|
||||
},
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.graph` (the root workflow graph). */
|
||||
async getNodeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => window.app!.graph.nodes.length)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
@@ -6,6 +7,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '../utils/litegraphUtils'
|
||||
|
||||
@@ -322,4 +324,93 @@ export class SubgraphHelper {
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async isInSubgraph(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
async exitViaBreadcrumb(): Promise<void> {
|
||||
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
const parentLink = breadcrumb.getByRole('link').first()
|
||||
if (await parentLink.isVisible()) {
|
||||
await parentLink.click()
|
||||
} else {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph
|
||||
if (!graph) return
|
||||
canvas.setGraph(graph.rootGraph)
|
||||
})
|
||||
}
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return graph.nodes.reduce((count, node) => {
|
||||
const proxyWidgets = node.properties?.proxyWidgets
|
||||
if (!Array.isArray(proxyWidgets)) return count
|
||||
|
||||
return (
|
||||
count +
|
||||
proxyWidgets.filter(
|
||||
(entry) =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[1] === 'string' &&
|
||||
entry[1].startsWith('$$')
|
||||
).length
|
||||
)
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
async getNodeCount(): Promise<number> {
|
||||
return this.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,26 @@ export async function getPromotedWidgetCount(
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
return entry[1].startsWith('$$')
|
||||
}
|
||||
|
||||
export async function getPseudoPreviewWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter(isPseudoPreviewEntry)
|
||||
}
|
||||
|
||||
export async function getNonPreviewPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
|
||||
@@ -43,6 +43,31 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
@@ -654,6 +655,28 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
|
||||
@@ -1,160 +1,347 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgetSnapshot,
|
||||
getPromotedWidgets
|
||||
getPromotedWidgets,
|
||||
getPseudoPreviewWidgets,
|
||||
getNonPreviewPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe('Subgraph Lifecycle', { tag: ['@subgraph', '@widget'] }, () => {
|
||||
test('hydrates legacy proxyWidgets deterministically across reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-duplicate-ids'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
const firstSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
|
||||
expect(firstSnapshot.proxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
firstSnapshot.proxyWidgets.every(([nodeId]) => nodeId !== '-1')
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-duplicate-ids'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const secondSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
|
||||
expect(secondSnapshot.proxyWidgets).toEqual(firstSnapshot.proxyWidgets)
|
||||
expect(secondSnapshot.widgetNames).toEqual(firstSnapshot.widgetNames)
|
||||
})
|
||||
|
||||
test('promoted view falls back to disconnected placeholder after source widget removal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const projection = await comfyPage.page.evaluate(() => {
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById('11')
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
)
|
||||
throw new Error('Expected host subgraph node 11')
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
const beforeType = hostNode.widgets?.[0]?.type
|
||||
const proxyWidgets = Array.isArray(hostNode.properties?.proxyWidgets)
|
||||
? hostNode.properties.proxyWidgets.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
: []
|
||||
const firstPromotion = proxyWidgets[0]
|
||||
if (!firstPromotion)
|
||||
throw new Error('Expected at least one promoted widget entry')
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
const [sourceNodeId, sourceWidgetName] = firstPromotion
|
||||
const subgraph = graph.subgraphs.get(hostNode.type)
|
||||
const sourceNode = subgraph?.getNodeById(Number(sourceNodeId))
|
||||
if (!sourceNode?.widgets)
|
||||
throw new Error('Expected promoted source node widget list')
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
sourceNode.widgets = sourceNode.widgets.filter(
|
||||
(widget) => widget.name !== sourceWidgetName
|
||||
)
|
||||
test.describe(
|
||||
'Subgraph Lifecycle Edge Behaviors',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return {
|
||||
beforeType,
|
||||
afterType: hostNode.widgets?.[0]?.type
|
||||
}
|
||||
})
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
expect(projection.beforeType).toBe('customtext')
|
||||
expect(projection.afterType).toBe('button')
|
||||
})
|
||||
|
||||
test('unpacking one preview host keeps remaining pseudo-preview promotions resolvable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeNode8 = await getPromotedWidgets(comfyPage, '8')
|
||||
expect(beforeNode8).toEqual([['6', '$$canvas-image-preview']])
|
||||
|
||||
const cleanupResult = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const invalidPseudoEntries = () => {
|
||||
const invalid: string[] = []
|
||||
for (const node of graph.nodes) {
|
||||
if (
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
)
|
||||
continue
|
||||
|
||||
const subgraph = graph.subgraphs.get(node.type)
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
: []
|
||||
for (const entry of proxyWidgets) {
|
||||
if (entry[1] !== '$$canvas-image-preview') continue
|
||||
|
||||
const sourceNodeId = Number(entry[0])
|
||||
const sourceNode = subgraph?.getNodeById(sourceNodeId)
|
||||
if (!sourceNode) invalid.push(`${node.id}:${entry[0]}`)
|
||||
}
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
return invalid
|
||||
}
|
||||
|
||||
const before = invalidPseudoEntries()
|
||||
const hostNode = graph.getNodeById('7')
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
)
|
||||
throw new Error('Expected preview host subgraph node 7')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
;(
|
||||
graph as unknown as { unpackSubgraph: (node: unknown) => void }
|
||||
).unpackSubgraph(hostNode)
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return {
|
||||
before,
|
||||
after: invalidPseudoEntries(),
|
||||
hasNode7: Boolean(graph.getNodeById('7')),
|
||||
hasNode8: Boolean(graph.getNodeById('8'))
|
||||
}
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
const serialized1 = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized1 as ComfyWorkflowJSON
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
const serialized2 = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
|
||||
serialized2 as ComfyWorkflowJSON
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
expect(cleanupResult.before).toEqual([])
|
||||
expect(cleanupResult.after).toEqual([])
|
||||
expect(cleanupResult.hasNode7).toBe(false)
|
||||
expect(cleanupResult.hasNode8).toBe(true)
|
||||
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
const afterNode8 = await getPromotedWidgets(comfyPage, '8')
|
||||
expect(afterNode8).toEqual([['6', '$$canvas-image-preview']])
|
||||
})
|
||||
})
|
||||
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const hostNode = window.app!.canvas.graph!.getNodeById('11')
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return {
|
||||
proxyWidgetCount: Array.isArray(proxyWidgets)
|
||||
? proxyWidgets.length
|
||||
: 0,
|
||||
firstWidgetType: hostNode?.widgets?.[0]?.type
|
||||
}
|
||||
})
|
||||
})
|
||||
.toEqual({
|
||||
proxyWidgetCount: initialWidgets.length,
|
||||
firstWidgetType: 'button'
|
||||
})
|
||||
})
|
||||
|
||||
test('Promoted widget disappears from DOM after interior node deletion', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Non-preview widgets coexist with pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
|
||||
comfyPage,
|
||||
'5'
|
||||
)
|
||||
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return graph.nodes.filter((n) => n.isSubgraphNode()).length
|
||||
})
|
||||
expect(subgraphNodeCount).toBe(0)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.canvas.graph!.getNodeById('5')
|
||||
})
|
||||
expect(nodeExists).toBe(false)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
|
||||
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
|
||||
expect(firstNodeBefore.length).toBeGreaterThan(0)
|
||||
expect(secondNodeBefore.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('7')
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.graph!.getNodeById('7')
|
||||
})
|
||||
expect(firstNodeExists).toBe(false)
|
||||
|
||||
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
expect(secondNodeAfter).toEqual(secondNodeBefore)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
141
browser_tests/tests/subgraphNestedDuplicateWidgetNames.spec.ts
Normal file
141
browser_tests/tests/subgraphNestedDuplicateWidgetNames.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
expect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
|
||||
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
|
||||
expect(textWidgets).toHaveLength(2)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
expect(values).toContain('11111111111')
|
||||
expect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await expect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await expect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -73,5 +73,59 @@ test.describe(
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
return subgraphNode ? String(subgraphNode.id) : null
|
||||
})
|
||||
expect(subgraphNodeId).not.toBeNull()
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId!)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
|
||||
subgraphNodeId!
|
||||
)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const inSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
expect(inSubgraph).toBe(true)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
@@ -12,25 +11,6 @@ import {
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Check whether we're currently in a subgraph.
|
||||
*/
|
||||
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
})
|
||||
}
|
||||
|
||||
async function exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas.graph) return
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
@@ -190,7 +170,7 @@ test.describe(
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
|
||||
@@ -262,7 +242,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
@@ -296,7 +276,7 @@ test.describe(
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
@@ -342,7 +322,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -377,7 +357,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -408,7 +388,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -564,6 +544,30 @@ test.describe(
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
const serialized = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.serialize()
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
|
||||
return window.app!.loadGraphData(workflow)
|
||||
}, serialized as ComfyWorkflowJSON)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -721,6 +725,44 @@ test.describe(
|
||||
expect(nodeExists).toBe(false)
|
||||
})
|
||||
|
||||
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(initialNames.length).toBeGreaterThan(0)
|
||||
|
||||
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await outerSubgraph.navigateIntoSubgraph()
|
||||
|
||||
const removedSlotName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
return graph.inputs?.[0]?.name ?? null
|
||||
})
|
||||
expect(removedSlotName).not.toBeNull()
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
const expectedNames = [...initialNames]
|
||||
const removedIndex = expectedNames.indexOf(removedSlotName!)
|
||||
expect(removedIndex).toBeGreaterThanOrEqual(0)
|
||||
expectedNames.splice(removedIndex, 1)
|
||||
|
||||
expect(finalNames).toEqual(expectedNames)
|
||||
})
|
||||
|
||||
test('Removing I/O slot removes associated promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -743,7 +785,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await exitSubgraphToParent(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Widget count should be reduced
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -78,19 +79,22 @@ function isWidgetShownOnParents(
|
||||
): boolean {
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
const sourceNodeId = getSourceNodeId(widget)
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: sourceNodeId
|
||||
})
|
||||
}
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
String(widgetNode.id),
|
||||
widget.name
|
||||
)
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: String(widgetNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getSourceNodeId,
|
||||
getWidgetName
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
@@ -84,15 +88,27 @@ const widgetsList = computed((): NodeWidgetsList => {
|
||||
const { widgets = [] } = node
|
||||
|
||||
const result: NodeWidgetsList = []
|
||||
for (const { interiorNodeId, widgetName } of entries) {
|
||||
for (const {
|
||||
sourceNodeId: entryNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
} of entries) {
|
||||
const widget = widgets.find((w) => {
|
||||
if (isPromotedWidgetView(w)) {
|
||||
if (
|
||||
String(w.sourceNodeId) !== entryNodeId ||
|
||||
w.sourceWidgetName !== sourceWidgetName
|
||||
)
|
||||
return false
|
||||
|
||||
if (!disambiguatingSourceNodeId) return true
|
||||
|
||||
return (
|
||||
String(w.sourceNodeId) === interiorNodeId &&
|
||||
w.sourceWidgetName === widgetName
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
return w.name === widgetName
|
||||
return w.name === sourceWidgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
@@ -113,12 +129,11 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
return allInteriorWidgets.filter(
|
||||
({ node: interiorNode, widget }) =>
|
||||
!promotionStore.isPromoted(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(interiorNode.id),
|
||||
widget.name
|
||||
)
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(widget)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
@@ -93,8 +95,11 @@ describe('WidgetActions', () => {
|
||||
function createMockNode(): LGraphNode {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'TestNode'
|
||||
} as LGraphNode
|
||||
type: 'TestNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [200, 100]
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
||||
@@ -206,4 +211,66 @@ describe('WidgetActions', () => {
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
|
||||
})
|
||||
|
||||
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
const parentSubgraphNode = {
|
||||
id: 4,
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [300, 150]
|
||||
} as unknown as SubgraphNode
|
||||
const node = {
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' }
|
||||
} as unknown as LGraphNode
|
||||
const widget = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
value: 'value',
|
||||
label: 'Text',
|
||||
options: {},
|
||||
y: 0,
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
} as IBaseWidget
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const wrapper = mount(WidgetActions, {
|
||||
props: {
|
||||
widget,
|
||||
node,
|
||||
label: 'Text',
|
||||
parents: [parentSubgraphNode],
|
||||
isShownOnParents: true
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
const hideButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Hide input'))
|
||||
expect(hideButton).toBeDefined()
|
||||
await hideButton?.trigger('click')
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,11 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import {
|
||||
demoteWidget,
|
||||
getSourceNodeId,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -41,6 +43,7 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -73,26 +76,29 @@ function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const sourceWidget = resolvePromotedWidgetSource(node, widget)
|
||||
if (!sourceWidget) {
|
||||
console.error('Could not resolve source widget for promoted widget')
|
||||
return
|
||||
}
|
||||
const disambiguatingSourceNodeId = getSourceNodeId(widget)
|
||||
|
||||
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
|
||||
for (const parent of parents) {
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId:
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id),
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}
|
||||
promotionStore.demote(parent.rootGraph.id, parent.id, source)
|
||||
parent.computeSize(parent.size)
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
// For regular widgets (not yet promoted), use them directly
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleShowInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
promoteWidget(node, widget, parents)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
@@ -49,19 +51,29 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
if (!node) return []
|
||||
|
||||
return promotionEntries.value.flatMap(
|
||||
({ interiorNodeId, widgetName }): WidgetItem[] => {
|
||||
if (interiorNodeId === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === widgetName)
|
||||
({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}): WidgetItem[] => {
|
||||
if (sourceNodeId === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
|
||||
if (!widget) return []
|
||||
return [
|
||||
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
|
||||
]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
|
||||
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
|
||||
if (!wNode) return []
|
||||
const widget = getPromotableWidgets(wNode).find(
|
||||
(w) => w.name === widgetName
|
||||
)
|
||||
const widget = getPromotableWidgets(wNode).find((w) => {
|
||||
if (w.name !== sourceWidgetName) return false
|
||||
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
|
||||
return (
|
||||
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
return true
|
||||
})
|
||||
if (!widget) return []
|
||||
return [[wNode, widget]]
|
||||
}
|
||||
@@ -76,11 +88,16 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
promotionStore.setPromotions(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
value.map(([n, w]) => ({
|
||||
interiorNodeId: String(n.id),
|
||||
widgetName: getWidgetName(w)
|
||||
}))
|
||||
value.map(([n, w]) => {
|
||||
const sid = getSourceNodeId(w)
|
||||
return {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
...(sid && { disambiguatingSourceNodeId: sid })
|
||||
}
|
||||
})
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -103,12 +120,11 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
if (!node) return []
|
||||
return interiorWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
!promotionStore.isPromoted(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(n.id),
|
||||
w.name
|
||||
)
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(w)
|
||||
})
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
@@ -137,8 +153,20 @@ const filteredActive = computed<WidgetItem[]>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function refreshPromotedWidgetRendering() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
|
||||
node.computeSize(node.size)
|
||||
node.setDirtyCanvas(true, true)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function toKey(item: WidgetItem) {
|
||||
return `${item[0].id}: ${item[1].name}`
|
||||
const sid = getSourceNodeId(item[1])
|
||||
return sid
|
||||
? `${item[0].id}: ${item[1].name}:${sid}`
|
||||
: `${item[0].id}: ${item[1].name}`
|
||||
}
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return getPromotableWidgets(n).map((w) => [n, w])
|
||||
@@ -147,49 +175,26 @@ function demote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
demoteWidget(node, widget, [subgraphNode])
|
||||
promotionStore.demote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
getWidgetName(widget)
|
||||
)
|
||||
}
|
||||
function promote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
promotionStore.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
widget.name
|
||||
)
|
||||
}
|
||||
function showAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
for (const [n, w] of filteredCandidates.value) {
|
||||
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
|
||||
for (const item of filteredCandidates.value) {
|
||||
promote(item)
|
||||
}
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
for (const [n, w] of filteredActive.value) {
|
||||
if (String(n.id) === '-1') continue
|
||||
promotionStore.demote(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(n.id),
|
||||
getWidgetName(w)
|
||||
)
|
||||
for (const item of filteredActive.value) {
|
||||
if (String(item[0].id) === '-1') continue
|
||||
demote(item)
|
||||
}
|
||||
}
|
||||
function showRecommended() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
for (const [n, w] of recommendedWidgets.value) {
|
||||
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
|
||||
for (const item of recommendedWidgets.value) {
|
||||
promote(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -413,12 +413,10 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const vueNode = vueNodeData.get(String(subgraphNode.id))
|
||||
@@ -500,12 +498,10 @@ describe('Nested promoted widget mapping', () => {
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(independentNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
@@ -523,6 +519,70 @@ describe('Nested promoted widget mapping', () => {
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
|
||||
const innerSubgraph = createTestSubgraph()
|
||||
const firstTextNode = new LGraphNode('FirstTextNode')
|
||||
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
|
||||
innerSubgraph.add(firstTextNode)
|
||||
|
||||
const secondTextNode = new LGraphNode('SecondTextNode')
|
||||
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
|
||||
innerSubgraph.add(secondTextNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 3,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
|
||||
const graph = outerSubgraphNode.graph as LGraph
|
||||
graph.add(outerSubgraphNode)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
innerSubgraphNode.rootGraph.id,
|
||||
innerSubgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
||||
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
||||
]
|
||||
)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
outerSubgraphNode.rootGraph.id,
|
||||
outerSubgraphNode.id,
|
||||
[
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(firstTextNode.id)
|
||||
},
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(secondTextNode.id)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'text'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
||||
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
@@ -224,19 +225,21 @@ function safeWidgetMapper(
|
||||
function resolvePromotedSourceByInputName(inputName: string): {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
} | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
sourceWidgetName: resolvedTarget.widgetName,
|
||||
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
|
||||
displayName: string
|
||||
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
|
||||
promotedSource: PromotedWidgetSource | null
|
||||
} {
|
||||
if (!isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
@@ -250,7 +253,8 @@ function safeWidgetMapper(
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
@@ -297,7 +301,8 @@ function safeWidgetMapper(
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
promotedSource.sourceWidgetName,
|
||||
promotedSource.disambiguatingSourceNodeId
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
@@ -310,7 +315,11 @@ function safeWidgetMapper(
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
? String(
|
||||
sourceNode?.id ??
|
||||
promotedSource?.disambiguatingSourceNodeId ??
|
||||
promotedSource?.sourceNodeId
|
||||
)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishSubgraph: vi.fn(),
|
||||
@@ -46,6 +46,10 @@ function createSubgraphNode(): SubgraphNode {
|
||||
return node
|
||||
}
|
||||
|
||||
function createRegularNode(): LGraphNode {
|
||||
return new LGraphNode('testnode')
|
||||
}
|
||||
|
||||
describe('useSubgraphOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -87,4 +91,16 @@ describe('useSubgraphOperations', () => {
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary does not call publishSubgraph when selected item is not a SubgraphNode', async () => {
|
||||
mocks.selectedItems = [createRegularNode()]
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,8 +112,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'seed'
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -126,8 +125,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
@@ -137,8 +135,8 @@ describe(usePromotedPreviews, () => {
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
@@ -151,8 +149,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
@@ -168,8 +165,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
@@ -192,14 +188,12 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'20',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10, 20])
|
||||
@@ -221,8 +215,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
@@ -232,8 +225,8 @@ describe(usePromotedPreviews, () => {
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -246,8 +239,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -259,8 +251,8 @@ describe(usePromotedPreviews, () => {
|
||||
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -273,8 +265,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -286,8 +277,7 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'99',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
@@ -300,14 +290,12 @@ describe(usePromotedPreviews, () => {
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'seed'
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=img.png']
|
||||
|
||||
@@ -8,8 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
@@ -30,13 +30,15 @@ export function usePromotedPreviews(
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
|
||||
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
const pseudoEntries = entries.filter((e) => e.widgetName.startsWith('$$'))
|
||||
const pseudoEntries = entries.filter((e) =>
|
||||
e.sourceWidgetName.startsWith('$$')
|
||||
)
|
||||
if (!pseudoEntries.length) return []
|
||||
|
||||
const previews: PromotedPreview[] = []
|
||||
|
||||
for (const entry of pseudoEntries) {
|
||||
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
|
||||
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
|
||||
if (!interiorNode) continue
|
||||
|
||||
// Read from both reactive refs to establish Vue dependency
|
||||
@@ -45,7 +47,7 @@ export function usePromotedPreviews(
|
||||
// access the computed would never re-evaluate.
|
||||
const locatorId = createNodeLocatorId(
|
||||
node.subgraph.id,
|
||||
entry.interiorNodeId
|
||||
entry.sourceNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
@@ -63,8 +65,8 @@ export function usePromotedPreviews(
|
||||
: 'image'
|
||||
|
||||
previews.push({
|
||||
interiorNodeId: entry.interiorNodeId,
|
||||
widgetName: entry.widgetName,
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
type,
|
||||
urls
|
||||
})
|
||||
|
||||
@@ -2,15 +2,28 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export type ResolvedPromotedWidget = {
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
/**
|
||||
* The original leaf-level source node ID, used to distinguish promoted
|
||||
* widgets with the same name on the same intermediate node. Unlike
|
||||
* `sourceNodeId` (the direct interior node), this traces to the deepest
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState,
|
||||
@@ -78,9 +79,9 @@ function setPromotions(
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
entries.map(([interiorNodeId, widgetName]) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
entries.map(([sourceNodeId, sourceWidgetName]) => ({
|
||||
sourceNodeId,
|
||||
sourceWidgetName
|
||||
}))
|
||||
)
|
||||
}
|
||||
@@ -114,6 +115,21 @@ describe(createPromotedWidgetView, () => {
|
||||
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
|
||||
expect(view.sourceNodeId).toBe('42')
|
||||
expect(view.sourceWidgetName).toBe('myWidget')
|
||||
expect(view.disambiguatingSourceNodeId).toBeUndefined()
|
||||
})
|
||||
|
||||
test('exposes disambiguatingSourceNodeId when provided', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'42',
|
||||
'myWidget',
|
||||
undefined,
|
||||
'99'
|
||||
)
|
||||
expect(view.sourceNodeId).toBe('42')
|
||||
expect(view.sourceWidgetName).toBe('myWidget')
|
||||
expect(view.disambiguatingSourceNodeId).toBe('99')
|
||||
})
|
||||
|
||||
test('name defaults to widgetName when no displayName given', () => {
|
||||
@@ -454,8 +470,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'picker'
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'picker'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -497,8 +513,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
expect(promotions).toHaveLength(1)
|
||||
expect(promotions[0]).toStrictEqual({
|
||||
interiorNodeId: String(secondNode.id),
|
||||
widgetName: 'picker'
|
||||
sourceNodeId: String(secondNode.id),
|
||||
sourceWidgetName: 'picker'
|
||||
})
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('b')
|
||||
@@ -591,18 +607,14 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(promotedNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(linkedNodeA.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(promotedNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(linkedNodeA.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
@@ -651,12 +663,10 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(independentNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const linkedView = widgets.find(
|
||||
@@ -733,8 +743,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetB' }
|
||||
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -764,8 +774,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetA' }
|
||||
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -823,8 +833,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
|
||||
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
|
||||
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' },
|
||||
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'string_a' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -868,8 +878,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: linkedEntry[0],
|
||||
widgetName: linkedEntry[1]
|
||||
sourceNodeId: linkedEntry[0],
|
||||
sourceWidgetName: linkedEntry[1]
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -925,8 +935,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(restoredPromotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(activeAliasNode.id),
|
||||
widgetName: 'string_a'
|
||||
sourceNodeId: String(activeAliasNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1113,8 +1123,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNodes[0].id),
|
||||
widgetName: 'stringWidget'
|
||||
sourceNodeId: String(innerNodes[0].id),
|
||||
sourceWidgetName: 'stringWidget'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1142,8 +1152,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(restoredEntries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1280,8 +1290,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'string_a' },
|
||||
{ interiorNodeId: '19', widgetName: 'string_a' }
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'string_a' },
|
||||
{ sourceNodeId: '19', sourceWidgetName: 'string_a' }
|
||||
])
|
||||
|
||||
const linkedView = hostWidgets[0]
|
||||
@@ -1416,8 +1426,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(hydratedEntries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1435,12 +1445,12 @@ describe('widgets getter caching', () => {
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
@@ -1465,12 +1475,12 @@ describe('widgets getter caching', () => {
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
@@ -1638,7 +1648,7 @@ describe('promote/demote cycle', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1709,6 +1719,197 @@ describe('disconnected state', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function createThreeLevelNestedSubgraph() {
|
||||
// Level C (innermost): concrete widget
|
||||
const subgraphC = createTestSubgraph({
|
||||
inputs: [{ name: 'c_input', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('c_input', '*')
|
||||
const concreteWidget = concreteNode.addWidget(
|
||||
'number',
|
||||
'c_input',
|
||||
100,
|
||||
() => {}
|
||||
)
|
||||
concreteInput.widget = { name: 'c_input' }
|
||||
subgraphC.add(concreteNode)
|
||||
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 501 })
|
||||
|
||||
// Level B (middle): wraps C
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'b_input', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeC)
|
||||
subgraphNodeC._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 502 })
|
||||
|
||||
// Level A (outermost): wraps B
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'a_input', type: '*' }]
|
||||
})
|
||||
subgraphA.add(subgraphNodeB)
|
||||
subgraphNodeB._internalConfigureAfterSlots()
|
||||
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 503 })
|
||||
return { concreteNode, concreteWidget, subgraphNodeA }
|
||||
}
|
||||
|
||||
describe('three-level nested value propagation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('value set at outermost level propagates to concrete widget', () => {
|
||||
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(100)
|
||||
|
||||
subgraphNodeA.widgets[0].value = 200
|
||||
expect(concreteNode.widgets![0].value).toBe(200)
|
||||
})
|
||||
|
||||
test('type resolves correctly through all three layers', () => {
|
||||
const { subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
})
|
||||
|
||||
test('concrete value change is visible at the outermost level', () => {
|
||||
const { concreteWidget, subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
concreteWidget.value = 999
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(999)
|
||||
})
|
||||
|
||||
test('nested duplicate-name promotions resolve and update independently by disambiguating source node id', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const firstTextNode = new LGraphNode('FirstTextNode')
|
||||
firstTextNode.addWidget('text', 'text', '11111111111', () => {})
|
||||
innerSubgraph.add(firstTextNode)
|
||||
|
||||
const secondTextNode = new LGraphNode('SecondTextNode')
|
||||
secondTextNode.addWidget('text', 'text', '22222222222', () => {})
|
||||
innerSubgraph.add(secondTextNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 3,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
|
||||
id: 4,
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(outerSubgraphNode)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
innerSubgraphNode.rootGraph.id,
|
||||
innerSubgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
||||
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
||||
]
|
||||
)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
outerSubgraphNode.rootGraph.id,
|
||||
outerSubgraphNode.id,
|
||||
[
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(firstTextNode.id)
|
||||
},
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(secondTextNode.id)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(outerSubgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(
|
||||
widgets.map((widget) => widget.disambiguatingSourceNodeId)
|
||||
).toStrictEqual([String(firstTextNode.id), String(secondTextNode.id)])
|
||||
expect(widgets.map((widget) => widget.value)).toStrictEqual([
|
||||
'11111111111',
|
||||
'22222222222'
|
||||
])
|
||||
|
||||
widgets[1].value = 'updated-second'
|
||||
|
||||
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
|
||||
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
|
||||
expect(widgets[0].value).toBe('11111111111')
|
||||
expect(widgets[1].value).toBe('updated-second')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-link representative determinism for input-based promotion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('first link is consistently chosen as representative for reads and writes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('shared', '*')
|
||||
firstNode.addWidget('text', 'shared', 'first-val', () => {})
|
||||
firstInput.widget = { name: 'shared' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('shared', '*')
|
||||
secondNode.addWidget('text', 'shared', 'second-val', () => {})
|
||||
secondInput.widget = { name: 'shared' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const thirdNode = new LGraphNode('ThirdNode')
|
||||
const thirdInput = thirdNode.addInput('shared', '*')
|
||||
thirdNode.addWidget('text', 'shared', 'third-val', () => {})
|
||||
thirdInput.widget = { name: 'shared' }
|
||||
subgraph.add(thirdNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[0].connect(secondInput, secondNode)
|
||||
subgraph.inputNode.slots[0].connect(thirdInput, thirdNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(1)
|
||||
expect(widgets[0].sourceNodeId).toBe(String(firstNode.id))
|
||||
|
||||
// Read returns the first link's value
|
||||
expect(widgets[0].value).toBe('first-val')
|
||||
|
||||
// Write propagates to all linked nodes
|
||||
widgets[0].value = 'updated'
|
||||
expect(firstNode.widgets![0].value).toBe('updated')
|
||||
expect(secondNode.widgets![0].value).toBe('updated')
|
||||
expect(thirdNode.widgets![0].value).toBe('updated')
|
||||
|
||||
// Repeated reads are still deterministic
|
||||
expect(widgets[0].value).toBe('updated')
|
||||
})
|
||||
})
|
||||
|
||||
function createFakeCanvasContext() {
|
||||
return new Proxy({} as CanvasRenderingContext2D, {
|
||||
get: () => vi.fn(() => ({ width: 10 }))
|
||||
@@ -2057,12 +2258,12 @@ describe('promoted combo rendering', () => {
|
||||
)
|
||||
|
||||
expect(promotions).toContainEqual({
|
||||
interiorNodeId: String(subgraphNodeA.id),
|
||||
widgetName: 'lora_name'
|
||||
sourceNodeId: String(subgraphNodeA.id),
|
||||
sourceWidgetName: 'lora_name'
|
||||
})
|
||||
expect(promotions).not.toContainEqual({
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'lora_name'
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'lora_name'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -49,9 +49,16 @@ export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string
|
||||
displayName?: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
class PromotedWidgetView implements IPromotedWidgetView {
|
||||
@@ -80,7 +87,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string
|
||||
private readonly displayName?: string,
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
@@ -287,7 +295,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -301,7 +310,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
@@ -341,7 +351,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName &&
|
||||
boundWidget.disambiguatingSourceNodeId ===
|
||||
this.disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isPreviewPseudoWidget,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected
|
||||
@@ -118,9 +119,12 @@ describe('pruneDisconnected', () => {
|
||||
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
|
||||
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
|
||||
{ interiorNodeId: '9999', widgetName: 'missing-node' }
|
||||
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
|
||||
{
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'missing-widget'
|
||||
},
|
||||
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
|
||||
])
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
@@ -129,7 +133,9 @@ describe('pruneDisconnected', () => {
|
||||
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
|
||||
).toEqual([
|
||||
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
|
||||
])
|
||||
expect(warnSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -143,8 +149,8 @@ describe('pruneDisconnected', () => {
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{
|
||||
interiorNodeId: String(interiorNode.id),
|
||||
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
|
||||
@@ -154,8 +160,8 @@ describe('pruneDisconnected', () => {
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([
|
||||
{
|
||||
interiorNodeId: String(interiorNode.id),
|
||||
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -255,12 +261,10 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(glslNode.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
).toBe(true)
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -280,12 +284,52 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(glslNode.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all enabled widgets are already promoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores computed-disabled widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
widget.computedDisabled = true
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
@@ -26,6 +27,30 @@ export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
}
|
||||
|
||||
export function getSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
if (!isPromotedWidgetView(w)) return undefined
|
||||
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
|
||||
}
|
||||
|
||||
function toPromotionSource(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetSource {
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
|
||||
for (const parent of parents) {
|
||||
parent.computeSize(parent.size)
|
||||
parent.setDirtyCanvas(true, true)
|
||||
}
|
||||
useCanvasStore().canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
@@ -51,16 +76,14 @@ export function promoteWidget(
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const nodeId = String(
|
||||
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const source = toPromotionSource(node, widget)
|
||||
for (const parent of parents) {
|
||||
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
store.promote(parent.rootGraph.id, parent.id, source)
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Promoted widget "${widgetName}" on node ${node.id}`,
|
||||
message: `Promoted widget "${source.sourceWidgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
@@ -71,16 +94,14 @@ export function demoteWidget(
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const nodeId = String(
|
||||
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const source = toPromotionSource(node, widget)
|
||||
for (const parent of parents) {
|
||||
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
store.demote(parent.rootGraph.id, parent.id, source)
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Demoted widget "${widgetName}" on node ${node.id}`,
|
||||
message: `Demoted widget "${source.sourceWidgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
@@ -110,10 +131,9 @@ export function addWidgetPromotionOptions(
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const parents = getParentNodes()
|
||||
const nodeId = String(node.id)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const source = toPromotionSource(node, widget)
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
options.unshift({
|
||||
@@ -147,10 +167,9 @@ export function tryToggleWidgetPromotion() {
|
||||
const parents = getParentNodes()
|
||||
if (!parents.length || !widget) return
|
||||
const store = usePromotionStore()
|
||||
const nodeId = String(node.id)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const source = toPromotionSource(node, widget)
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
@@ -219,12 +238,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const widget = node.widgets?.find(isPreviewPseudoWidget)
|
||||
if (!widget) return
|
||||
if (
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
widget.name
|
||||
)
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
)
|
||||
return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
@@ -242,20 +259,18 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
// includes this node and onDrawBackground can call updatePreviews on it
|
||||
// once execution outputs arrive.
|
||||
if (supportsVirtualCanvasImagePreview(node)) {
|
||||
const canvasSource: PromotedWidgetSource = {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
if (
|
||||
!store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
canvasSource
|
||||
)
|
||||
) {
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -271,8 +286,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(n.id),
|
||||
getWidgetName(w)
|
||||
toPromotionSource(n, w)
|
||||
)
|
||||
}
|
||||
subgraphNode.computeSize(subgraphNode.size)
|
||||
@@ -285,17 +299,16 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
|
||||
[]
|
||||
const removedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
const validEntries = entries.filter((entry) => {
|
||||
const node = subgraph.getNodeById(entry.interiorNodeId)
|
||||
const node = subgraph.getNodeById(entry.sourceNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(entry)
|
||||
return false
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === entry.widgetName
|
||||
(iw) => iw.name === entry.sourceWidgetName
|
||||
)
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(entry)
|
||||
@@ -315,9 +328,26 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
||||
refreshPromotedWidgetRendering([subgraphNode])
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
const promotionStore = usePromotionStore()
|
||||
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
(interiorNode.widgets ?? []).some(
|
||||
(widget) =>
|
||||
!widget.computedDisabled &&
|
||||
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type PromotedWidgetStub = Pick<
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
@@ -51,7 +52,8 @@ function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode
|
||||
node?: SubgraphNode,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
@@ -61,6 +63,7 @@ function createPromotedWidget(
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
@@ -94,6 +97,27 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
|
||||
const host = createHostNode(100)
|
||||
const sourceNode = addNodeToHost(host, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
|
||||
]
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(sourceNode.id),
|
||||
'text',
|
||||
'2'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
|
||||
@@ -20,7 +20,8 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
|
||||
function traversePromotedWidgetChain(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
const visited = new Set<string>()
|
||||
const hostUidByObject = new WeakMap<SubgraphNode, number>()
|
||||
@@ -28,6 +29,7 @@ function traversePromotedWidgetChain(
|
||||
let currentHost = hostNode
|
||||
let currentNodeId = nodeId
|
||||
let currentWidgetName = widgetName
|
||||
let currentSourceNodeId = sourceNodeId
|
||||
|
||||
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
|
||||
let hostUid = hostUidByObject.get(currentHost)
|
||||
@@ -37,7 +39,7 @@ function traversePromotedWidgetChain(
|
||||
hostUidByObject.set(currentHost, hostUid)
|
||||
}
|
||||
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
|
||||
if (visited.has(key)) {
|
||||
return { status: 'failure', failure: 'cycle' }
|
||||
}
|
||||
@@ -48,8 +50,10 @@ function traversePromotedWidgetChain(
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
const sourceWidget = findWidgetByIdentity(
|
||||
sourceNode.widgets,
|
||||
currentWidgetName,
|
||||
currentSourceNodeId
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
@@ -69,22 +73,42 @@ function traversePromotedWidgetChain(
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
currentSourceNodeId = undefined
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
function findWidgetByIdentity(
|
||||
widgets: IBaseWidget[] | undefined,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): IBaseWidget | undefined {
|
||||
if (!widgets) return undefined
|
||||
|
||||
if (sourceNodeId) {
|
||||
return widgets.find(
|
||||
(entry) =>
|
||||
isPromotedWidgetView(entry) &&
|
||||
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
|
||||
sourceNodeId &&
|
||||
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
|
||||
)
|
||||
}
|
||||
|
||||
return widgets.find((entry) => entry.name === widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = node.widgets?.find(
|
||||
(entry: IBaseWidget) => entry.name === widgetName
|
||||
)
|
||||
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
@@ -93,10 +117,11 @@ export function resolvePromotedWidgetAtHost(
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
if (!hostNode.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'invalid-host' }
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ export function resolvePromotedWidgetSource(
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
widget.sourceWidgetName,
|
||||
widget.disambiguatingSourceNodeId
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
|
||||
@@ -161,4 +161,33 @@ describe('resolveSubgraphInputLink', () => {
|
||||
expect(result).toBe('ok')
|
||||
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('returns first link result with 3+ links connected', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'third_input', 'thirdWidget')
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'prompt',
|
||||
({ targetInput }) => targetInput.name
|
||||
)
|
||||
|
||||
expect(result).toBe('first_input')
|
||||
})
|
||||
|
||||
test('returns undefined when all links fail to resolve', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'prompt',
|
||||
() => undefined
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -158,4 +158,93 @@ describe('resolveSubgraphInputTarget', () => {
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves widget and non-widget inputs on the same nested SubgraphNode independently', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'width', type: '*' },
|
||||
{ name: 'audio', type: '*' }
|
||||
]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 820
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const widthInput = innerSubgraphNode.addInput('width', '*')
|
||||
innerSubgraphNode.addWidget('number', 'width', 0, () => undefined)
|
||||
widthInput.widget = { name: 'width' }
|
||||
|
||||
const audioInput = innerSubgraphNode.addInput('audio', '*')
|
||||
|
||||
const widthSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'width'
|
||||
)!
|
||||
const audioSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'audio'
|
||||
)!
|
||||
widthSlot.connect(widthInput, innerSubgraphNode)
|
||||
audioSlot.connect(audioInput, innerSubgraphNode)
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '820',
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('three-level nesting returns immediate child target, not deepest', () => {
|
||||
// outer → middle → inner (concrete)
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
concreteNode.id = 900
|
||||
const concreteInput = concreteNode.addInput('seed_input', '*')
|
||||
concreteNode.addWidget('number', 'seed', 0, () => undefined)
|
||||
concreteInput.widget = { name: 'seed' }
|
||||
innerSubgraph.add(concreteNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 901
|
||||
})
|
||||
middleSubgraph.add(innerSubgraphNode)
|
||||
const middleInput = innerSubgraphNode.addInput('seed', '*')
|
||||
innerSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
|
||||
middleInput.widget = { name: 'seed' }
|
||||
middleSubgraph.inputNode.slots[0].connect(middleInput, innerSubgraphNode)
|
||||
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'seed'
|
||||
])
|
||||
const middleSubgraphNode = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 902
|
||||
})
|
||||
outerSubgraph.add(middleSubgraphNode)
|
||||
const outerInput = middleSubgraphNode.addInput('seed', '*')
|
||||
middleSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
|
||||
outerInput.widget = { name: 'seed' }
|
||||
outerSubgraph.inputNode.slots[0].connect(outerInput, middleSubgraphNode)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
|
||||
|
||||
// Should return the immediate child (middle), not the deepest (concrete)
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '902',
|
||||
widgetName: 'seed'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
|
||||
|
||||
type ResolvedSubgraphInputTarget = {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId?: string
|
||||
}
|
||||
|
||||
export function resolveSubgraphInputTarget(
|
||||
@@ -19,6 +21,16 @@ export function resolveSubgraphInputTarget(
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
if (isPromotedWidgetView(targetWidget)) {
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.sourceWidgetName,
|
||||
sourceNodeId:
|
||||
targetWidget.disambiguatingSourceNodeId ??
|
||||
targetWidget.sourceNodeId
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
@@ -61,7 +61,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
@@ -72,8 +72,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' },
|
||||
{ interiorNodeId: innerIds[1], widgetName: 'stringWidget' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
|
||||
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
|
||||
]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
@@ -88,8 +88,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetA')
|
||||
@@ -97,8 +97,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
// Reorder
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
|
||||
])
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
@@ -109,7 +109,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
@@ -124,7 +124,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
@@ -143,7 +143,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
|
||||
@@ -164,24 +164,20 @@ describe('Subgraph proxyWidgets', () => {
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
// Promote once
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
innerIds[0],
|
||||
'stringWidget'
|
||||
)
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
innerIds[0],
|
||||
'stringWidget'
|
||||
)
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
@@ -189,7 +185,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -199,8 +195,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
|
||||
@@ -211,10 +207,12 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }])
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
test('removeWidgetByName removes from promotion list', () => {
|
||||
test('removeWidget removes from promotion list', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
@@ -222,12 +220,13 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
subgraphNode.removeWidgetByName('widgetA')
|
||||
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
|
||||
subgraphNode.removeWidget(widgetA)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
@@ -239,7 +238,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
@@ -260,7 +259,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
|
||||
@@ -279,8 +278,8 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
|
||||
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
@@ -291,4 +290,119 @@ describe('Subgraph proxyWidgets', () => {
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
})
|
||||
|
||||
test('multi-link representative is deterministic across repeated reads', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared_input', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
subgraphNode.graph!.add(subgraphNode)
|
||||
|
||||
const nodeA = new LGraphNode('NodeA')
|
||||
const inputA = nodeA.addInput('shared_input', '*')
|
||||
nodeA.addWidget('text', 'shared_input', 'first', () => {})
|
||||
inputA.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('NodeB')
|
||||
const inputB = nodeB.addInput('shared_input', '*')
|
||||
nodeB.addWidget('text', 'shared_input', 'second', () => {})
|
||||
inputB.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeB)
|
||||
|
||||
const nodeC = new LGraphNode('NodeC')
|
||||
const inputC = nodeC.addInput('shared_input', '*')
|
||||
nodeC.addWidget('text', 'shared_input', 'third', () => {})
|
||||
inputC.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeC)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(inputA, nodeA)
|
||||
subgraph.inputNode.slots[0].connect(inputB, nodeB)
|
||||
subgraph.inputNode.slots[0].connect(inputC, nodeC)
|
||||
|
||||
const firstRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const secondRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const thirdRead = subgraphNode.widgets.map((w) => w.value)
|
||||
|
||||
expect(firstRead).toStrictEqual(secondRead)
|
||||
expect(secondRead).toStrictEqual(thirdRead)
|
||||
expect(subgraphNode.widgets[0].value).toBe('first')
|
||||
})
|
||||
|
||||
test('3-level nested promotion resolves concrete widget type and value', () => {
|
||||
usePromotionStore()
|
||||
|
||||
// Level C: innermost subgraph with a concrete widget
|
||||
const subgraphC = createTestSubgraph({
|
||||
inputs: [{ name: 'deep_input', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('deep_input', '*')
|
||||
concreteNode.addWidget('number', 'deep_input', 42, () => {})
|
||||
concreteInput.widget = { name: 'deep_input' }
|
||||
subgraphC.add(concreteNode)
|
||||
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
|
||||
|
||||
// Level B: middle subgraph containing C
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'mid_input', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeC)
|
||||
subgraphNodeC._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
|
||||
|
||||
// Level A: outermost subgraph containing B
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'outer_input', type: '*' }]
|
||||
})
|
||||
subgraphA.add(subgraphNodeB)
|
||||
subgraphNodeB._internalConfigureAfterSlots()
|
||||
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
|
||||
|
||||
// Outermost promoted widget should resolve through all 3 levels
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(42)
|
||||
|
||||
// Setting value at outermost level propagates to concrete widget
|
||||
subgraphNodeA.widgets[0].value = 99
|
||||
expect(concreteNode.widgets![0].value).toBe(99)
|
||||
})
|
||||
|
||||
test('removeWidget cleans up promotion and input, then re-promote works', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
subgraphNode.addInput('stringWidget', '*')
|
||||
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
|
||||
input._widget = view
|
||||
|
||||
// Remove: should clean up store AND input reference
|
||||
subgraphNode.removeWidget(view)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(0)
|
||||
expect(input._widget).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
// Re-promote: should work correctly after cleanup
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { hasUnpromotedWidgets } from './unpromotedWidgetUtils'
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all enabled widgets are already promoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(interiorNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores computed-disabled widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
widget.computedDisabled = true
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
const promotionStore = usePromotionStore()
|
||||
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
(interiorNode.widgets ?? []).some(
|
||||
(widget) =>
|
||||
!widget.computedDisabled &&
|
||||
!promotionStore.isPromoted(
|
||||
rootGraph.id,
|
||||
subgraphNodeId,
|
||||
String(interiorNode.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2,66 +2,47 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseProxyWidgets } from './promotionSchema'
|
||||
|
||||
describe('parseProxyWidgets', () => {
|
||||
describe('valid inputs', () => {
|
||||
it('returns empty array for undefined', () => {
|
||||
expect(parseProxyWidgets(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty array', () => {
|
||||
expect(parseProxyWidgets([])).toEqual([])
|
||||
})
|
||||
|
||||
it('parses a single entry', () => {
|
||||
expect(parseProxyWidgets([['1', 'seed']])).toEqual([['1', 'seed']])
|
||||
})
|
||||
|
||||
it('parses multiple entries', () => {
|
||||
const input = [
|
||||
['1', 'seed'],
|
||||
['2', 'steps']
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('parses a JSON string', () => {
|
||||
expect(parseProxyWidgets('[["1", "seed"]]')).toEqual([['1', 'seed']])
|
||||
})
|
||||
|
||||
it('parses a double-encoded JSON string', () => {
|
||||
expect(parseProxyWidgets('"[[\\"1\\", \\"seed\\"]]"')).toEqual([
|
||||
['1', 'seed']
|
||||
])
|
||||
})
|
||||
describe(parseProxyWidgets, () => {
|
||||
it('parses 2-tuple arrays', () => {
|
||||
const input = [
|
||||
['10', 'seed'],
|
||||
['11', 'steps']
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual([
|
||||
['10', 'seed'],
|
||||
['11', 'steps']
|
||||
])
|
||||
})
|
||||
|
||||
describe('invalid inputs (resilient)', () => {
|
||||
it('returns empty array for malformed JSON string', () => {
|
||||
expect(parseProxyWidgets('not valid json')).toEqual([])
|
||||
})
|
||||
it('parses 3-tuple arrays', () => {
|
||||
const input = [
|
||||
['3', 'text', '1'],
|
||||
['3', 'text', '2']
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual([
|
||||
['3', 'text', '1'],
|
||||
['3', 'text', '2']
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array for wrong tuple length', () => {
|
||||
expect(parseProxyWidgets([['only-one']] as unknown as undefined)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
it('parses mixed 2-tuple and 3-tuple arrays', () => {
|
||||
const input = [
|
||||
['10', 'seed'],
|
||||
['3', 'text', '1']
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual([
|
||||
['10', 'seed'],
|
||||
['3', 'text', '1']
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array for wrong shape', () => {
|
||||
expect(
|
||||
parseProxyWidgets({ wrong: 'shape' } as unknown as undefined)
|
||||
).toEqual([])
|
||||
})
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(parseProxyWidgets(undefined)).toEqual([])
|
||||
expect(parseProxyWidgets('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for number', () => {
|
||||
expect(parseProxyWidgets(42)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for null', () => {
|
||||
expect(parseProxyWidgets(null as unknown as undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(parseProxyWidgets('')).toEqual([])
|
||||
})
|
||||
it('returns empty array for invalid tuples', () => {
|
||||
expect(parseProxyWidgets([['only-one']])).toEqual([])
|
||||
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,11 @@ import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
z.tuple([z.string(), z.string(), z.string()]),
|
||||
z.tuple([z.string(), z.string()])
|
||||
])
|
||||
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
export function parseProxyWidgets(
|
||||
|
||||
@@ -244,7 +244,10 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
graph.id = graphId
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote(graphId, 1 as NodeId, '10', 'seed')
|
||||
promotionStore.promote(graphId, 1 as NodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(graphId, {
|
||||
@@ -258,14 +261,24 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(true)
|
||||
expect(
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
|
||||
expect.objectContaining({ value: 1 })
|
||||
)
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(false)
|
||||
expect(
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
|
||||
).toBeUndefined()
|
||||
|
||||
@@ -2018,11 +2018,6 @@ export class LGraphNode
|
||||
})
|
||||
}
|
||||
|
||||
removeWidgetByName(name: string): void {
|
||||
const widget = this.widgets?.find((x) => x.name === name)
|
||||
if (widget) this.removeWidget(widget)
|
||||
}
|
||||
|
||||
removeWidget(widget: IBaseWidget): void {
|
||||
if (!this.widgets)
|
||||
throw new Error('removeWidget called on node without widgets')
|
||||
|
||||
@@ -3,13 +3,13 @@ import { describe, expect, test } from 'vitest'
|
||||
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
|
||||
|
||||
type TestPromotionEntry = {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
function makeView(entry: TestPromotionEntry) {
|
||||
const baseKey = `${entry.interiorNodeId}:${entry.widgetName}`
|
||||
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
|
||||
|
||||
return {
|
||||
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
|
||||
@@ -19,7 +19,7 @@ function makeView(entry: TestPromotionEntry) {
|
||||
describe('PromotedWidgetViewManager', () => {
|
||||
test('returns memoized array when entries reference is unchanged', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
const entries = [{ interiorNodeId: '1', widgetName: 'widgetA' }]
|
||||
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
|
||||
|
||||
const first = manager.reconcile(entries, makeView)
|
||||
const second = manager.reconcile(entries, makeView)
|
||||
@@ -33,16 +33,16 @@ describe('PromotedWidgetViewManager', () => {
|
||||
|
||||
const firstPass = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
const reordered = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' }
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
@@ -56,9 +56,9 @@ describe('PromotedWidgetViewManager', () => {
|
||||
|
||||
const first = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' }
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
@@ -68,14 +68,14 @@ describe('PromotedWidgetViewManager', () => {
|
||||
])
|
||||
|
||||
manager.reconcile(
|
||||
[{ interiorNodeId: '1', widgetName: 'widgetB' }],
|
||||
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
|
||||
makeView
|
||||
)
|
||||
|
||||
const restored = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' }
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
@@ -89,8 +89,8 @@ describe('PromotedWidgetViewManager', () => {
|
||||
|
||||
const views = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
@@ -106,8 +106,8 @@ describe('PromotedWidgetViewManager', () => {
|
||||
|
||||
const firstPass = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
@@ -116,8 +116,8 @@ describe('PromotedWidgetViewManager', () => {
|
||||
|
||||
const secondPass = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
type PromotionEntry = {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
viewKey?: string
|
||||
}
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
type CreateView<TView> = (entry: PromotionEntry) => TView
|
||||
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
|
||||
|
||||
type CreateView<TView> = (entry: ViewManagerEntry) => TView
|
||||
|
||||
/**
|
||||
* Reconciles promoted widget entries to stable view instances.
|
||||
@@ -18,11 +16,11 @@ export class PromotedWidgetViewManager<TView> {
|
||||
private cachedEntryKeys: string[] | null = null
|
||||
|
||||
reconcile(
|
||||
entries: readonly PromotionEntry[],
|
||||
entries: readonly ViewManagerEntry[],
|
||||
createView: CreateView<TView>
|
||||
): TView[] {
|
||||
const entryKeys = entries.map((entry) =>
|
||||
this.makeKey(entry.interiorNodeId, entry.widgetName, entry.viewKey)
|
||||
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
|
||||
)
|
||||
|
||||
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
|
||||
@@ -33,8 +31,8 @@ export class PromotedWidgetViewManager<TView> {
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = this.makeKey(
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey
|
||||
)
|
||||
if (seenKeys.has(key)) continue
|
||||
@@ -61,12 +59,12 @@ export class PromotedWidgetViewManager<TView> {
|
||||
}
|
||||
|
||||
getOrCreate(
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
createView: () => TView,
|
||||
viewKey?: string
|
||||
): TView {
|
||||
const key = this.makeKey(interiorNodeId, widgetName, viewKey)
|
||||
const key = this.makeKey(sourceNodeId, sourceWidgetName, viewKey)
|
||||
const cached = this.viewCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -75,17 +73,17 @@ export class PromotedWidgetViewManager<TView> {
|
||||
return view
|
||||
}
|
||||
|
||||
remove(interiorNodeId: string, widgetName: string): void {
|
||||
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName))
|
||||
remove(sourceNodeId: string, sourceWidgetName: string): void {
|
||||
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName))
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
removeByViewKey(
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
viewKey: string
|
||||
): void {
|
||||
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName, viewKey))
|
||||
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName, viewKey))
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
@@ -110,11 +108,11 @@ export class PromotedWidgetViewManager<TView> {
|
||||
}
|
||||
|
||||
private makeKey(
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
viewKey?: string
|
||||
): string {
|
||||
const baseKey = `${interiorNodeId}:${widgetName}`
|
||||
const baseKey = `${sourceNodeId}:${sourceWidgetName}`
|
||||
return viewKey ? `${baseKey}:${viewKey}` : baseKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,20 +221,16 @@ describe('SubgraphConversion', () => {
|
||||
const graphId = graph.id
|
||||
const subgraphNodeId = subgraphNode.id
|
||||
|
||||
promotionStore.promote(
|
||||
graphId,
|
||||
subgraphNodeId,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
)
|
||||
promotionStore.promote(graphId, subgraphNodeId, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'myWidget'
|
||||
})
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted(
|
||||
graphId,
|
||||
subgraphNodeId,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
)
|
||||
promotionStore.isPromoted(graphId, subgraphNodeId, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'myWidget'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
@@ -496,9 +496,9 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([
|
||||
{ interiorNodeId: String(firstNode.id), widgetName: 'seed' },
|
||||
{ interiorNodeId: String(secondNode.id), widgetName: 'seed' }
|
||||
).toEqual([
|
||||
{ sourceNodeId: String(firstNode.id), sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: String(secondNode.id), sourceWidgetName: 'seed' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
isPromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
@@ -44,7 +45,10 @@ import {
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import {
|
||||
makePromotionEntryKey,
|
||||
usePromotionStore
|
||||
} from '@/stores/promotionStore'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
@@ -56,16 +60,9 @@ const workflowSvg = new Image()
|
||||
workflowSvg.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
|
||||
|
||||
type LinkedPromotionEntry = {
|
||||
type LinkedPromotionEntry = PromotedWidgetSource & {
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
type PromotionEntry = {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
|
||||
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
|
||||
@@ -103,7 +100,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
* `onAdded()`, so construction-time promotions require normal add-to-graph
|
||||
* lifecycle to persist.
|
||||
*/
|
||||
private _pendingPromotions: PromotionEntry[] = []
|
||||
private _pendingPromotions: PromotedWidgetSource[] = []
|
||||
private _cacheVersion = 0
|
||||
private _linkedEntriesCache?: {
|
||||
version: number
|
||||
@@ -112,7 +109,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
private _promotedViewsCache?: {
|
||||
version: number
|
||||
entriesRef: PromotionEntry[]
|
||||
entriesRef: PromotedWidgetSource[]
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
views: PromotedWidgetView[]
|
||||
}
|
||||
@@ -124,7 +121,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
private _resolveLinkedPromotionBySubgraphInput(
|
||||
subgraphInput: SubgraphInput
|
||||
): { interiorNodeId: string; widgetName: string } | undefined {
|
||||
): PromotedWidgetSource | undefined {
|
||||
// Preserve deterministic representative selection for multi-linked inputs:
|
||||
// the first connected source remains the promoted linked view.
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
@@ -142,15 +139,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
if (inputNode.isSubgraphNode())
|
||||
return {
|
||||
interiorNodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
if (isPromotedWidgetView(targetWidget)) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId:
|
||||
targetWidget.disambiguatingSourceNodeId ??
|
||||
targetWidget.sourceNodeId
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interiorNodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.name
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,8 +192,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
interiorNodeId: boundWidget.sourceNodeId,
|
||||
widgetName: boundWidget.sourceWidgetName
|
||||
sourceNodeId: boundWidget.sourceNodeId,
|
||||
sourceWidgetName: boundWidget.sourceWidgetName
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -207,9 +214,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const deduplicatedEntries = linkedEntries.filter((entry) => {
|
||||
const entryKey = this._makePromotionViewKey(
|
||||
entry.inputKey,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.inputName
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
)
|
||||
if (seenEntryKeys.has(entryKey)) return false
|
||||
|
||||
@@ -266,9 +274,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
(entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
|
||||
entry.disambiguatingSourceNodeId
|
||||
)
|
||||
)
|
||||
|
||||
@@ -303,23 +312,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
mergedEntries.length !== entries.length ||
|
||||
mergedEntries.some(
|
||||
(entry, index) =>
|
||||
entry.interiorNodeId !== entries[index]?.interiorNodeId ||
|
||||
entry.widgetName !== entries[index]?.widgetName
|
||||
entry.sourceNodeId !== entries[index]?.sourceNodeId ||
|
||||
entry.sourceWidgetName !== entries[index]?.sourceWidgetName ||
|
||||
entry.disambiguatingSourceNodeId !==
|
||||
entries[index]?.disambiguatingSourceNodeId
|
||||
)
|
||||
|
||||
if (!hasChanged) return
|
||||
|
||||
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
|
||||
}
|
||||
|
||||
private _buildPromotionReconcileState(
|
||||
entries: PromotionEntry[],
|
||||
entries: PromotedWidgetSource[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
displayNameByViewKey: Map<string, string>
|
||||
reconcileEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey?: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}>
|
||||
} {
|
||||
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
|
||||
@@ -332,9 +345,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
linkedEntries,
|
||||
fallbackStoredEntries
|
||||
)
|
||||
const fallbackReconcileEntries = fallbackStoredEntries.map((e) =>
|
||||
e.disambiguatingSourceNodeId
|
||||
? {
|
||||
sourceNodeId: e.sourceNodeId,
|
||||
sourceWidgetName: e.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: e.disambiguatingSourceNodeId,
|
||||
viewKey: `src:${e.sourceNodeId}:${e.sourceWidgetName}:${e.disambiguatingSourceNodeId}`
|
||||
}
|
||||
: e
|
||||
)
|
||||
const reconcileEntries = shouldPersistLinkedOnly
|
||||
? linkedReconcileEntries
|
||||
: [...linkedReconcileEntries, ...fallbackStoredEntries]
|
||||
: [...linkedReconcileEntries, ...fallbackReconcileEntries]
|
||||
|
||||
return {
|
||||
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
|
||||
@@ -343,10 +366,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _buildPromotionPersistenceState(
|
||||
entries: PromotionEntry[],
|
||||
entries: PromotedWidgetSource[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
mergedEntries: PromotionEntry[]
|
||||
mergedEntries: PromotedWidgetSource[]
|
||||
} {
|
||||
const { linkedPromotionEntries, fallbackStoredEntries } =
|
||||
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
|
||||
@@ -363,16 +386,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _collectLinkedAndFallbackEntries(
|
||||
entries: PromotionEntry[],
|
||||
entries: PromotedWidgetSource[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
linkedPromotionEntries: PromotionEntry[]
|
||||
fallbackStoredEntries: PromotionEntry[]
|
||||
linkedPromotionEntries: PromotedWidgetSource[]
|
||||
fallbackStoredEntries: PromotedWidgetSource[]
|
||||
} {
|
||||
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
|
||||
const excludedEntryKeys = new Set(
|
||||
linkedPromotionEntries.map((entry) =>
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
this._makePromotionEntryKey(
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
)
|
||||
)
|
||||
)
|
||||
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
|
||||
@@ -397,28 +424,38 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
private _shouldPersistLinkedOnly(
|
||||
linkedEntries: LinkedPromotionEntry[],
|
||||
fallbackStoredEntries: PromotionEntry[]
|
||||
fallbackStoredEntries: PromotedWidgetSource[]
|
||||
): boolean {
|
||||
if (
|
||||
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
|
||||
)
|
||||
return false
|
||||
|
||||
const linkedEntryKeys = new Set(
|
||||
linkedEntries.map((entry) =>
|
||||
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
|
||||
)
|
||||
)
|
||||
|
||||
const linkedWidgetNames = new Set(
|
||||
linkedEntries.map((entry) => entry.widgetName)
|
||||
linkedEntries.map((entry) => entry.sourceWidgetName)
|
||||
)
|
||||
|
||||
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
|
||||
const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId)
|
||||
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
|
||||
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
|
||||
|
||||
const hasSourceWidget =
|
||||
sourceNode?.widgets?.some(
|
||||
(widget) => widget.name === entry.widgetName
|
||||
sourceNode.widgets?.some(
|
||||
(widget) => widget.name === entry.sourceWidgetName
|
||||
) === true
|
||||
if (hasSourceWidget) return true
|
||||
|
||||
// If the fallback widget name overlaps a linked widget name, keep it
|
||||
// If the fallback entry overlaps a linked entry, keep it
|
||||
// until aliasing can be positively proven.
|
||||
return linkedWidgetNames.has(entry.widgetName)
|
||||
return linkedEntryKeys.has(
|
||||
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
|
||||
)
|
||||
})
|
||||
|
||||
return !hasFallbackToKeep
|
||||
@@ -426,29 +463,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
private _toPromotionEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): PromotionEntry[] {
|
||||
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
}))
|
||||
): PromotedWidgetSource[] {
|
||||
return linkedEntries.map(
|
||||
({ sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private _getFallbackStoredEntries(
|
||||
entries: PromotionEntry[],
|
||||
entries: PromotedWidgetSource[],
|
||||
excludedEntryKeys: Set<string>
|
||||
): PromotionEntry[] {
|
||||
): PromotedWidgetSource[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
!excludedEntryKeys.has(
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
this._makePromotionEntryKey(
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private _pruneStaleAliasFallbackEntries(
|
||||
fallbackStoredEntries: PromotionEntry[],
|
||||
linkedPromotionEntries: PromotionEntry[]
|
||||
): PromotionEntry[] {
|
||||
fallbackStoredEntries: PromotedWidgetSource[],
|
||||
linkedPromotionEntries: PromotedWidgetSource[]
|
||||
): PromotedWidgetSource[] {
|
||||
if (
|
||||
fallbackStoredEntries.length === 0 ||
|
||||
linkedPromotionEntries.length === 0
|
||||
@@ -462,7 +506,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
|
||||
|
||||
const prunedEntries: PromotionEntry[] = []
|
||||
const prunedEntries: PromotedWidgetSource[] = []
|
||||
|
||||
for (const entry of fallbackStoredEntries) {
|
||||
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
|
||||
@@ -475,12 +519,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _resolveConcretePromotionEntryKey(
|
||||
entry: PromotionEntry
|
||||
entry: PromotedWidgetSource
|
||||
): string | undefined {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
)
|
||||
if (result.status !== 'resolved') return undefined
|
||||
|
||||
@@ -513,16 +558,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
private _buildLinkedReconcileEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
|
||||
): Array<{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey: string
|
||||
}> {
|
||||
return linkedEntries.map(
|
||||
({ inputKey, inputName, interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
({
|
||||
inputKey,
|
||||
inputName,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}) => ({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
viewKey: this._makePromotionViewKey(
|
||||
inputKey,
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
inputName
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
inputName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -535,9 +591,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
linkedEntries.map((entry) => [
|
||||
this._makePromotionViewKey(
|
||||
entry.inputKey,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.inputName
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
),
|
||||
entry.inputName
|
||||
])
|
||||
@@ -545,19 +602,43 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _makePromotionEntryKey(
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): string {
|
||||
return `${interiorNodeId}:${widgetName}`
|
||||
return makePromotionEntryKey({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
|
||||
private _makePromotionViewKey(
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
inputName = ''
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
inputName = '',
|
||||
disambiguatingSourceNodeId?: string
|
||||
): string {
|
||||
return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName])
|
||||
return disambiguatingSourceNodeId
|
||||
? JSON.stringify([
|
||||
inputKey,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
inputName,
|
||||
disambiguatingSourceNodeId
|
||||
])
|
||||
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
}
|
||||
|
||||
private _serializeEntries(
|
||||
entries: PromotedWidgetSource[]
|
||||
): (string[] | [string, string, string])[] {
|
||||
return entries.map((e) =>
|
||||
e.disambiguatingSourceNodeId
|
||||
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
|
||||
: [e.sourceNodeId, e.sourceWidgetName]
|
||||
)
|
||||
}
|
||||
|
||||
private _resolveLegacyEntry(
|
||||
@@ -777,20 +858,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// Keep the earliest bound view once present, and only bind from event
|
||||
// payload when this input has no representative yet.
|
||||
const nodeId = String(e.detail.node.id)
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId: nodeId,
|
||||
sourceWidgetName: e.detail.widget.name
|
||||
}
|
||||
if (
|
||||
usePromotionStore().isPromoted(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
e.detail.widget.name
|
||||
)
|
||||
usePromotionStore().isPromoted(this.rootGraph.id, this.id, source)
|
||||
) {
|
||||
usePromotionStore().demote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
e.detail.widget.name
|
||||
)
|
||||
usePromotionStore().demote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
const didSetWidgetFromEvent = !input._widget
|
||||
@@ -962,11 +1037,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const store = usePromotionStore()
|
||||
|
||||
const entries = raw
|
||||
.map(([nodeId, widgetName]) => {
|
||||
.map(([nodeId, widgetName, sourceNodeId]) => {
|
||||
if (nodeId === '-1') {
|
||||
const resolved = this._resolveLegacyEntry(widgetName)
|
||||
if (resolved)
|
||||
return { interiorNodeId: resolved[0], widgetName: resolved[1] }
|
||||
return { sourceNodeId: resolved[0], sourceWidgetName: resolved[1] }
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
|
||||
@@ -974,7 +1049,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
return null
|
||||
}
|
||||
return { interiorNodeId: nodeId, widgetName }
|
||||
const entry: PromotedWidgetSource = {
|
||||
sourceNodeId: nodeId,
|
||||
sourceWidgetName: widgetName,
|
||||
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
|
||||
}
|
||||
return entry
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||
|
||||
@@ -982,10 +1062,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
// Write back resolved entries so legacy -1 format doesn't persist
|
||||
if (raw.some(([id]) => id === '-1')) {
|
||||
this.properties.proxyWidgets = entries.map((e) => [
|
||||
e.interiorNodeId,
|
||||
e.widgetName
|
||||
])
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
}
|
||||
|
||||
// Check all inputs for connected widgets
|
||||
@@ -1008,21 +1085,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
for (const node of this.subgraph.nodes) {
|
||||
if (!supportsVirtualCanvasImagePreview(node)) continue
|
||||
if (
|
||||
store.isPromoted(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
)
|
||||
continue
|
||||
store.promote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,6 +1146,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
const nodeId = String(interiorNode.id)
|
||||
const widgetName = interiorWidget.name
|
||||
const sourceNodeId =
|
||||
interiorNode.isSubgraphNode() && isPromotedWidgetView(interiorWidget)
|
||||
? interiorWidget.sourceNodeId
|
||||
: undefined
|
||||
|
||||
const previousView = input._widget
|
||||
|
||||
@@ -1087,12 +1159,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
(previousView.sourceNodeId !== nodeId ||
|
||||
previousView.sourceWidgetName !== widgetName)
|
||||
) {
|
||||
usePromotionStore().demote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
previousView.sourceNodeId,
|
||||
previousView.sourceWidgetName
|
||||
)
|
||||
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
|
||||
this._removePromotedView(previousView)
|
||||
}
|
||||
|
||||
@@ -1100,22 +1167,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (
|
||||
!this._pendingPromotions.some(
|
||||
(entry) =>
|
||||
entry.interiorNodeId === nodeId && entry.widgetName === widgetName
|
||||
entry.sourceNodeId === nodeId &&
|
||||
entry.sourceWidgetName === widgetName &&
|
||||
entry.disambiguatingSourceNodeId === sourceNodeId
|
||||
)
|
||||
) {
|
||||
this._pendingPromotions.push({
|
||||
interiorNodeId: nodeId,
|
||||
widgetName
|
||||
sourceNodeId: nodeId,
|
||||
sourceWidgetName: widgetName,
|
||||
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Add to promotion store
|
||||
usePromotionStore().promote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
widgetName
|
||||
)
|
||||
usePromotionStore().promote(this.rootGraph.id, this.id, {
|
||||
sourceNodeId: nodeId,
|
||||
sourceWidgetName: widgetName,
|
||||
disambiguatingSourceNodeId: sourceNodeId
|
||||
})
|
||||
}
|
||||
|
||||
// Create/retrieve the view from cache
|
||||
@@ -1127,13 +1196,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this,
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? subgraphInput.name
|
||||
input.label ?? subgraphInput.name,
|
||||
sourceNodeId
|
||||
),
|
||||
this._makePromotionViewKey(
|
||||
String(subgraphInput.id),
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? input.name
|
||||
input.label ?? input.name,
|
||||
sourceNodeId
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1157,12 +1228,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (this.id === -1 || this._pendingPromotions.length === 0) return
|
||||
|
||||
for (const entry of this._pendingPromotions) {
|
||||
usePromotionStore().promote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
)
|
||||
usePromotionStore().promote(this.rootGraph.id, this.id, entry)
|
||||
}
|
||||
|
||||
this._pendingPromotions = []
|
||||
@@ -1318,11 +1384,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
/** Clear the DOM position override for a promoted view's interior widget. */
|
||||
private _clearDomOverrideForView(view: PromotedWidgetView): void {
|
||||
const node = this.subgraph.getNodeById(view.sourceNodeId)
|
||||
if (!node) return
|
||||
const interiorWidget = node.widgets?.find(
|
||||
(w: IBaseWidget) => w.name === view.sourceWidgetName
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
this,
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
view.disambiguatingSourceNodeId
|
||||
)
|
||||
if (resolved.status !== 'resolved') return
|
||||
|
||||
const interiorWidget = resolved.resolved.widget
|
||||
if (
|
||||
interiorWidget &&
|
||||
'id' in interiorWidget &&
|
||||
@@ -1347,7 +1417,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
String(input._subgraphSlot.id),
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
inputName
|
||||
inputName,
|
||||
view.disambiguatingSourceNodeId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1357,20 +1428,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this.ensureWidgetRemoved(widget)
|
||||
}
|
||||
|
||||
override removeWidgetByName(name: string): void {
|
||||
const widget = this.widgets.find((w) => w.name === name)
|
||||
if (widget) this.ensureWidgetRemoved(widget)
|
||||
}
|
||||
|
||||
override ensureWidgetRemoved(widget: IBaseWidget): void {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
this._clearDomOverrideForView(widget)
|
||||
usePromotionStore().demote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
|
||||
this._removePromotedView(widget)
|
||||
}
|
||||
for (const input of this.inputs) {
|
||||
@@ -1472,10 +1533,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this.rootGraph.id,
|
||||
this.id
|
||||
)
|
||||
this.properties.proxyWidgets = entries.map((e) => [
|
||||
e.interiorNodeId,
|
||||
e.widgetName
|
||||
])
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
return super.serialize()
|
||||
}
|
||||
|
||||
@@ -147,37 +147,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
// BUG: removeWidgetByName calls demote but widgets getter rebuilds from
|
||||
// promotionStore which still has the entry.
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/10174
|
||||
it.skip('should fire widget-demoted event when removing promoted widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('Test Node')
|
||||
const subgraphNode = setupPromotedWidget(subgraph, node)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
|
||||
const eventCapture = createEventCapture(subgraph.events, [
|
||||
'widget-demoted'
|
||||
])
|
||||
|
||||
// Remove the widget
|
||||
subgraphNode.removeWidgetByName('input')
|
||||
|
||||
// Check event was fired
|
||||
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
|
||||
expect(demotedEvents).toHaveLength(1)
|
||||
expect(demotedEvents[0].detail.widget).toBeDefined()
|
||||
expect(demotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
|
||||
|
||||
// Widget should be removed
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('should handle multiple widgets on same node', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
|
||||
@@ -211,11 +211,10 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
if (
|
||||
graphId &&
|
||||
!suppressPromotedOutline &&
|
||||
usePromotionStore().isPromotedByAny(
|
||||
graphId,
|
||||
String(this.node.id),
|
||||
this.name
|
||||
)
|
||||
usePromotionStore().isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
)
|
||||
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
|
||||
return this.advanced
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
/>
|
||||
<NodeContent
|
||||
v-for="preview in promotedPreviews"
|
||||
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
|
||||
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
|
||||
:node-data="nodeData"
|
||||
:media="preview"
|
||||
/>
|
||||
@@ -269,7 +269,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/unpromotedWidgetUtils'
|
||||
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
@@ -41,9 +42,10 @@ describe('NodeWidgets', () => {
|
||||
|
||||
const createMockNodeData = (
|
||||
nodeType: string = 'TestNode',
|
||||
widgets: SafeWidgetData[] = []
|
||||
widgets: SafeWidgetData[] = [],
|
||||
id: string = '1'
|
||||
): VueNodeData => ({
|
||||
id: '1',
|
||||
id,
|
||||
type: nodeType,
|
||||
widgets,
|
||||
title: 'Test Node',
|
||||
@@ -54,9 +56,10 @@ describe('NodeWidgets', () => {
|
||||
outputs: []
|
||||
})
|
||||
|
||||
const mountComponent = (nodeData?: VueNodeData) => {
|
||||
const mountComponent = (nodeData?: VueNodeData, setupStores?: () => void) => {
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
setupStores?.()
|
||||
|
||||
return mount(NodeWidgets, {
|
||||
props: {
|
||||
@@ -75,6 +78,20 @@ describe('NodeWidgets', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
|
||||
(
|
||||
wrapper.vm as unknown as { processedWidgets: unknown[] }
|
||||
).processedWidgets.map(
|
||||
(entry) =>
|
||||
(
|
||||
entry as {
|
||||
simplified: {
|
||||
borderStyle?: string
|
||||
}
|
||||
}
|
||||
).simplified.borderStyle
|
||||
)
|
||||
|
||||
describe('node-type prop passing', () => {
|
||||
it('passes node type to widget components', () => {
|
||||
const widget = createMockWidget()
|
||||
@@ -257,6 +274,81 @@ describe('NodeWidgets', () => {
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
|
||||
const firstPromoted = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:1',
|
||||
storeNodeId: 'outer-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const secondPromoted = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:2',
|
||||
storeNodeId: 'outer-subgraph:2',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
firstPromoted,
|
||||
secondPromoted
|
||||
])
|
||||
const wrapper = mountComponent(nodeData)
|
||||
|
||||
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
|
||||
const wrapper = mountComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
const borderStyles = getBorderStyles(wrapper)
|
||||
|
||||
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply promoted border styling to outermost widgets', async () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '4')
|
||||
const wrapper = mountComponent(nodeData, () => {
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
const borderStyles = getBorderStyles(wrapper)
|
||||
|
||||
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides widgets when merged store options mark them hidden', async () => {
|
||||
const nodeData = createMockNodeData('TestNode', [
|
||||
createMockWidget({
|
||||
|
||||
@@ -361,10 +361,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
widgetState,
|
||||
identity: { renderKey }
|
||||
} of uniqueWidgets) {
|
||||
const hostNodeId = String(nodeId ?? '')
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const isPromotedView = !!widget.nodeId
|
||||
const promotionSourceNodeId = widget.storeName
|
||||
? String(bareWidgetId)
|
||||
: undefined
|
||||
|
||||
const vueComponent =
|
||||
getComponent(widget.type) ||
|
||||
@@ -384,8 +387,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
|
||||
const borderStyle =
|
||||
graphId &&
|
||||
!isPromotedView &&
|
||||
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: hostNodeId,
|
||||
sourceWidgetName: widget.storeName ?? widget.name,
|
||||
disambiguatingSourceNodeId: promotionSourceNodeId
|
||||
})
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: mergedOptions.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type ResolvedWidget = {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export function resolveWidgetFromHostNode(
|
||||
hostNode: LGraphNode | undefined,
|
||||
widgetName: string
|
||||
): ResolvedWidget | undefined {
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!hostNode) return undefined
|
||||
|
||||
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)
|
||||
|
||||
@@ -1133,6 +1133,12 @@ export class ComfyApp {
|
||||
useMissingModelStore().clearMissingModels()
|
||||
|
||||
if (clean !== false) {
|
||||
// Reset canvas context before configuring a new graph so subgraph UI
|
||||
// state from the previous workflow cannot leak into the newly loaded
|
||||
// one, and so `clean()` can clear the root graph even when the user is
|
||||
// currently inside a subgraph.
|
||||
this.canvas.setGraph(this.rootGraph)
|
||||
|
||||
this.clean()
|
||||
}
|
||||
|
||||
|
||||
@@ -138,11 +138,10 @@ describe('DOMWidget draw promotion behavior', () => {
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).toHaveBeenCalledWith(
|
||||
'root-graph-id',
|
||||
'-1',
|
||||
'seed'
|
||||
)
|
||||
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
|
||||
sourceNodeId: '-1',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(ctx.strokeRect).toHaveBeenCalledOnce()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
@@ -190,11 +190,10 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
this.promotionStore.isPromotedByAny(
|
||||
graphId,
|
||||
String(this.node.id),
|
||||
this.name
|
||||
)
|
||||
this.promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: String(this.node.id),
|
||||
sourceWidgetName: this.name
|
||||
})
|
||||
if (!isPromoted) {
|
||||
this.options.onDraw?.(this)
|
||||
return
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
|
||||
import type {
|
||||
SubgraphPseudoWidget,
|
||||
SubgraphPseudoWidgetCache,
|
||||
SubgraphPseudoWidgetNode,
|
||||
SubgraphPromotionEntry
|
||||
SubgraphPseudoWidgetNode
|
||||
} from '@/services/subgraphPseudoWidgetCache'
|
||||
|
||||
interface TestWidget extends SubgraphPseudoWidget {
|
||||
@@ -30,8 +30,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
const getNodeById = vi.fn((id: string) =>
|
||||
id === 'n1' ? interiorNode : undefined
|
||||
)
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
@@ -48,8 +48,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
|
||||
it('keeps $$ fallback behavior when the backing widget is missing', () => {
|
||||
const interiorNode = node('n1', [widget('other')])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: '$$canvas-image-preview' }
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: '$$canvas-image-preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
@@ -64,8 +64,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
|
||||
it('reuses cache when promotions and node identities are unchanged', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
|
||||
]
|
||||
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
@@ -91,8 +91,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
|
||||
it('rebuilds cache when promotions reference changes', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const promotionsA: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
const promotionsA: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
|
||||
]
|
||||
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
@@ -100,8 +100,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
const promotionsB: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
const promotionsB: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
@@ -116,8 +116,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
|
||||
it('falls back to rebuild when a cached node reference goes stale', () => {
|
||||
const oldNode = node('n1', [widget('preview', true)])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
|
||||
]
|
||||
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
@@ -138,16 +138,100 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
expect(result.nodes).toEqual([newNode])
|
||||
})
|
||||
|
||||
it('rebuilds cache with different results when replacement node lacks the pseudo widget', () => {
|
||||
const oldNode = node('n1', [widget('preview', true)])
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
|
||||
]
|
||||
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: () => oldNode,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(initial.nodes).toHaveLength(1)
|
||||
|
||||
const replacementNode = node('n1', [widget('other')])
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: initial.cache,
|
||||
promotions,
|
||||
getNodeById: () => replacementNode,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.cache).not.toBe(initial.cache)
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.cache.entries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes all pseudo-widget promotions across multiple interior nodes', () => {
|
||||
const nodeA = node('n1', [widget('preview', true)])
|
||||
const nodeB = node('n2', [widget('preview', true)])
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' },
|
||||
{ sourceNodeId: 'n2', sourceWidgetName: 'preview' }
|
||||
]
|
||||
const getNodeById = (id: string) => {
|
||||
if (id === 'n1') return nodeA
|
||||
if (id === 'n2') return nodeB
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([nodeA, nodeB])
|
||||
expect(result.cache.entries).toHaveLength(2)
|
||||
|
||||
const reducedPromotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
|
||||
]
|
||||
|
||||
const reduced = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions: reducedPromotions,
|
||||
getNodeById,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(reduced.nodes).toEqual([nodeA])
|
||||
expect(reduced.cache.entries).toHaveLength(1)
|
||||
expect(reduced.cache.entries[0].sourceNodeId).toBe('n1')
|
||||
})
|
||||
|
||||
it('excludes promotions where isPreviewPseudoWidget returns false', () => {
|
||||
const interiorNode = node('n1', [widget('myWidget', false)])
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'n1', sourceWidgetName: 'myWidget' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.cache.entries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops cached entries when node no longer resolves', () => {
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'missing', widgetName: '$$canvas-image-preview' }
|
||||
const promotions: readonly PromotedWidgetSource[] = [
|
||||
{ sourceNodeId: 'missing', sourceWidgetName: '$$canvas-image-preview' }
|
||||
]
|
||||
const cache: SubgraphPseudoWidgetCache<TestNode, TestWidget> = {
|
||||
promotions,
|
||||
entries: [
|
||||
{
|
||||
interiorNodeId: 'missing',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
sourceNodeId: 'missing',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
node: node('missing')
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export interface SubgraphPromotionEntry {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
export interface SubgraphPseudoWidget {
|
||||
name: string
|
||||
@@ -18,8 +15,8 @@ interface SubgraphPseudoWidgetCacheEntry<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
node: TNode
|
||||
}
|
||||
|
||||
@@ -27,7 +24,7 @@ export interface SubgraphPseudoWidgetCache<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
promotions: readonly SubgraphPromotionEntry[]
|
||||
promotions: readonly PromotedWidgetSource[]
|
||||
entries: SubgraphPseudoWidgetCacheEntry<TNode, TWidget>[]
|
||||
nodes: TNode[]
|
||||
}
|
||||
@@ -37,7 +34,7 @@ interface ResolveSubgraphPseudoWidgetCacheArgs<
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
cache: SubgraphPseudoWidgetCache<TNode, TWidget> | null
|
||||
promotions: readonly SubgraphPromotionEntry[]
|
||||
promotions: readonly PromotedWidgetSource[]
|
||||
getNodeById: (nodeId: string) => TNode | undefined
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
}
|
||||
@@ -69,10 +66,10 @@ function isCacheStillValid<
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
): boolean {
|
||||
return cache.entries.every((entry) => {
|
||||
const currentNode = getNodeById(entry.interiorNodeId)
|
||||
const currentNode = getNodeById(entry.sourceNodeId)
|
||||
if (!currentNode || currentNode !== entry.node) return false
|
||||
return isPseudoPromotion(
|
||||
entry.widgetName,
|
||||
entry.sourceWidgetName,
|
||||
currentNode.widgets,
|
||||
isPreviewPseudoWidget
|
||||
)
|
||||
@@ -95,11 +92,11 @@ export function resolveSubgraphPseudoWidgetCache<
|
||||
return { cache, nodes: cache.nodes }
|
||||
|
||||
const entries = promotions.flatMap((promotion) => {
|
||||
const node = getNodeById(promotion.interiorNodeId)
|
||||
const node = getNodeById(promotion.sourceNodeId)
|
||||
if (!node) return []
|
||||
if (
|
||||
!isPseudoPromotion(
|
||||
promotion.widgetName,
|
||||
promotion.sourceWidgetName,
|
||||
node.widgets,
|
||||
isPreviewPseudoWidget
|
||||
)
|
||||
|
||||
@@ -32,8 +32,8 @@ describe(usePromotionStore, () => {
|
||||
|
||||
it('returns entries after setPromotions', () => {
|
||||
const entries = [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
|
||||
@@ -41,31 +41,52 @@ describe(usePromotionStore, () => {
|
||||
|
||||
it('returns a defensive copy', () => {
|
||||
store.setPromotions(graphA, nodeId, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
|
||||
const result = store.getPromotions(graphA, nodeId)
|
||||
result.push({ interiorNodeId: '11', widgetName: 'steps' })
|
||||
result.push({ sourceNodeId: '11', sourceWidgetName: 'steps' })
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPromoted', () => {
|
||||
it('returns false when nothing is promoted', () => {
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a promoted entry', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(true)
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a different widget on the same node', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'steps')).toBe(false)
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,62 +95,141 @@ describe(usePromotionStore, () => {
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('returns false when nothing is promoted', () => {
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when promoted by one parent', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when promoted by multiple parents', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false after demoting from all parents', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
store.demote(graphA, nodeA, '10', 'seed')
|
||||
store.demote(graphA, nodeB, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when still promoted by one parent after partial demote', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
store.demote(graphA, nodeA, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for different widget on same node', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'steps')).toBe(false)
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPromotions', () => {
|
||||
it('replaces existing entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.setPromotions(graphA, nodeId, [
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromoted(graphA, nodeId, '11', 'steps')).toBe(true)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('clears entries when set to empty array', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.setPromotions(graphA, nodeId, [])
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves order', () => {
|
||||
const entries = [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' },
|
||||
{ interiorNodeId: '12', widgetName: 'cfg' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
|
||||
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
|
||||
@@ -138,74 +238,128 @@ describe(usePromotionStore, () => {
|
||||
|
||||
describe('promote', () => {
|
||||
it('adds a new entry', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate existing entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('appends to existing entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('demote', () => {
|
||||
it('removes an existing entry', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.demote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('is a no-op for non-existent entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.demote(graphA, nodeId, '99', 'nonexistent')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '99',
|
||||
sourceWidgetName: 'nonexistent'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves other entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
store.demote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('movePromotion', () => {
|
||||
it('moves an entry from one index to another', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
store.promote(graphA, nodeId, '12', 'cfg')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '12',
|
||||
sourceWidgetName: 'cfg'
|
||||
})
|
||||
store.movePromotion(graphA, nodeId, 0, 2)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '11', widgetName: 'steps' },
|
||||
{ interiorNodeId: '12', widgetName: 'cfg' },
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
|
||||
{ sourceNodeId: '12', sourceWidgetName: 'cfg' },
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op for out-of-bounds indices', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.movePromotion(graphA, nodeId, 0, 5)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when fromIndex equals toIndex', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.movePromotion(graphA, nodeId, 1, 1)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -216,48 +370,109 @@ describe(usePromotionStore, () => {
|
||||
|
||||
it('tracks across setPromotions calls', () => {
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeB, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Remove from A — still promoted by B
|
||||
store.setPromotions(graphA, nodeA, [])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Remove from B — now gone
|
||||
store.setPromotions(graphA, nodeB, [])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('handles replacement via setPromotions correctly', () => {
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Replace with different entries
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ interiorNodeId: '11', widgetName: 'steps' },
|
||||
{ interiorNodeId: '12', widgetName: 'cfg' }
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
|
||||
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
|
||||
expect(store.isPromotedByAny(graphA, '12', 'cfg')).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '12',
|
||||
sourceWidgetName: 'cfg'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('stays consistent through movePromotion', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeA, '11', 'steps')
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.movePromotion(graphA, nodeA, 0, 1)
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -266,52 +481,412 @@ describe(usePromotionStore, () => {
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('keeps promotions separate per subgraph node', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '20', 'cfg')
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'cfg'
|
||||
})
|
||||
|
||||
expect(store.getPromotions(graphA, nodeA)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(store.getPromotions(graphA, nodeB)).toEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'cfg' }
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'cfg' }
|
||||
])
|
||||
})
|
||||
|
||||
it('demoting from one node does not affect another', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
store.demote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
expect(store.isPromoted(graphA, nodeA, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromoted(graphA, nodeB, '10', 'seed')).toBe(true)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearGraph resets ref counts', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('resets isPromotedByAny after clearGraph', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPromotions idempotency', () => {
|
||||
it('does not double ref counts when called twice with same entries', () => {
|
||||
const entries = [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeId, [])
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promote/demote interleaved with setPromotions', () => {
|
||||
it('maintains consistent ref counts through mixed operations', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates promotions by graph id', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphB, nodeId, '20', 'steps')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphB, nodeId, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(store.getPromotions(graphB, nodeId)).toEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'steps' }
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphB, nodeId, '20', 'steps')
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphB, nodeId, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
expect(store.getPromotions(graphB, nodeId)).toEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'steps' }
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromotedByAny(graphB, '20', 'steps')).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphB, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sourceNodeId disambiguation', () => {
|
||||
it('promote with disambiguatingSourceNodeId is found by matching isPromoted', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('isPromoted with different disambiguatingSourceNodeId returns false', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '88'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('isPromoted with undefined disambiguatingSourceNodeId does not match entry with disambiguatingSourceNodeId', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('two entries with same sourceNodeId/sourceWidgetName but different disambiguatingSourceNodeId coexist', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('demote with disambiguatingSourceNodeId removes only matching entry', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
}
|
||||
])
|
||||
store.setPromotions(graphA, nodeB, [
|
||||
{
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
}
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeA, [])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeB, [])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
interface PromotionEntry {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
|
||||
|
||||
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
|
||||
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
|
||||
return source.disambiguatingSourceNodeId
|
||||
? `${base}:${source.disambiguatingSourceNodeId}`
|
||||
: base
|
||||
}
|
||||
|
||||
const EMPTY_PROMOTIONS: PromotionEntry[] = []
|
||||
|
||||
export const usePromotionStore = defineStore('promotion', () => {
|
||||
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
|
||||
const graphPromotions = ref(
|
||||
new Map<UUID, Map<NodeId, PromotedWidgetSource[]>>()
|
||||
)
|
||||
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
|
||||
|
||||
function _getPromotionsForGraph(
|
||||
graphId: UUID
|
||||
): Map<NodeId, PromotionEntry[]> {
|
||||
): Map<NodeId, PromotedWidgetSource[]> {
|
||||
const promotions = graphPromotions.value.get(graphId)
|
||||
if (promotions) return promotions
|
||||
|
||||
const nextPromotions = new Map<NodeId, PromotionEntry[]>()
|
||||
const nextPromotions = new Map<NodeId, PromotedWidgetSource[]>()
|
||||
graphPromotions.value.set(graphId, nextPromotions)
|
||||
return nextPromotions
|
||||
}
|
||||
@@ -35,22 +40,24 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
return nextRefCounts
|
||||
}
|
||||
|
||||
function _makeKey(interiorNodeId: string, widgetName: string): string {
|
||||
return `${interiorNodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
function _incrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
|
||||
function _incrementKeys(
|
||||
graphId: UUID,
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = _makeKey(e.interiorNodeId, e.widgetName)
|
||||
const key = makePromotionEntryKey(e)
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function _decrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
|
||||
function _decrementKeys(
|
||||
graphId: UUID,
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = _makeKey(e.interiorNodeId, e.widgetName)
|
||||
const key = makePromotionEntryKey(e)
|
||||
const count = (refCounts.get(key) ?? 1) - 1
|
||||
if (count <= 0) {
|
||||
refCounts.delete(key)
|
||||
@@ -63,7 +70,7 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
function getPromotionsRef(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotionEntry[] {
|
||||
): PromotedWidgetSource[] {
|
||||
return (
|
||||
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
|
||||
)
|
||||
@@ -72,34 +79,35 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
function getPromotions(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotionEntry[] {
|
||||
): PromotedWidgetSource[] {
|
||||
return [...getPromotionsRef(graphId, subgraphNodeId)]
|
||||
}
|
||||
|
||||
function isPromoted(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
return getPromotionsRef(graphId, subgraphNodeId).some(
|
||||
(e) => e.interiorNodeId === interiorNodeId && e.widgetName === widgetName
|
||||
(e) =>
|
||||
e.sourceNodeId === source.sourceNodeId &&
|
||||
e.sourceWidgetName === source.sourceWidgetName &&
|
||||
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
function isPromotedByAny(
|
||||
graphId: UUID,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
return (refCounts.get(_makeKey(interiorNodeId, widgetName)) ?? 0) > 0
|
||||
return (refCounts.get(makePromotionEntryKey(source)) ?? 0) > 0
|
||||
}
|
||||
|
||||
function setPromotions(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
entries: PromotionEntry[]
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const promotions = _getPromotionsForGraph(graphId)
|
||||
const oldEntries = promotions.get(subgraphNodeId) ?? []
|
||||
@@ -117,23 +125,24 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
function promote(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
source: PromotedWidgetSource
|
||||
): void {
|
||||
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
|
||||
if (isPromoted(graphId, subgraphNodeId, source)) return
|
||||
|
||||
const entries = getPromotionsRef(graphId, subgraphNodeId)
|
||||
setPromotions(graphId, subgraphNodeId, [
|
||||
...entries,
|
||||
{ interiorNodeId, widgetName }
|
||||
])
|
||||
const entry: PromotedWidgetSource = {
|
||||
sourceNodeId: source.sourceNodeId,
|
||||
sourceWidgetName: source.sourceWidgetName
|
||||
}
|
||||
if (source.disambiguatingSourceNodeId)
|
||||
entry.disambiguatingSourceNodeId = source.disambiguatingSourceNodeId
|
||||
setPromotions(graphId, subgraphNodeId, [...entries, entry])
|
||||
}
|
||||
|
||||
function demote(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
source: PromotedWidgetSource
|
||||
): void {
|
||||
const entries = getPromotionsRef(graphId, subgraphNodeId)
|
||||
setPromotions(
|
||||
@@ -141,7 +150,11 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
subgraphNodeId,
|
||||
entries.filter(
|
||||
(e) =>
|
||||
!(e.interiorNodeId === interiorNodeId && e.widgetName === widgetName)
|
||||
!(
|
||||
e.sourceNodeId === source.sourceNodeId &&
|
||||
e.sourceWidgetName === source.sourceWidgetName &&
|
||||
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -169,7 +182,6 @@ export const usePromotionStore = defineStore('promotion', () => {
|
||||
const [entry] = entries.splice(fromIndex, 1)
|
||||
entries.splice(toIndex, 0, entry)
|
||||
|
||||
// Reordering does not change membership, so ref-counts remain valid.
|
||||
promotions.set(subgraphNodeId, entries)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,22 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
|
||||
|
||||
type MockSubgraph = Pick<Subgraph, 'id' | 'rootGraph' | '_nodes' | 'nodes'>
|
||||
|
||||
function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
|
||||
const mockSubgraph = {
|
||||
id,
|
||||
rootGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
} satisfies MockSubgraph
|
||||
|
||||
return mockSubgraph as unknown as Subgraph
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
subgraph: null,
|
||||
@@ -25,14 +37,17 @@ vi.mock('@/scripts/app', () => {
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
const mockGraph = {
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
graph: {
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
},
|
||||
graph: mockGraph,
|
||||
rootGraph: mockGraph,
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}
|
||||
@@ -52,6 +67,14 @@ vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
|
||||
describe('useSubgraphNavigationStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
app.rootGraph.subgraphs.clear()
|
||||
app.canvas.subgraph = undefined
|
||||
app.canvas.ds.scale = 1
|
||||
app.canvas.ds.offset = [0, 0]
|
||||
app.canvas.ds.state.scale = 1
|
||||
app.canvas.ds.state.offset = [0, 0]
|
||||
app.graph.getNodeById = vi.fn()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should not clear navigation stack when workflow internal state changes', async () => {
|
||||
@@ -88,60 +111,109 @@ describe('useSubgraphNavigationStore', () => {
|
||||
it('should preserve navigation stack per workflow', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
// Mock first workflow
|
||||
const workflow1 = {
|
||||
path: 'workflow1.json',
|
||||
filename: 'workflow1.json',
|
||||
changeTracker: createMockChangeTracker({
|
||||
restore: vi.fn(),
|
||||
store: vi.fn()
|
||||
})
|
||||
} as Partial<ComfyWorkflow> as ComfyWorkflow
|
||||
filename: 'workflow1.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
// Set the active workflow
|
||||
workflowStore.activeWorkflow =
|
||||
workflow1 as typeof workflowStore.activeWorkflow
|
||||
|
||||
// Simulate the restore process that happens when loading a workflow
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
|
||||
|
||||
// Verify navigation was set
|
||||
expect(navigationStore.exportState()).toHaveLength(2)
|
||||
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
|
||||
|
||||
// Switch to a different workflow with no subgraph state (root level)
|
||||
const workflow2 = {
|
||||
path: 'workflow2.json',
|
||||
filename: 'workflow2.json',
|
||||
changeTracker: createMockChangeTracker({
|
||||
restore: vi.fn(),
|
||||
store: vi.fn()
|
||||
})
|
||||
} as Partial<ComfyWorkflow> as ComfyWorkflow
|
||||
filename: 'workflow2.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
const sub1 = createMockSubgraph('sub-1')
|
||||
const sub2 = createMockSubgraph('sub-2')
|
||||
|
||||
app.rootGraph.subgraphs.set(sub1.id, sub1)
|
||||
app.rootGraph.subgraphs.set(sub2.id, sub2)
|
||||
|
||||
vi.mocked(findSubgraphPathById).mockImplementation((_rootGraph, id) => {
|
||||
if (id === sub1.id) return [sub1.id]
|
||||
if (id === sub2.id) return [sub1.id, sub2.id]
|
||||
return null
|
||||
})
|
||||
|
||||
// Workflow1 is in a nested subgraph (sub-1 -> sub-2)
|
||||
app.canvas.subgraph = sub2
|
||||
workflowStore.activeWorkflow =
|
||||
workflow1 as typeof workflowStore.activeWorkflow
|
||||
await nextTick()
|
||||
|
||||
expect(navigationStore.exportState()).toEqual([sub1.id, sub2.id])
|
||||
|
||||
// Switch to workflow2 at root level
|
||||
app.canvas.subgraph = undefined
|
||||
workflowStore.activeWorkflow =
|
||||
workflow2 as typeof workflowStore.activeWorkflow
|
||||
await nextTick()
|
||||
|
||||
expect(navigationStore.exportState()).toEqual([])
|
||||
|
||||
// Switch back to workflow1 in its subgraph
|
||||
app.canvas.subgraph = sub2
|
||||
workflowStore.activeWorkflow =
|
||||
workflow1 as typeof workflowStore.activeWorkflow
|
||||
await nextTick()
|
||||
|
||||
expect(navigationStore.exportState()).toEqual([sub1.id, sub2.id])
|
||||
})
|
||||
|
||||
it('should reset navigation on workflow switch and restore on switch back', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
const workflow1 = {
|
||||
path: 'workflow1.json',
|
||||
filename: 'workflow1.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
const workflow1Subgraph = createMockSubgraph('sub-1')
|
||||
|
||||
app.rootGraph.subgraphs.set(workflow1Subgraph.id, workflow1Subgraph)
|
||||
vi.mocked(findSubgraphPathById).mockImplementation((_rootGraph, id) =>
|
||||
id === workflow1Subgraph.id ? [workflow1Subgraph.id] : null
|
||||
)
|
||||
|
||||
app.canvas.subgraph = workflow1Subgraph
|
||||
|
||||
workflowStore.activeWorkflow =
|
||||
workflow1 as typeof workflowStore.activeWorkflow
|
||||
await nextTick()
|
||||
|
||||
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
|
||||
|
||||
const workflow2 = {
|
||||
path: 'workflow2.json',
|
||||
filename: 'workflow2.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
app.canvas.subgraph = undefined
|
||||
|
||||
workflowStore.activeWorkflow =
|
||||
workflow2 as typeof workflowStore.activeWorkflow
|
||||
await nextTick()
|
||||
|
||||
// Simulate the restore process for workflow2
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
navigationStore.restoreState([])
|
||||
expect(navigationStore.exportState()).toEqual([])
|
||||
|
||||
// The navigation stack should be empty for workflow2 (at root level)
|
||||
expect(navigationStore.exportState()).toHaveLength(0)
|
||||
app.canvas.subgraph = workflow1Subgraph
|
||||
|
||||
// Switch back to workflow1
|
||||
workflowStore.activeWorkflow =
|
||||
workflow1 as typeof workflowStore.activeWorkflow
|
||||
await nextTick()
|
||||
|
||||
// Simulate the restore process for workflow1 again
|
||||
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
|
||||
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
|
||||
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
|
||||
})
|
||||
|
||||
// The navigation stack should be restored for workflow1
|
||||
expect(navigationStore.exportState()).toHaveLength(2)
|
||||
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
|
||||
it('should handle restoreState with unreachable subgraph IDs', () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
navigationStore.restoreState(['nonexistent-sub'])
|
||||
|
||||
expect(navigationStore.exportState()).toEqual(['nonexistent-sub'])
|
||||
expect(navigationStore.navigationStack).toEqual([])
|
||||
})
|
||||
|
||||
it('should clear navigation when activeSubgraph becomes undefined', async () => {
|
||||
@@ -150,12 +222,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
// Create mock subgraph and graph structure
|
||||
const mockSubgraph = {
|
||||
id: 'subgraph-1',
|
||||
rootGraph: app.graph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
} as Partial<Subgraph> as Subgraph
|
||||
const mockSubgraph = createMockSubgraph('subgraph-1', app.graph)
|
||||
|
||||
// Add the subgraph to the graph's subgraphs map
|
||||
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
|
||||
@@ -13,6 +13,8 @@ import { app } from '@/scripts/app'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export const VIEWPORT_CACHE_MAX_SIZE = 32
|
||||
|
||||
/**
|
||||
* Stores the current subgraph navigation state; a stack representing subgraph
|
||||
* navigation history from the root graph to the subgraph that is currently
|
||||
@@ -34,7 +36,7 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
|
||||
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
|
||||
const viewportCache = new QuickLRU<string, DragAndScaleState>({
|
||||
maxSize: 32
|
||||
maxSize: VIEWPORT_CACHE_MAX_SIZE
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import {
|
||||
useSubgraphNavigationStore,
|
||||
VIEWPORT_CACHE_MAX_SIZE
|
||||
} from '@/stores/subgraphNavigationStore'
|
||||
|
||||
const { mockSetDirty } = vi.hoisted(() => ({
|
||||
mockSetDirty: vi.fn()
|
||||
@@ -261,5 +264,64 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(navigationStore.viewportCache.has('root')).toBe(true)
|
||||
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should save/restore viewports correctly across multiple subgraphs', () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
navigationStore.viewportCache.set('root', {
|
||||
scale: 1,
|
||||
offset: [0, 0]
|
||||
})
|
||||
navigationStore.viewportCache.set('sub-1', {
|
||||
scale: 2,
|
||||
offset: [100, 200]
|
||||
})
|
||||
navigationStore.viewportCache.set('sub-2', {
|
||||
scale: 0.5,
|
||||
offset: [-50, -75]
|
||||
})
|
||||
|
||||
navigationStore.restoreViewport('sub-1')
|
||||
expect(mockCanvas.ds.scale).toBe(2)
|
||||
expect(mockCanvas.ds.offset).toEqual([100, 200])
|
||||
|
||||
navigationStore.restoreViewport('sub-2')
|
||||
expect(mockCanvas.ds.scale).toBe(0.5)
|
||||
expect(mockCanvas.ds.offset).toEqual([-50, -75])
|
||||
|
||||
navigationStore.restoreViewport('root')
|
||||
expect(mockCanvas.ds.scale).toBe(1)
|
||||
expect(mockCanvas.ds.offset).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('should evict oldest viewport entry when LRU cache exceeds capacity', () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const overflowEntryCount = VIEWPORT_CACHE_MAX_SIZE * 2 + 1
|
||||
|
||||
// QuickLRU uses double-buffering: effective capacity is up to 2 * maxSize.
|
||||
// Fill enough entries so the earliest ones are fully evicted.
|
||||
for (let i = 0; i < overflowEntryCount; i++) {
|
||||
navigationStore.viewportCache.set(`sub-${i}`, {
|
||||
scale: i + 1,
|
||||
offset: [i * 10, i * 20]
|
||||
})
|
||||
}
|
||||
|
||||
expect(navigationStore.viewportCache.has('sub-0')).toBe(false)
|
||||
|
||||
expect(
|
||||
navigationStore.viewportCache.has(`sub-${overflowEntryCount - 1}`)
|
||||
).toBe(true)
|
||||
|
||||
mockCanvas.ds.scale = 99
|
||||
mockCanvas.ds.offset = [999, 999]
|
||||
mockSetDirty.mockClear()
|
||||
|
||||
navigationStore.restoreViewport('sub-0')
|
||||
|
||||
expect(mockCanvas.ds.scale).toBe(99)
|
||||
expect(mockCanvas.ds.offset).toEqual([999, 999])
|
||||
expect(mockSetDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user