mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
[backport core/1.42] test: subgraph integration contracts and expanded Playwright coverage (#10327)
Backport of #10123, #9967, and #9972 to `core/1.42` Includes three cherry-picks in dependency order: 1. #9972 — `fix: resolve all lint warnings` (clean) 2. #9967 — `test: harden subgraph test coverage and remove low-value tests` (clean) 3. #10123 — `test: subgraph integration contracts and expanded Playwright coverage` (1 conflict, auto-resolved by rerere from #10326) See #10326 for core/1.41 backport with detailed conflict resolution notes. --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: bymyself <cbyrne@comfy.org> 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
export interface PromotedWidgetSnapshot {
|
||||
proxyWidgets: PromotedWidgetEntry[]
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetEntry(
|
||||
entry: unknown
|
||||
): entry is PromotedWidgetEntry {
|
||||
@@ -32,6 +37,28 @@ export async function getPromotedWidgets(
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetSnapshot> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return {
|
||||
proxyWidgets: node?.properties?.proxyWidgets ?? [],
|
||||
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
|
||||
}
|
||||
}, nodeId)
|
||||
|
||||
return {
|
||||
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
|
||||
widgetNames: Array.isArray(raw.widgetNames)
|
||||
? raw.widgetNames.filter(
|
||||
(name): name is string => typeof name === 'string'
|
||||
)
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -48,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'
|
||||
@@ -631,6 +632,51 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
|
||||
test('Switching workflows while inside subgraph returns to root graph context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph(comfyPage)).toBe(false)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-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', () => {
|
||||
|
||||
347
browser_tests/tests/subgraphLifecycle.spec.ts
Normal file
347
browser_tests/tests/subgraphLifecycle.spec.ts
Normal file
@@ -0,0 +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 {
|
||||
getPromotedWidgets,
|
||||
getPseudoPreviewWidgets,
|
||||
getNonPreviewPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
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(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
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,26 +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 exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
const parentLink = breadcrumb.getByRole('link').first()
|
||||
await expect(parentLink).toBeVisible()
|
||||
await parentLink.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Widget Promotion',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
@@ -179,7 +158,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 ({
|
||||
@@ -251,7 +230,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
@@ -285,7 +264,7 @@ test.describe(
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
@@ -331,7 +310,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -366,7 +345,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -397,7 +376,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
@@ -545,6 +524,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
|
||||
}) => {
|
||||
@@ -702,6 +705,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
|
||||
}) => {
|
||||
@@ -724,15 +765,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
name: 'subgraph-with-promoted-text-widget'
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState,
|
||||
setupComplexPromotionFixture
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
@@ -48,9 +49,14 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[]] {
|
||||
): [SubgraphNode, LGraphNode[], string[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
@@ -62,7 +68,8 @@ function setupSubgraph(
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
return [subgraphNode, innerNodes]
|
||||
const innerIds = innerNodes.map((n) => String(n.id))
|
||||
return [subgraphNode, innerNodes, innerIds]
|
||||
}
|
||||
|
||||
function setPromotions(
|
||||
@@ -72,9 +79,9 @@ function setPromotions(
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
entries.map(([interiorNodeId, widgetName]) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
entries.map(([sourceNodeId, sourceWidgetName]) => ({
|
||||
sourceNodeId,
|
||||
sourceWidgetName
|
||||
}))
|
||||
)
|
||||
}
|
||||
@@ -97,13 +104,8 @@ function callSyncPromotions(node: SubgraphNode) {
|
||||
)._syncPromotions()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
|
||||
describe(createPromotedWidgetView, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockDomWidgetStore.widgetStates.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -113,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', () => {
|
||||
@@ -315,18 +332,10 @@ describe(createPromotedWidgetView, () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
const store = useWidgetValueStore()
|
||||
const bareId = String(innerNode.id)
|
||||
|
||||
// No displayName → falls back to widgetName
|
||||
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
|
||||
// Store label is undefined → falls back to displayName/widgetName
|
||||
const state = store.getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
bareId as never,
|
||||
'myWidget'
|
||||
)
|
||||
state!.label = undefined
|
||||
expect(view1.label).toBe('myWidget')
|
||||
|
||||
// With displayName → falls back to displayName
|
||||
@@ -435,10 +444,6 @@ describe(createPromotedWidgetView, () => {
|
||||
})
|
||||
|
||||
describe('SubgraphNode.widgets getter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('defers promotions while subgraph node id is -1 and flushes on add', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'picker_input', type: '*' }]
|
||||
@@ -465,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'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -508,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')
|
||||
@@ -576,7 +581,7 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
|
||||
test('input-linked same-name widgets propagate value to all connected nodes while store-promoted peer stays independent', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
@@ -602,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)
|
||||
@@ -631,53 +632,17 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = subgraphNode.rootGraph.id
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent')
|
||||
// Both linked nodes share the same SubgraphInput slot, so the value
|
||||
// propagates to all connected widgets via getLinkedInputWidgets().
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent-updated')
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
|
||||
})
|
||||
|
||||
test('duplicate-name promoted views map slot linkage by view identity', () => {
|
||||
@@ -698,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(
|
||||
@@ -780,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' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -811,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' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -870,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' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -915,8 +878,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: linkedEntry[0],
|
||||
widgetName: linkedEntry[1]
|
||||
sourceNodeId: linkedEntry[0],
|
||||
sourceWidgetName: linkedEntry[1]
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -972,8 +935,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(restoredPromotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(activeAliasNode.id),
|
||||
widgetName: 'string_a'
|
||||
sourceNodeId: String(activeAliasNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1053,9 +1016,9 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
|
||||
const first = subgraphNode.widgets[0]
|
||||
const second = subgraphNode.widgets[0]
|
||||
@@ -1063,10 +1026,10 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
})
|
||||
|
||||
test('memoizes promotion list by reference', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
|
||||
const views1 = subgraphNode.widgets
|
||||
expect(views1).toHaveLength(1)
|
||||
@@ -1076,52 +1039,52 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
expect(views2[0]).toBe(views1[0])
|
||||
|
||||
// New store value with same content → same cached view object
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
const views3 = subgraphNode.widgets
|
||||
expect(views3[0]).toBe(views1[0])
|
||||
})
|
||||
|
||||
test('cleans stale cache entries when promotions shrink', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
const viewA = subgraphNode.widgets[0]
|
||||
|
||||
// Remove widgetA from promotion list
|
||||
setPromotions(subgraphNode, [['1', 'widgetB']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetB']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
|
||||
// Re-adding widgetA creates a new view (old one was cleaned)
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA']
|
||||
[innerIds[0], 'widgetB'],
|
||||
[innerIds[0], 'widgetA']
|
||||
])
|
||||
const newViewA = subgraphNode.widgets[1]
|
||||
expect(newViewA).not.toBe(viewA)
|
||||
})
|
||||
|
||||
test('deduplicates entries with same nodeId:widgetName', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetA']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetA']
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('setter is a no-op', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
|
||||
// Assigning to widgets does nothing
|
||||
subgraphNode.widgets = []
|
||||
@@ -1160,8 +1123,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNodes[0].id),
|
||||
widgetName: 'stringWidget'
|
||||
sourceNodeId: String(innerNodes[0].id),
|
||||
sourceWidgetName: 'stringWidget'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1189,8 +1152,8 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(restoredEntries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -1327,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]
|
||||
@@ -1463,22 +1426,18 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
)
|
||||
expect(hydratedEntries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('widgets getter caching', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
@@ -1486,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
|
||||
},
|
||||
@@ -1506,9 +1465,9 @@ describe('widgets getter caching', () => {
|
||||
})
|
||||
|
||||
test('does not re-run reconciliation when only canvas frame advances', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
@@ -1516,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
|
||||
},
|
||||
@@ -1573,19 +1532,19 @@ describe('widgets getter caching', () => {
|
||||
})
|
||||
|
||||
test('preserves view identities when promotion order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
|
||||
const [viewA, viewB] = subgraphNode.widgets
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA']
|
||||
[innerIds[0], 'widgetB'],
|
||||
[innerIds[0], 'widgetA']
|
||||
])
|
||||
|
||||
expect(subgraphNode.widgets[0]).toBe(viewB)
|
||||
@@ -1593,15 +1552,15 @@ describe('widgets getter caching', () => {
|
||||
})
|
||||
|
||||
test('deduplicates by key while preserving first-occurrence order', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA']
|
||||
[innerIds[0], 'widgetB'],
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB'],
|
||||
[innerIds[0], 'widgetA']
|
||||
])
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
@@ -1610,9 +1569,9 @@ describe('widgets getter caching', () => {
|
||||
})
|
||||
|
||||
test('returns same array reference when promotions unchanged', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
|
||||
const result1 = subgraphNode.widgets
|
||||
const result2 = subgraphNode.widgets
|
||||
@@ -1620,16 +1579,16 @@ describe('widgets getter caching', () => {
|
||||
})
|
||||
|
||||
test('returns new array after promotion change', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
|
||||
const result1 = subgraphNode.widgets
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
const result2 = subgraphNode.widgets
|
||||
|
||||
@@ -1638,12 +1597,12 @@ describe('widgets getter caching', () => {
|
||||
})
|
||||
|
||||
test('invalidates cache on removeWidget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
|
||||
const result1 = subgraphNode.widgets
|
||||
@@ -1657,30 +1616,26 @@ describe('widgets getter caching', () => {
|
||||
})
|
||||
|
||||
describe('promote/demote cycle', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('promoting adds to store and widgets reflects it', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
const view = subgraphNode.widgets[0] as PromotedWidgetView
|
||||
expect(view.sourceNodeId).toBe('1')
|
||||
expect(view.sourceNodeId).toBe(innerIds[0])
|
||||
expect(view.sourceWidgetName).toBe('widgetA')
|
||||
})
|
||||
|
||||
test('demoting via removeWidget removes from store', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
|
||||
const viewA = subgraphNode.widgets[0]
|
||||
@@ -1693,16 +1648,16 @@ describe('promote/demote cycle', () => {
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
test('full promote → demote → re-promote cycle', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
// Promote
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
const view1 = subgraphNode.widgets[0]
|
||||
|
||||
@@ -1711,7 +1666,7 @@ describe('promote/demote cycle', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
// Re-promote — creates a new view since the cache was cleared
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0]).not.toBe(view1)
|
||||
expect(
|
||||
@@ -1721,22 +1676,18 @@ describe('promote/demote cycle', () => {
|
||||
})
|
||||
|
||||
describe('disconnected state', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('view resolves type when interior widget exists', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('number', 'numWidget', 42, () => {})
|
||||
setPromotions(subgraphNode, [['1', 'numWidget']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'numWidget']])
|
||||
|
||||
expect(subgraphNode.widgets[0].type).toBe('number')
|
||||
})
|
||||
|
||||
test('keeps promoted entry as disconnected when interior node is removed', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'myWidget']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
|
||||
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
|
||||
@@ -1747,9 +1698,9 @@ describe('disconnected state', () => {
|
||||
})
|
||||
|
||||
test('view recovers when interior widget is re-added', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'myWidget']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
|
||||
|
||||
// Remove widget
|
||||
innerNodes[0].widgets!.pop()
|
||||
@@ -1768,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 }))
|
||||
@@ -1831,10 +1973,6 @@ function createTwoLevelNestedSubgraph() {
|
||||
}
|
||||
|
||||
describe('promoted combo rendering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('draw shows value even when interior combo is computedDisabled', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -2120,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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2151,7 +2289,6 @@ describe('promoted combo rendering', () => {
|
||||
|
||||
describe('DOM widget promotion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -2175,9 +2312,9 @@ describe('DOM widget promotion', () => {
|
||||
}
|
||||
|
||||
test('draw registers position override for DOM widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
createMockDOMWidget(innerNodes[0], 'textarea')
|
||||
setPromotions(subgraphNode, [['1', 'textarea']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
||||
@@ -2189,9 +2326,9 @@ describe('DOM widget promotion', () => {
|
||||
})
|
||||
|
||||
test('draw registers position override for component widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
createMockComponentWidget(innerNodes[0], 'compWidget')
|
||||
setPromotions(subgraphNode, [['1', 'compWidget']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'compWidget']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
||||
@@ -2203,9 +2340,9 @@ describe('DOM widget promotion', () => {
|
||||
})
|
||||
|
||||
test('draw does not register override for non-DOM widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'textWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'textWidget']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'textWidget']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30, true)
|
||||
@@ -2232,14 +2369,14 @@ describe('DOM widget promotion', () => {
|
||||
})
|
||||
|
||||
test('computeLayoutSize delegates to interior DOM widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const domWidget = createMockDOMWidget(innerNodes[0], 'textarea')
|
||||
domWidget.computeLayoutSize = vi.fn(() => ({
|
||||
minHeight: 100,
|
||||
maxHeight: 300,
|
||||
minWidth: 0
|
||||
}))
|
||||
setPromotions(subgraphNode, [['1', 'textarea']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
const result = view.computeLayoutSize!(subgraphNode)
|
||||
@@ -2248,9 +2385,9 @@ describe('DOM widget promotion', () => {
|
||||
})
|
||||
|
||||
test('demoting clears position override for DOM widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
createMockDOMWidget(innerNodes[0], 'textarea')
|
||||
setPromotions(subgraphNode, [['1', 'textarea']])
|
||||
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
subgraphNode.removeWidget(view)
|
||||
@@ -2261,12 +2398,12 @@ describe('DOM widget promotion', () => {
|
||||
})
|
||||
|
||||
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
createMockDOMWidget(innerNodes[0], 'widgetA')
|
||||
createMockDOMWidget(innerNodes[0], 'widgetB')
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
|
||||
// Access widgets to populate cache
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphI
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -61,6 +62,7 @@ function addLinkedInteriorInput(
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -121,6 +123,21 @@ describe('resolveSubgraphInputLink', () => {
|
||||
expect(result).toBe('seed_input')
|
||||
})
|
||||
|
||||
test('resolves the first connected link when multiple links exist', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
|
||||
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
|
||||
|
||||
const result = resolveSubgraphInputLink(
|
||||
subgraphNode,
|
||||
'prompt',
|
||||
({ targetInput }) => targetInput.name
|
||||
)
|
||||
|
||||
// First connected wins — consistent with SubgraphNode._resolveLinkedPromotionBySubgraphInput
|
||||
expect(result).toBe('first_input')
|
||||
})
|
||||
|
||||
test('caches getTargetWidget result within the same callback evaluation', () => {
|
||||
const { subgraph, subgraphNode } = createSubgraphSetup('model')
|
||||
const linked = addLinkedInteriorInput(
|
||||
@@ -144,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,9 +19,9 @@ export function resolveSubgraphInputLink<TResult>(
|
||||
)
|
||||
if (!inputSlot) return undefined
|
||||
|
||||
// Iterate from newest to oldest so the latest connection wins.
|
||||
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
|
||||
const linkId = inputSlot.linkIds[index]
|
||||
// Iterate forward so the first connected source is the promoted representative,
|
||||
// matching SubgraphNode._resolveLinkedPromotionBySubgraphInput.
|
||||
for (const linkId of inputSlot.linkIds) {
|
||||
const link = node.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -23,33 +24,35 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[]] {
|
||||
): [SubgraphNode, LGraphNode[], string[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
const innerNodes = []
|
||||
const innerNodes: LGraphNode[] = []
|
||||
for (let i = 0; i < innerNodeCount; i++) {
|
||||
const innerNode = new LGraphNode(`InnerNode${i}`)
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
return [subgraphNode, innerNodes]
|
||||
const innerIds = innerNodes.map((n) => String(n.id))
|
||||
return [subgraphNode, innerNodes, innerIds]
|
||||
}
|
||||
|
||||
describe('Subgraph proxyWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
test('Can add simple widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
@@ -57,18 +60,20 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(2)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
|
||||
for (const innerNode of innerNodes)
|
||||
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'stringWidget' },
|
||||
{ interiorNodeId: '2', widgetName: 'stringWidget' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
|
||||
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
|
||||
]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
@@ -77,14 +82,14 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
|
||||
})
|
||||
test('Will reflect proxyWidgets order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', 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')
|
||||
@@ -92,19 +97,19 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
// Reorder
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' },
|
||||
{ interiorNodeId: '1', 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')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
@@ -114,12 +119,12 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
@@ -133,12 +138,12 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
|
||||
})
|
||||
test('Renders placeholder when interior widget is detached', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
|
||||
@@ -154,46 +159,44 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
})
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
// Promote once
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(innerNodes[0].id),
|
||||
'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,
|
||||
String(innerNodes[0].id),
|
||||
'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)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
|
||||
test('removeWidget removes from promotion list and view cache', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
|
||||
@@ -204,35 +207,38 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'widgetB' }])
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
test('removeWidgetByName removes from promotion list', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
test('removeWidget removes from promotion list', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', 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')
|
||||
})
|
||||
|
||||
test('removeWidget cleans up input references', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
@@ -248,12 +254,12 @@ describe('Subgraph proxyWidgets', () => {
|
||||
})
|
||||
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
|
||||
@@ -265,23 +271,138 @@ describe('Subgraph proxyWidgets', () => {
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual([
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
[innerIds[0], 'widgetA'],
|
||||
[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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
48
src/core/schemas/promotionSchema.test.ts
Normal file
48
src/core/schemas/promotionSchema.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseProxyWidgets } from './promotionSchema'
|
||||
|
||||
describe(parseProxyWidgets, () => {
|
||||
it('parses 2-tuple arrays', () => {
|
||||
const input = [
|
||||
['10', 'seed'],
|
||||
['11', 'steps']
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual([
|
||||
['10', 'seed'],
|
||||
['11', 'steps']
|
||||
])
|
||||
})
|
||||
|
||||
it('parses 3-tuple arrays', () => {
|
||||
const input = [
|
||||
['3', 'text', '1'],
|
||||
['3', 'text', '2']
|
||||
]
|
||||
expect(parseProxyWidgets(input)).toEqual([
|
||||
['3', 'text', '1'],
|
||||
['3', 'text', '2']
|
||||
])
|
||||
})
|
||||
|
||||
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 non-array input', () => {
|
||||
expect(parseProxyWidgets(undefined)).toEqual([])
|
||||
expect(parseProxyWidgets('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for invalid tuples', () => {
|
||||
expect(parseProxyWidgets([['only-one']])).toEqual([])
|
||||
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -3,18 +3,27 @@ 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(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetsProperty {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = proxyWidgetsPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data
|
||||
try {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = proxyWidgetsPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
throw new Error(`Invalid assignment for properties.proxyWidgets:\n${error}`)
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(`Invalid assignment for properties.proxyWidgets:\n${error}`)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.proxyWidgets:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import dompurify from 'dompurify'
|
||||
|
||||
import type {
|
||||
ContextMenuDivElement,
|
||||
@@ -16,7 +16,7 @@ const ALLOWED_STYLE_PROPS = new Set([
|
||||
'border-left'
|
||||
])
|
||||
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
dompurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
const sanitizedStyle = data.attrValue
|
||||
.split(';')
|
||||
@@ -33,7 +33,7 @@ DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
})
|
||||
|
||||
function sanitizeMenuHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
return dompurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ['style']
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3028,14 +3028,14 @@ export class Subgraph
|
||||
* @param input The input slot to remove.
|
||||
*/
|
||||
removeInput(input: SubgraphInput): void {
|
||||
input.disconnect()
|
||||
|
||||
const index = this.inputs.indexOf(input)
|
||||
if (index === -1) throw new Error('Input not found')
|
||||
|
||||
const mayContinue = this.events.dispatch('removing-input', { input, index })
|
||||
if (!mayContinue) return
|
||||
|
||||
input.disconnect()
|
||||
|
||||
this.inputs.splice(index, 1)
|
||||
|
||||
const { length } = this.inputs
|
||||
@@ -3049,8 +3049,6 @@ export class Subgraph
|
||||
* @param output The output slot to remove.
|
||||
*/
|
||||
removeOutput(output: SubgraphOutput): void {
|
||||
output.disconnect()
|
||||
|
||||
const index = this.outputs.indexOf(output)
|
||||
if (index === -1) throw new Error('Output not found')
|
||||
|
||||
@@ -3060,6 +3058,8 @@ export class Subgraph
|
||||
})
|
||||
if (!mayContinue) return
|
||||
|
||||
output.disconnect()
|
||||
|
||||
this.outputs.splice(index, 1)
|
||||
|
||||
const { length } = this.outputs
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -87,7 +87,7 @@ export { ContextMenu } from './ContextMenu'
|
||||
export { DragAndScale } from './DragAndScale'
|
||||
|
||||
export { Rectangle } from './infrastructure/Rectangle'
|
||||
export { RecursionError } from './infrastructure/RecursionError'
|
||||
export type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
|
||||
export type {
|
||||
CanvasColour,
|
||||
ColorOption,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,10 +12,16 @@ import {
|
||||
import {
|
||||
createNestedSubgraphs,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('ExecutableNodeDTO Creation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('ExecutableNodeDTO Creation', () => {
|
||||
it('should create DTO from regular node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
@@ -106,7 +111,7 @@ describe.skip('ExecutableNodeDTO Creation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Path-Based IDs', () => {
|
||||
describe('ExecutableNodeDTO Path-Based IDs', () => {
|
||||
it('should generate simple ID for root node', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Root Node')
|
||||
@@ -160,7 +165,7 @@ describe.skip('ExecutableNodeDTO Path-Based IDs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Input Resolution', () => {
|
||||
describe('ExecutableNodeDTO Input Resolution', () => {
|
||||
it('should return undefined for unconnected inputs', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
@@ -202,7 +207,7 @@ describe.skip('ExecutableNodeDTO Input Resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Output Resolution', () => {
|
||||
describe('ExecutableNodeDTO Output Resolution', () => {
|
||||
it('should resolve outputs for simple nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
@@ -478,7 +483,7 @@ describe('Virtual node resolveVirtualOutput', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Properties', () => {
|
||||
describe('ExecutableNodeDTO Properties', () => {
|
||||
it('should provide access to basic properties', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
@@ -513,7 +518,7 @@ describe.skip('ExecutableNodeDTO Properties', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
|
||||
describe('ExecutableNodeDTO Memory Efficiency', () => {
|
||||
it('should create lightweight objects', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Test Node')
|
||||
@@ -537,7 +542,7 @@ describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
|
||||
expect(dto.hasOwnProperty('widgets')).toBe(false) // Widgets not copied
|
||||
})
|
||||
|
||||
it('should handle disposal without memory leaks', () => {
|
||||
it('should drop local references without explicit disposal', () => {
|
||||
const graph = new LGraph()
|
||||
const nodes: ExecutableNodeDTO[] = []
|
||||
|
||||
@@ -580,19 +585,20 @@ describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Integration', () => {
|
||||
describe('ExecutableNodeDTO Integration', () => {
|
||||
it('should work with SubgraphNode flattening', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 3 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const flattened = subgraphNode.getInnerNodes(new Map())
|
||||
|
||||
const idPattern = new RegExp(`^${subgraphNode.id}:\\d+$`)
|
||||
expect(flattened).toHaveLength(3)
|
||||
expect(flattened[0]).toBeInstanceOf(ExecutableNodeDTO)
|
||||
expect(flattened[0].id).toMatch(/^1:\d+$/)
|
||||
expect(flattened[0].id).toMatch(idPattern)
|
||||
})
|
||||
|
||||
it.skip('should handle nested subgraph flattening', () => {
|
||||
it('should handle nested subgraph flattening', () => {
|
||||
// FIXME: Complex nested structure requires proper parent graph setup
|
||||
// This test needs investigation of how resolveSubgraphIdPath works
|
||||
// Skip for now - will implement in edge cases test file
|
||||
@@ -654,7 +660,7 @@ describe.skip('ExecutableNodeDTO Integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Scale Testing', () => {
|
||||
describe('ExecutableNodeDTO Scale Testing', () => {
|
||||
it('should create DTOs at scale', () => {
|
||||
const graph = new LGraph()
|
||||
const startTime = performance.now()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
// TODO: Fix these tests after migration
|
||||
/**
|
||||
* Core Subgraph Tests
|
||||
*
|
||||
* This file implements fundamental tests for the Subgraph class that establish
|
||||
* patterns for the rest of the testing team. These tests cover construction,
|
||||
* basic I/O management, and known issues.
|
||||
* patterns for the rest of the testing team. These tests cover construction
|
||||
* and basic I/O management.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createUuidv4,
|
||||
RecursionError,
|
||||
LGraph,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
assertSubgraphStructure,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphData
|
||||
createTestSubgraphData,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('Subgraph Construction', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('Subgraph Construction', () => {
|
||||
it('should create a subgraph with minimal data', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
@@ -44,11 +47,10 @@ describe.skip('Subgraph Construction', () => {
|
||||
|
||||
it('should require a root graph', () => {
|
||||
const subgraphData = createTestSubgraphData()
|
||||
const createWithoutRoot = () =>
|
||||
new Subgraph(null as unknown as LGraph, subgraphData)
|
||||
|
||||
expect(() => {
|
||||
// @ts-expect-error Testing invalid null parameter
|
||||
new Subgraph(null, subgraphData)
|
||||
}).toThrow('Root graph is required')
|
||||
expect(createWithoutRoot).toThrow('Root graph is required')
|
||||
})
|
||||
|
||||
it('should accept custom name and ID', () => {
|
||||
@@ -63,31 +65,9 @@ describe.skip('Subgraph Construction', () => {
|
||||
expect(subgraph.id).toBe(customId)
|
||||
expect(subgraph.name).toBe(customName)
|
||||
})
|
||||
|
||||
it('should initialize with empty inputs and outputs', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
expect(subgraph.inputs).toHaveLength(0)
|
||||
expect(subgraph.outputs).toHaveLength(0)
|
||||
expect(subgraph.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should have properly configured input and output nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Input node should be positioned on the left
|
||||
expect(subgraph.inputNode.pos[0]).toBeLessThan(100)
|
||||
|
||||
// Output node should be positioned on the right
|
||||
expect(subgraph.outputNode.pos[0]).toBeGreaterThan(300)
|
||||
|
||||
// Both should reference the subgraph
|
||||
expect(subgraph.inputNode.subgraph).toBe(subgraph)
|
||||
expect(subgraph.outputNode.subgraph).toBe(subgraph)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Subgraph Input/Output Management', () => {
|
||||
describe('Subgraph Input/Output Management', () => {
|
||||
subgraphTest('should add a single input', ({ emptySubgraph }) => {
|
||||
const input = emptySubgraph.addInput('test_input', 'number')
|
||||
|
||||
@@ -164,163 +144,3 @@ describe.skip('Subgraph Input/Output Management', () => {
|
||||
expect(simpleSubgraph.outputs.indexOf(simpleSubgraph.outputs[0])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Subgraph Serialization', () => {
|
||||
subgraphTest('should serialize empty subgraph', ({ emptySubgraph }) => {
|
||||
const serialized = emptySubgraph.asSerialisable()
|
||||
|
||||
expect(serialized.version).toBe(1)
|
||||
expect(serialized.id).toBeTruthy()
|
||||
expect(serialized.name).toBe('Empty Test Subgraph')
|
||||
expect(serialized.inputs).toHaveLength(0)
|
||||
expect(serialized.outputs).toHaveLength(0)
|
||||
expect(serialized.nodes).toHaveLength(0)
|
||||
expect(typeof serialized.links).toBe('object')
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should serialize subgraph with inputs and outputs',
|
||||
({ simpleSubgraph }) => {
|
||||
const serialized = simpleSubgraph.asSerialisable()
|
||||
|
||||
expect(serialized.inputs).toHaveLength(1)
|
||||
expect(serialized.outputs).toHaveLength(1)
|
||||
expect(serialized.inputs![0].name).toBe('input')
|
||||
expect(serialized.inputs![0].type).toBe('number')
|
||||
expect(serialized.outputs![0].name).toBe('output')
|
||||
expect(serialized.outputs![0].type).toBe('number')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'should include input and output nodes in serialization',
|
||||
({ emptySubgraph }) => {
|
||||
const serialized = emptySubgraph.asSerialisable()
|
||||
|
||||
expect(serialized.inputNode).toBeDefined()
|
||||
expect(serialized.outputNode).toBeDefined()
|
||||
expect(serialized.inputNode.id).toBe(-10)
|
||||
expect(serialized.outputNode.id).toBe(-20)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('Subgraph Known Issues', () => {
|
||||
it.todo('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
|
||||
// This test documents that MAX_NESTED_SUBGRAPHS = 1000 is defined
|
||||
// but not actually enforced anywhere in the code.
|
||||
//
|
||||
// Expected behavior: Should throw error when nesting exceeds limit
|
||||
// Actual behavior: No validation is performed
|
||||
//
|
||||
// This safety limit should be implemented to prevent runaway recursion.
|
||||
})
|
||||
|
||||
it('should provide MAX_NESTED_SUBGRAPHS constant', () => {
|
||||
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
|
||||
})
|
||||
|
||||
it('should have recursion detection in place', () => {
|
||||
// Verify that RecursionError is available and can be thrown
|
||||
expect(() => {
|
||||
throw new RecursionError('test recursion')
|
||||
}).toThrow(RecursionError)
|
||||
|
||||
expect(() => {
|
||||
throw new RecursionError('test recursion')
|
||||
}).toThrow('test recursion')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Subgraph Root Graph Relationship', () => {
|
||||
it('should maintain reference to root graph', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraphData = createTestSubgraphData()
|
||||
const subgraph = new Subgraph(rootGraph, subgraphData)
|
||||
|
||||
expect(subgraph.rootGraph).toBe(rootGraph)
|
||||
})
|
||||
|
||||
it('should inherit root graph in nested subgraphs', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const parentData = createTestSubgraphData({
|
||||
name: 'Parent Subgraph'
|
||||
})
|
||||
const parentSubgraph = new Subgraph(rootGraph, parentData)
|
||||
|
||||
// Create a nested subgraph
|
||||
const nestedData = createTestSubgraphData({
|
||||
name: 'Nested Subgraph'
|
||||
})
|
||||
const nestedSubgraph = new Subgraph(rootGraph, nestedData)
|
||||
|
||||
expect(nestedSubgraph.rootGraph).toBe(rootGraph)
|
||||
expect(parentSubgraph.rootGraph).toBe(rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Subgraph Error Handling', () => {
|
||||
subgraphTest(
|
||||
'should handle removing non-existent input gracefully',
|
||||
({ emptySubgraph }) => {
|
||||
// Create a fake input that doesn't belong to this subgraph
|
||||
const fakeInput = emptySubgraph.addInput('temp', 'number')
|
||||
emptySubgraph.removeInput(fakeInput) // Remove it first
|
||||
|
||||
// Now try to remove it again
|
||||
expect(() => {
|
||||
emptySubgraph.removeInput(fakeInput)
|
||||
}).toThrow('Input not found')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'should handle removing non-existent output gracefully',
|
||||
({ emptySubgraph }) => {
|
||||
// Create a fake output that doesn't belong to this subgraph
|
||||
const fakeOutput = emptySubgraph.addOutput('temp', 'number')
|
||||
emptySubgraph.removeOutput(fakeOutput) // Remove it first
|
||||
|
||||
// Now try to remove it again
|
||||
expect(() => {
|
||||
emptySubgraph.removeOutput(fakeOutput)
|
||||
}).toThrow('Output not found')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('Subgraph Integration', () => {
|
||||
it("should work with LGraph's node management", () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
nodeCount: 3
|
||||
})
|
||||
|
||||
// Verify nodes were added to the subgraph
|
||||
expect(subgraph.nodes).toHaveLength(3)
|
||||
|
||||
// Verify we can access nodes by ID
|
||||
const firstNode = subgraph.getNodeById(1)
|
||||
expect(firstNode).toBeDefined()
|
||||
expect(firstNode?.title).toContain('Test Node')
|
||||
})
|
||||
|
||||
it('should maintain link integrity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
nodeCount: 2
|
||||
})
|
||||
|
||||
const node1 = subgraph.nodes[0]
|
||||
const node2 = subgraph.nodes[1]
|
||||
|
||||
// Connect the nodes
|
||||
node1.connect(0, node2, 0)
|
||||
|
||||
// Verify link was created
|
||||
expect(subgraph.links.size).toBe(1)
|
||||
|
||||
// Verify link integrity
|
||||
const link = Array.from(subgraph.links.values())[0]
|
||||
expect(link.origin_id).toBe(node1.id)
|
||||
expect(link.target_id).toBe(node2.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { assert, describe, expect, it } from 'vitest'
|
||||
import { assert, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import {
|
||||
LGraphGroup,
|
||||
@@ -8,11 +9,19 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function createNode(
|
||||
graph: LGraph,
|
||||
inputs: ISlotType[] = [],
|
||||
@@ -40,8 +49,8 @@ function createNode(
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
describe.skip('SubgraphConversion', () => {
|
||||
describe.skip('Subgraph Unpacking Functionality', () => {
|
||||
describe('SubgraphConversion', () => {
|
||||
describe('Subgraph Unpacking Functionality', () => {
|
||||
it('Should keep interior nodes and links', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -197,4 +206,39 @@ describe.skip('SubgraphConversion', () => {
|
||||
expect(linkRefCount).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promotion cleanup on unpack', () => {
|
||||
it('Should clear promotions for the unpacked subgraph node', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const innerNode = createNode(subgraph, [], ['number'])
|
||||
innerNode.addWidget('text', 'myWidget', 'default', () => {})
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
const graphId = graph.id
|
||||
const subgraphNodeId = subgraphNode.id
|
||||
|
||||
promotionStore.promote(graphId, subgraphNodeId, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'myWidget'
|
||||
})
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted(graphId, subgraphNodeId, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'myWidget'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.getNodeById(subgraphNodeId)).toBeUndefined()
|
||||
expect(
|
||||
promotionStore.getPromotions(graphId, subgraphNodeId)
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
// TODO: Fix these tests after migration
|
||||
/**
|
||||
* SubgraphEdgeCases Tests
|
||||
*
|
||||
* Tests for edge cases, error handling, and boundary conditions in the subgraph system.
|
||||
* This covers unusual scenarios, invalid states, and stress testing.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createNestedSubgraphs,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphEdgeCases - Recursion Detection', () => {
|
||||
it('should handle circular subgraph references without crashing', () => {
|
||||
const sub1 = createTestSubgraph({ name: 'Sub1' })
|
||||
const sub2 = createTestSubgraph({ name: 'Sub2' })
|
||||
@@ -24,14 +31,11 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
|
||||
const node1 = createTestSubgraphNode(sub1, { id: 1 })
|
||||
const node2 = createTestSubgraphNode(sub2, { id: 2 })
|
||||
|
||||
// Current limitation: adding a circular reference overflows recursion depth.
|
||||
sub1.add(node2)
|
||||
sub2.add(node1)
|
||||
|
||||
// Should not crash or hang - currently throws path resolution error due to circular structure
|
||||
expect(() => {
|
||||
const executableNodes = new Map()
|
||||
node1.getInnerNodes(executableNodes)
|
||||
}).toThrow(/Node \[\d+\] not found/) // Current behavior: path resolution fails
|
||||
sub2.add(node1)
|
||||
}).toThrow(RangeError)
|
||||
})
|
||||
|
||||
it('should handle deep nesting scenarios', () => {
|
||||
@@ -48,20 +52,14 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
|
||||
expect(firstLevel.isSubgraphNode()).toBe(true)
|
||||
})
|
||||
|
||||
it.todo('should use WeakSet for cycle detection', () => {
|
||||
// TODO: This test is currently skipped because cycle detection has a bug
|
||||
// The fix is to pass 'visited' directly instead of 'new Set(visited)' in SubgraphNode.ts:299
|
||||
it('should throw RangeError for self-referential subgraph', () => {
|
||||
// Current limitation: creating self-referential subgraph instances overflows recursion depth.
|
||||
const subgraph = createTestSubgraph({ nodeCount: 1 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Add to own subgraph to create cycle
|
||||
subgraph.add(subgraphNode)
|
||||
|
||||
// Should throw due to cycle detection
|
||||
const executableNodes = new Map()
|
||||
expect(() => {
|
||||
subgraphNode.getInnerNodes(executableNodes)
|
||||
}).toThrow(/while flattening subgraph/i)
|
||||
subgraph.add(subgraphNode)
|
||||
}).toThrow(RangeError)
|
||||
})
|
||||
|
||||
it('should respect MAX_NESTED_SUBGRAPHS constant', () => {
|
||||
@@ -76,7 +74,7 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEdgeCases - Invalid States', () => {
|
||||
describe('SubgraphEdgeCases - Invalid States', () => {
|
||||
it('should handle removing non-existent inputs gracefully', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const fakeInput = {
|
||||
@@ -120,7 +118,9 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
|
||||
|
||||
expect(() => {
|
||||
subgraph.addInput(undefinedString, 'number')
|
||||
}).toThrow()
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.inputs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle null/undefined output names', () => {
|
||||
@@ -135,7 +135,9 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
|
||||
|
||||
expect(() => {
|
||||
subgraph.addOutput(undefinedString, 'number')
|
||||
}).toThrow()
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.outputs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle empty string names', () => {
|
||||
@@ -160,11 +162,14 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
|
||||
// Undefined type should throw error
|
||||
expect(() => {
|
||||
subgraph.addInput('test', undefinedString)
|
||||
}).toThrow()
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => {
|
||||
subgraph.addOutput('test', undefinedString)
|
||||
}).toThrow()
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.inputs).toHaveLength(1)
|
||||
expect(subgraph.outputs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle duplicate slot names', () => {
|
||||
@@ -185,7 +190,7 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEdgeCases - Boundary Conditions', () => {
|
||||
describe('SubgraphEdgeCases - Boundary Conditions', () => {
|
||||
it('should handle empty subgraphs (no nodes, no IO)', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 0 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -239,35 +244,9 @@ describe.skip('SubgraphEdgeCases - Boundary Conditions', () => {
|
||||
const flattened = subgraphNode.getInnerNodes(executableNodes)
|
||||
expect(flattened).toHaveLength(1) // Original node count
|
||||
})
|
||||
|
||||
it('should handle very long slot names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const longName = 'a'.repeat(1000) // 1000 character name
|
||||
|
||||
expect(() => {
|
||||
subgraph.addInput(longName, 'number')
|
||||
subgraph.addOutput(longName, 'string')
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.inputs[0].name).toBe(longName)
|
||||
expect(subgraph.outputs[0].name).toBe(longName)
|
||||
})
|
||||
|
||||
it('should handle Unicode characters in names', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const unicodeName = '测试_🚀_تست_тест'
|
||||
|
||||
expect(() => {
|
||||
subgraph.addInput(unicodeName, 'number')
|
||||
subgraph.addOutput(unicodeName, 'string')
|
||||
}).not.toThrow()
|
||||
|
||||
expect(subgraph.inputs[0].name).toBe(unicodeName)
|
||||
expect(subgraph.outputs[0].name).toBe(unicodeName)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEdgeCases - Type Validation', () => {
|
||||
describe('SubgraphEdgeCases - Type Validation', () => {
|
||||
it('should allow connecting mismatched types (no validation currently)', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph()
|
||||
@@ -289,18 +268,6 @@ describe.skip('SubgraphEdgeCases - Type Validation', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle invalid type strings', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// These should not crash (current behavior)
|
||||
expect(() => {
|
||||
subgraph.addInput('test1', 'invalid_type')
|
||||
subgraph.addInput('test2', '')
|
||||
subgraph.addInput('test3', '123')
|
||||
subgraph.addInput('test4', 'special!@#$%')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle complex type strings', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
@@ -317,7 +284,7 @@ describe.skip('SubgraphEdgeCases - Type Validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
describe('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
it('should handle large numbers of nodes in subgraph', () => {
|
||||
// Create subgraph with many nodes (keep reasonable for test speed)
|
||||
const subgraph = createTestSubgraph({ nodeCount: 50 })
|
||||
@@ -348,35 +315,4 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
expect(subgraph.inputs).toHaveLength(0)
|
||||
expect(subgraph.outputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle concurrent modifications safely', () => {
|
||||
// This test ensures the system doesn't crash under concurrent access
|
||||
// Note: JavaScript is single-threaded, so this tests rapid sequential access
|
||||
const subgraph = createTestSubgraph({ nodeCount: 5 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Simulate concurrent operations
|
||||
const operations: Array<() => void> = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
operations.push(
|
||||
() => {
|
||||
const executableNodes = new Map()
|
||||
subgraphNode.getInnerNodes(executableNodes)
|
||||
},
|
||||
() => {
|
||||
subgraph.addInput(`concurrent_${i}`, 'number')
|
||||
},
|
||||
() => {
|
||||
if (subgraph.inputs.length > 0) {
|
||||
subgraph.removeInput(subgraph.inputs[0])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Execute all operations - should not crash
|
||||
expect(() => {
|
||||
for (const op of operations) op()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import { verifyEventSequence } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
describe('SubgraphEvents - Event Payload Verification', () => {
|
||||
subgraphTest(
|
||||
'dispatches input-added with correct payload',
|
||||
({ eventCapture }) => {
|
||||
@@ -199,9 +200,9 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEvents - Event Handler Isolation', () => {
|
||||
describe('SubgraphEvents - Event Handler Isolation', () => {
|
||||
subgraphTest(
|
||||
'continues dispatching if handler throws',
|
||||
'surfaces handler errors to caller and stops propagation',
|
||||
({ emptySubgraph }) => {
|
||||
const handler1 = vi.fn(() => {
|
||||
throw new Error('Handler 1 error')
|
||||
@@ -213,15 +214,15 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
|
||||
emptySubgraph.events.addEventListener('input-added', handler2)
|
||||
emptySubgraph.events.addEventListener('input-added', handler3)
|
||||
|
||||
// The operation itself should not throw (error is isolated)
|
||||
// Current runtime behavior: listener exceptions bubble out of dispatch.
|
||||
expect(() => {
|
||||
emptySubgraph.addInput('test', 'number')
|
||||
}).not.toThrow()
|
||||
}).toThrowError('Handler 1 error')
|
||||
|
||||
// Verify all handlers were called despite the first one throwing
|
||||
// Once the first listener throws, later listeners are not invoked.
|
||||
expect(handler1).toHaveBeenCalled()
|
||||
expect(handler2).toHaveBeenCalled()
|
||||
expect(handler3).toHaveBeenCalled()
|
||||
expect(handler2).not.toHaveBeenCalled()
|
||||
expect(handler3).not.toHaveBeenCalled()
|
||||
|
||||
// Verify the throwing handler actually received the event
|
||||
expect(handler1).toHaveBeenCalledWith(
|
||||
@@ -229,24 +230,6 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
|
||||
type: 'input-added'
|
||||
})
|
||||
)
|
||||
|
||||
// Verify other handlers received correct event data
|
||||
expect(handler2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'input-added',
|
||||
detail: expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
name: 'test',
|
||||
type: 'number'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(handler3).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'input-added'
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -305,7 +288,7 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEvents - Event Sequence Testing', () => {
|
||||
describe('SubgraphEvents - Event Sequence Testing', () => {
|
||||
subgraphTest(
|
||||
'maintains correct event sequence for inputs',
|
||||
({ eventCapture }) => {
|
||||
@@ -351,7 +334,7 @@ describe.skip('SubgraphEvents - Event Sequence Testing', () => {
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest('handles concurrent event handling', ({ eventCapture }) => {
|
||||
subgraphTest('fires all listeners synchronously', ({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
|
||||
const handler1 = vi.fn(() => {
|
||||
@@ -393,7 +376,7 @@ describe.skip('SubgraphEvents - Event Sequence Testing', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEvents - Event Cancellation', () => {
|
||||
describe('SubgraphEvents - Event Cancellation', () => {
|
||||
subgraphTest(
|
||||
'supports preventDefault() for cancellable events',
|
||||
({ emptySubgraph }) => {
|
||||
@@ -443,71 +426,78 @@ describe.skip('SubgraphEvents - Event Cancellation', () => {
|
||||
expect(emptySubgraph.inputs).toHaveLength(0)
|
||||
expect(allowHandler).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphEvents - Event Detail Structure Validation', () => {
|
||||
subgraphTest('veto preserves input connections', ({ emptySubgraph }) => {
|
||||
const input = emptySubgraph.addInput('test', 'number')
|
||||
|
||||
const node = new LGraphNode('Interior')
|
||||
node.addInput('in', 'number')
|
||||
emptySubgraph.add(node)
|
||||
|
||||
input.connect(node.inputs[0], node)
|
||||
expect(input.linkIds).not.toHaveLength(0)
|
||||
|
||||
emptySubgraph.events.addEventListener('removing-input', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
emptySubgraph.removeInput(input)
|
||||
|
||||
expect(emptySubgraph.inputs).toContain(input)
|
||||
expect(input.linkIds).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
subgraphTest('veto preserves output connections', ({ emptySubgraph }) => {
|
||||
const output = emptySubgraph.addOutput('test', 'number')
|
||||
|
||||
const node = new LGraphNode('Interior')
|
||||
node.addOutput('out', 'number')
|
||||
emptySubgraph.add(node)
|
||||
|
||||
output.connect(node.outputs[0], node)
|
||||
expect(output.linkIds).not.toHaveLength(0)
|
||||
|
||||
emptySubgraph.events.addEventListener('removing-output', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
emptySubgraph.removeOutput(output)
|
||||
|
||||
expect(emptySubgraph.outputs).toContain(output)
|
||||
expect(output.linkIds).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'validates all event detail structures match TypeScript types',
|
||||
({ eventCapture }) => {
|
||||
const { subgraph, capture } = eventCapture
|
||||
'rename input cancellation does not prevent rename',
|
||||
({ emptySubgraph }) => {
|
||||
const input = emptySubgraph.addInput('original', 'number')
|
||||
|
||||
const input = subgraph.addInput('test_input', 'number')
|
||||
subgraph.renameInput(input, 'renamed_input')
|
||||
subgraph.removeInput(input)
|
||||
|
||||
const output = subgraph.addOutput('test_output', 'string')
|
||||
subgraph.renameOutput(output, 'renamed_output')
|
||||
subgraph.removeOutput(output)
|
||||
|
||||
const addingInputEvent = capture.getEventsByType('adding-input')[0]
|
||||
expect(addingInputEvent.detail).toEqual({
|
||||
name: expect.any(String),
|
||||
type: expect.any(String)
|
||||
const preventHandler = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
emptySubgraph.events.addEventListener('renaming-input', preventHandler)
|
||||
|
||||
const inputAddedEvent = capture.getEventsByType('input-added')[0]
|
||||
expect(inputAddedEvent.detail).toEqual({
|
||||
input: expect.any(Object)
|
||||
})
|
||||
emptySubgraph.renameInput(input, 'new_name')
|
||||
|
||||
const renamingInputEvent = capture.getEventsByType('renaming-input')[0]
|
||||
expect(renamingInputEvent.detail).toEqual({
|
||||
input: expect.any(Object),
|
||||
index: expect.any(Number),
|
||||
oldName: expect.any(String),
|
||||
newName: expect.any(String)
|
||||
})
|
||||
expect(input.label).toBe('new_name')
|
||||
expect(preventHandler).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
const removingInputEvent = capture.getEventsByType('removing-input')[0]
|
||||
expect(removingInputEvent.detail).toEqual({
|
||||
input: expect.any(Object),
|
||||
index: expect.any(Number)
|
||||
})
|
||||
subgraphTest(
|
||||
'rename output cancellation does not prevent rename',
|
||||
({ emptySubgraph }) => {
|
||||
const output = emptySubgraph.addOutput('original', 'number')
|
||||
|
||||
const addingOutputEvent = capture.getEventsByType('adding-output')[0]
|
||||
expect(addingOutputEvent.detail).toEqual({
|
||||
name: expect.any(String),
|
||||
type: expect.any(String)
|
||||
const preventHandler = vi.fn((event: Event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
emptySubgraph.events.addEventListener('renaming-output', preventHandler)
|
||||
|
||||
const outputAddedEvent = capture.getEventsByType('output-added')[0]
|
||||
expect(outputAddedEvent.detail).toEqual({
|
||||
output: expect.any(Object)
|
||||
})
|
||||
emptySubgraph.renameOutput(output, 'new_name')
|
||||
|
||||
const renamingOutputEvent = capture.getEventsByType('renaming-output')[0]
|
||||
expect(renamingOutputEvent.detail).toEqual({
|
||||
output: expect.any(Object),
|
||||
index: expect.any(Number),
|
||||
oldName: expect.any(String),
|
||||
newName: expect.any(String)
|
||||
})
|
||||
|
||||
const removingOutputEvent = capture.getEventsByType('removing-output')[0]
|
||||
expect(removingOutputEvent.detail).toEqual({
|
||||
output: expect.any(Object),
|
||||
index: expect.any(Number)
|
||||
})
|
||||
expect(output.label).toBe('new_name')
|
||||
expect(preventHandler).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -497,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' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -7,17 +8,23 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
type InputWithWidget = {
|
||||
_widget?: IWidget | { type: string; value: unknown; name: string }
|
||||
_connection?: { id: number; type: string }
|
||||
_listenerController?: AbortController
|
||||
}
|
||||
|
||||
describe.skip('SubgraphNode Memory Management', () => {
|
||||
describe.skip('Event Listener Cleanup', () => {
|
||||
describe('SubgraphNode Memory Management', () => {
|
||||
describe('Event Listener Cleanup', () => {
|
||||
it('should register event listeners on construction', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
@@ -93,8 +100,8 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Widget Promotion Memory Management', () => {
|
||||
it('should clean up promoted widget references', () => {
|
||||
describe('Widget Promotion Memory Management', () => {
|
||||
it('should not mutate manually injected widget references', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'testInput', type: 'number' }]
|
||||
})
|
||||
@@ -127,8 +134,8 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
|
||||
subgraphNode.removeWidget(mockWidget)
|
||||
|
||||
// Widget should be removed from array
|
||||
expect(subgraphNode.widgets).not.toContain(mockWidget)
|
||||
// removeWidget only affects managed promoted widgets, not manually injected entries.
|
||||
expect(subgraphNode.widgets).toContain(mockWidget)
|
||||
})
|
||||
|
||||
it('should not leak widgets during reconfiguration', () => {
|
||||
@@ -162,7 +169,7 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphMemory - Event Listener Management', () => {
|
||||
describe('SubgraphMemory - Event Listener Management', () => {
|
||||
subgraphTest(
|
||||
'event handlers still work after node creation',
|
||||
({ emptySubgraph }) => {
|
||||
@@ -254,35 +261,18 @@ describe.skip('SubgraphMemory - Event Listener Management', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('SubgraphMemory - Reference Management', () => {
|
||||
it('properly manages subgraph references in root graph', () => {
|
||||
describe('SubgraphMemory - Reference Management', () => {
|
||||
it('maintains proper parent-child references while attached', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphId = subgraph.id
|
||||
|
||||
// Add subgraph to root graph registry
|
||||
rootGraph.subgraphs.set(subgraphId, subgraph)
|
||||
expect(rootGraph.subgraphs.has(subgraphId)).toBe(true)
|
||||
expect(rootGraph.subgraphs.get(subgraphId)).toBe(subgraph)
|
||||
|
||||
// Remove subgraph from registry
|
||||
rootGraph.subgraphs.delete(subgraphId)
|
||||
expect(rootGraph.subgraphs.has(subgraphId)).toBe(false)
|
||||
})
|
||||
|
||||
it('maintains proper parent-child references', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph({ nodeCount: 2 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
|
||||
// Add to graph
|
||||
rootGraph.add(subgraphNode)
|
||||
expect(subgraphNode.graph).toBe(rootGraph)
|
||||
expect(rootGraph.nodes).toContain(subgraphNode)
|
||||
|
||||
// Remove from graph
|
||||
rootGraph.remove(subgraphNode)
|
||||
expect(rootGraph.nodes).not.toContain(subgraphNode)
|
||||
})
|
||||
|
||||
it('prevents circular reference creation', () => {
|
||||
@@ -298,65 +288,7 @@ describe.skip('SubgraphMemory - Reference Management', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphMemory - Widget Reference Management', () => {
|
||||
subgraphTest(
|
||||
'properly sets and clears widget references',
|
||||
({ simpleSubgraph }) => {
|
||||
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
|
||||
const input = subgraphNode.inputs[0]
|
||||
|
||||
// Mock widget for testing
|
||||
const mockWidget = {
|
||||
type: 'number',
|
||||
value: 42,
|
||||
name: 'test_widget'
|
||||
}
|
||||
|
||||
// Set widget reference
|
||||
if (input && '_widget' in input) {
|
||||
;(input as InputWithWidget)._widget = mockWidget
|
||||
expect((input as InputWithWidget)._widget).toBe(mockWidget)
|
||||
}
|
||||
|
||||
// Clear widget reference
|
||||
if (input && '_widget' in input) {
|
||||
;(input as InputWithWidget)._widget = undefined
|
||||
expect((input as InputWithWidget)._widget).toBeUndefined()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest('maintains widget count consistency', ({ simpleSubgraph }) => {
|
||||
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
|
||||
|
||||
const initialWidgetCount = subgraphNode.widgets?.length || 0
|
||||
|
||||
const widget1 = {
|
||||
type: 'number',
|
||||
value: 1,
|
||||
name: 'widget1',
|
||||
options: {},
|
||||
y: 0
|
||||
} as Partial<IWidget> as IWidget
|
||||
const widget2 = {
|
||||
type: 'string',
|
||||
value: 'test',
|
||||
name: 'widget2',
|
||||
options: {},
|
||||
y: 0
|
||||
} as Partial<IWidget> as IWidget
|
||||
|
||||
if (subgraphNode.widgets) {
|
||||
subgraphNode.widgets.push(widget1, widget2)
|
||||
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
|
||||
}
|
||||
|
||||
if (subgraphNode.widgets) {
|
||||
subgraphNode.widgets.length = initialWidgetCount
|
||||
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
|
||||
}
|
||||
})
|
||||
|
||||
describe('SubgraphMemory - Widget Reference Management', () => {
|
||||
subgraphTest(
|
||||
'cleans up references during node removal',
|
||||
({ simpleSubgraph }) => {
|
||||
@@ -399,7 +331,7 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('SubgraphMemory - Performance and Scale', () => {
|
||||
describe('SubgraphMemory - Performance and Scale', () => {
|
||||
subgraphTest(
|
||||
'handles multiple subgraphs in same graph',
|
||||
({ subgraphWithNode }) => {
|
||||
@@ -450,29 +382,4 @@ describe.skip('SubgraphMemory - Performance and Scale', () => {
|
||||
|
||||
expect(rootGraph.nodes.length).toBe(0)
|
||||
})
|
||||
|
||||
it('maintains consistent behavior across multiple cycles', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const rootGraph = new LGraph()
|
||||
|
||||
for (let cycle = 0; cycle < 10; cycle++) {
|
||||
const instances = []
|
||||
|
||||
// Create instances
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const instance = createTestSubgraphNode(subgraph)
|
||||
rootGraph.add(instance)
|
||||
instances.push(instance)
|
||||
}
|
||||
|
||||
expect(rootGraph.nodes.length).toBe(10)
|
||||
|
||||
// Remove instances
|
||||
for (const instance of instances) {
|
||||
rootGraph.remove(instance)
|
||||
}
|
||||
|
||||
expect(rootGraph.nodes.length).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
// TODO: Fix these tests after migration
|
||||
/**
|
||||
* SubgraphNode Tests
|
||||
*
|
||||
* Tests for SubgraphNode instances including construction,
|
||||
* IO synchronization, and edge cases.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('SubgraphNode Construction', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphNode Construction', () => {
|
||||
it('should create a SubgraphNode from a subgraph definition', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Test Definition',
|
||||
@@ -102,7 +106,7 @@ describe.skip('SubgraphNode Construction', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Synchronization', () => {
|
||||
describe('SubgraphNode Synchronization', () => {
|
||||
it('should sync input addition', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -194,15 +198,7 @@ describe.skip('SubgraphNode Synchronization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Lifecycle', () => {
|
||||
it('should initialize with empty widgets array', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.widgets).toBeDefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('SubgraphNode Lifecycle', () => {
|
||||
it('should handle reconfiguration', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'input1', type: 'number' }],
|
||||
@@ -254,15 +250,7 @@ describe.skip('SubgraphNode Lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Basic Functionality', () => {
|
||||
it('should identify as subgraph node', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.isSubgraphNode()).toBe(true)
|
||||
expect(subgraphNode.isVirtualNode).toBe(true)
|
||||
})
|
||||
|
||||
describe('SubgraphNode Basic Functionality', () => {
|
||||
it('should inherit input types correctly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
@@ -294,7 +282,7 @@ describe.skip('SubgraphNode Basic Functionality', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Execution', () => {
|
||||
describe('SubgraphNode Execution', () => {
|
||||
it('should flatten to ExecutableNodeDTOs', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 3 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -302,32 +290,39 @@ describe.skip('SubgraphNode Execution', () => {
|
||||
const executableNodes = new Map()
|
||||
const flattened = subgraphNode.getInnerNodes(executableNodes)
|
||||
|
||||
const nodeId = subgraphNode.id
|
||||
const idPattern = new RegExp(`^${nodeId}:\\d+$`)
|
||||
expect(flattened).toHaveLength(3)
|
||||
expect(flattened[0].id).toMatch(/^1:\d+$/) // Should have path-based ID like "1:1"
|
||||
expect(flattened[1].id).toMatch(/^1:\d+$/)
|
||||
expect(flattened[2].id).toMatch(/^1:\d+$/)
|
||||
expect(flattened[0].id).toMatch(idPattern)
|
||||
expect(flattened[1].id).toMatch(idPattern)
|
||||
expect(flattened[2].id).toMatch(idPattern)
|
||||
})
|
||||
|
||||
it.skip('should handle nested subgraph execution', () => {
|
||||
// FIXME: Complex nested structure requires proper parent graph setup
|
||||
// Skip for now - similar issue to ExecutableNodeDTO nested test
|
||||
// Will implement proper nested execution test in edge cases file
|
||||
it('should handle nested subgraph execution', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const childSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
name: 'Child',
|
||||
nodeCount: 1
|
||||
})
|
||||
|
||||
const parentSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
name: 'Parent',
|
||||
nodeCount: 1
|
||||
})
|
||||
|
||||
const childSubgraphNode = createTestSubgraphNode(childSubgraph, { id: 42 })
|
||||
const childSubgraphNode = createTestSubgraphNode(childSubgraph, {
|
||||
id: 42,
|
||||
parentGraph: parentSubgraph
|
||||
})
|
||||
parentSubgraph.add(childSubgraphNode)
|
||||
|
||||
const parentSubgraphNode = createTestSubgraphNode(parentSubgraph, {
|
||||
id: 10
|
||||
id: 10,
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(parentSubgraphNode)
|
||||
|
||||
const executableNodes = new Map()
|
||||
const flattened = parentSubgraphNode.getInnerNodes(executableNodes)
|
||||
@@ -362,44 +357,16 @@ describe.skip('SubgraphNode Execution', () => {
|
||||
})
|
||||
|
||||
it('should prevent infinite recursion', () => {
|
||||
// Cycle detection properly prevents infinite recursion when a subgraph contains itself
|
||||
// Circular self-references currently recurse in traversal; this test documents
|
||||
// that execution flattening throws instead of silently succeeding.
|
||||
const subgraph = createTestSubgraph({ nodeCount: 1 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Add subgraph node to its own subgraph (circular reference)
|
||||
subgraph.add(subgraphNode)
|
||||
|
||||
const executableNodes = new Map()
|
||||
expect(() => {
|
||||
subgraphNode.getInnerNodes(executableNodes)
|
||||
}).toThrow(
|
||||
/Circular reference detected.*infinite loop in the subgraph hierarchy/i
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle nested subgraph execution', () => {
|
||||
// This test verifies that subgraph nodes can be properly executed
|
||||
// when they contain other nodes and produce correct output
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Nested Execution Test',
|
||||
nodeCount: 3
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: subgraph
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Verify that we can get executable DTOs for all nested nodes
|
||||
const executableNodes = new Map()
|
||||
const flattened = subgraphNode.getInnerNodes(executableNodes)
|
||||
|
||||
expect(flattened).toHaveLength(3)
|
||||
|
||||
// Each DTO should have proper execution context
|
||||
for (const dto of flattened) {
|
||||
expect(dto).toHaveProperty('id')
|
||||
expect(dto).toHaveProperty('graph')
|
||||
expect(dto).toHaveProperty('inputs')
|
||||
expect(dto.id).toMatch(/^\d+:\d+$/) // Path-based ID format
|
||||
}
|
||||
// Add subgraph node to its own subgraph (circular reference)
|
||||
// add() itself throws due to recursive forEachNode traversal
|
||||
expect(() => subgraph.add(subgraphNode)).toThrow()
|
||||
})
|
||||
|
||||
it('should resolve cross-boundary links', () => {
|
||||
@@ -427,7 +394,7 @@ describe.skip('SubgraphNode Execution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Edge Cases', () => {
|
||||
describe('SubgraphNode Edge Cases', () => {
|
||||
it('should handle deep nesting', () => {
|
||||
// Create a simpler deep nesting test that works with current implementation
|
||||
const subgraph = createTestSubgraph({
|
||||
@@ -451,18 +418,9 @@ describe.skip('SubgraphNode Edge Cases', () => {
|
||||
expect(dto.id).toMatch(/^\d+:\d+$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should validate against MAX_NESTED_SUBGRAPHS', () => {
|
||||
// Test that the MAX_NESTED_SUBGRAPHS constant exists
|
||||
// Note: Currently not enforced in the implementation
|
||||
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
|
||||
|
||||
// This test documents the current behavior - limit is not enforced
|
||||
// TODO: Implement actual limit enforcement when business requirements clarify
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Integration', () => {
|
||||
describe('SubgraphNode Integration', () => {
|
||||
it('should be addable to a parent graph', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -494,39 +452,13 @@ describe.skip('SubgraphNode Integration', () => {
|
||||
expect(parentGraph.nodes).toContain(subgraphNode)
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
expect(parentGraph.nodes).not.toContain(subgraphNode)
|
||||
expect(parentGraph.nodes.find((node) => node.id === subgraphNode.id)).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Foundation Test Utilities', () => {
|
||||
it('should create test SubgraphNodes with custom options', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const customPos: [number, number] = [500, 300]
|
||||
const customSize: [number, number] = [250, 120]
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
pos: customPos,
|
||||
size: customSize
|
||||
})
|
||||
|
||||
expect(Array.from(subgraphNode.pos)).toEqual(customPos)
|
||||
expect(Array.from(subgraphNode.size)).toEqual(customSize)
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'fixtures should provide properly configured SubgraphNode',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
|
||||
|
||||
expect(subgraph).toBeDefined()
|
||||
expect(subgraphNode).toBeDefined()
|
||||
expect(parentGraph).toBeDefined()
|
||||
expect(parentGraph.nodes).toContain(subgraphNode)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Cleanup', () => {
|
||||
describe('SubgraphNode Cleanup', () => {
|
||||
it('should clean up event listeners when removed', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph()
|
||||
@@ -544,10 +476,8 @@ describe.skip('SubgraphNode Cleanup', () => {
|
||||
// Remove node2
|
||||
rootGraph.remove(node2)
|
||||
|
||||
// Now trigger an event - only node1 should respond
|
||||
subgraph.events.dispatch('input-added', {
|
||||
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
|
||||
})
|
||||
// Now trigger a real event through subgraph API - only node1 should respond
|
||||
subgraph.addInput('test', 'number')
|
||||
|
||||
// Only node1 should have added an input
|
||||
expect(node1.inputs.length).toBe(1) // node1 responds
|
||||
@@ -571,10 +501,8 @@ describe.skip('SubgraphNode Cleanup', () => {
|
||||
expect(node.inputs.length).toBe(0)
|
||||
}
|
||||
|
||||
// Trigger an event - no nodes should respond
|
||||
subgraph.events.dispatch('input-added', {
|
||||
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
|
||||
})
|
||||
// Trigger an event - no removed nodes should respond
|
||||
subgraph.addInput('test', 'number')
|
||||
|
||||
// Without cleanup: all 3 removed nodes would have added an input
|
||||
// With cleanup: no nodes should have added an input
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
interface MockPointerEvent {
|
||||
canvasX: number
|
||||
canvasY: number
|
||||
}
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe.skip('SubgraphNode Title Button', () => {
|
||||
describe.skip('Constructor', () => {
|
||||
describe('SubgraphNode Title Button', () => {
|
||||
describe('Constructor', () => {
|
||||
it('should automatically add enter_subgraph button', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Test Subgraph',
|
||||
@@ -30,10 +31,6 @@ describe.skip('SubgraphNode Title Button', () => {
|
||||
const button = subgraphNode.title_buttons[0]
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBe('enter_subgraph')
|
||||
expect(button.text).toBe('\uE93B') // pi-window-maximize
|
||||
expect(button.xOffset).toBe(-10)
|
||||
expect(button.yOffset).toBe(0)
|
||||
expect(button.fontSize).toBe(16)
|
||||
})
|
||||
|
||||
it('should preserve enter_subgraph button when adding more buttons', () => {
|
||||
@@ -52,7 +49,7 @@ describe.skip('SubgraphNode Title Button', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('onTitleButtonClick', () => {
|
||||
describe('onTitleButtonClick', () => {
|
||||
it('should open subgraph when enter_subgraph button is clicked', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Test Subgraph'
|
||||
@@ -68,7 +65,7 @@ describe.skip('SubgraphNode Title Button', () => {
|
||||
|
||||
subgraphNode.onTitleButtonClick(enterButton, canvas)
|
||||
|
||||
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
|
||||
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
|
||||
expect(canvas.dispatch).not.toHaveBeenCalled() // Should not call parent implementation
|
||||
})
|
||||
|
||||
@@ -99,8 +96,8 @@ describe.skip('SubgraphNode Title Button', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Integration with node click handling', () => {
|
||||
it('should handle clicks on enter_subgraph button', () => {
|
||||
describe('Integration with node click handling', () => {
|
||||
it('should expose button hit testing that canvas uses for click routing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Nested Subgraph',
|
||||
nodeCount: 3
|
||||
@@ -130,66 +127,48 @@ describe.skip('SubgraphNode Title Button', () => {
|
||||
dispatch: vi.fn()
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||
|
||||
// Simulate click on the enter button
|
||||
const event: MockPointerEvent = {
|
||||
canvasX: 275, // Near right edge where button should be
|
||||
canvasY: 80 // In title area
|
||||
}
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
275 - subgraphNode.pos[0], // 275 - 100 = 175
|
||||
80 - subgraphNode.pos[1] // 80 - 100 = -20
|
||||
]
|
||||
|
||||
// @ts-expect-error onMouseDown possibly undefined
|
||||
const handled = subgraphNode.onMouseDown(
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
clickPosRelativeToNode,
|
||||
canvas
|
||||
)
|
||||
expect(
|
||||
enterButton.isPointInside(
|
||||
clickPosRelativeToNode[0],
|
||||
clickPosRelativeToNode[1]
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
|
||||
subgraphNode.onTitleButtonClick(enterButton, canvas)
|
||||
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
|
||||
})
|
||||
|
||||
it('should not interfere with normal node operations', () => {
|
||||
it('does not report hits outside the enter button area', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.pos = [100, 100]
|
||||
subgraphNode.size = [200, 100]
|
||||
|
||||
const canvas = {
|
||||
ctx: {
|
||||
measureText: vi.fn().mockReturnValue({ width: 25 })
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
|
||||
openSubgraph: vi.fn(),
|
||||
dispatch: vi.fn()
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||
const enterButton = subgraphNode.title_buttons[0]
|
||||
enterButton.getWidth = vi.fn().mockReturnValue(25)
|
||||
enterButton.height = 20
|
||||
enterButton._last_area[0] = 170
|
||||
enterButton._last_area[1] = -30
|
||||
enterButton._last_area[2] = 25
|
||||
enterButton._last_area[3] = 20
|
||||
|
||||
// Click in the body of the node, not on button
|
||||
const event: MockPointerEvent = {
|
||||
canvasX: 200, // Middle of node
|
||||
canvasY: 150 // Body area
|
||||
}
|
||||
const bodyClickRelativeToNode: [number, number] = [100, 50]
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
200 - subgraphNode.pos[0], // 200 - 100 = 100
|
||||
150 - subgraphNode.pos[1] // 150 - 100 = 50
|
||||
]
|
||||
|
||||
const handled = subgraphNode.onMouseDown!(
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
clickPosRelativeToNode,
|
||||
canvas
|
||||
)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(canvas.openSubgraph).not.toHaveBeenCalled()
|
||||
expect(
|
||||
enterButton.isPointInside(
|
||||
bodyClickRelativeToNode[0],
|
||||
bodyClickRelativeToNode[1]
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should not process button clicks when node is collapsed', () => {
|
||||
it('keeps enter button metadata but canvas is responsible for collapsed guard', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.pos = [100, 100]
|
||||
@@ -206,52 +185,18 @@ describe.skip('SubgraphNode Title Button', () => {
|
||||
enterButton._last_area[2] = 25
|
||||
enterButton._last_area[3] = 20
|
||||
|
||||
const canvas = {
|
||||
ctx: {
|
||||
measureText: vi.fn().mockReturnValue({ width: 25 })
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
|
||||
openSubgraph: vi.fn(),
|
||||
dispatch: vi.fn()
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||
|
||||
// Try to click on where the button would be
|
||||
const event: MockPointerEvent = {
|
||||
canvasX: 275,
|
||||
canvasY: 80
|
||||
}
|
||||
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
275 - subgraphNode.pos[0], // 175
|
||||
80 - subgraphNode.pos[1] // -20
|
||||
]
|
||||
|
||||
const handled = subgraphNode.onMouseDown!(
|
||||
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
|
||||
clickPosRelativeToNode,
|
||||
canvas
|
||||
)
|
||||
|
||||
// Should not handle the click when collapsed
|
||||
expect(handled).toBe(false)
|
||||
expect(canvas.openSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Visual properties', () => {
|
||||
it('should have appropriate visual properties for enter button', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const enterButton = subgraphNode.title_buttons[0]
|
||||
|
||||
// Check visual properties
|
||||
expect(enterButton.text).toBe('\uE93B') // pi-window-maximize
|
||||
expect(enterButton.fontSize).toBe(16) // Icon size
|
||||
expect(enterButton.xOffset).toBe(-10) // Positioned from right edge
|
||||
expect(enterButton.yOffset).toBe(0) // Centered vertically
|
||||
|
||||
// Should be visible by default
|
||||
expect(enterButton.visible).toBe(true)
|
||||
expect(
|
||||
enterButton.isPointInside(
|
||||
clickPosRelativeToNode[0],
|
||||
clickPosRelativeToNode[1]
|
||||
)
|
||||
).toBe(true)
|
||||
expect(subgraphNode.flags.collapsed).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
@@ -775,20 +856,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
|
||||
@@ -958,11 +1033,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}"`
|
||||
@@ -970,7 +1045,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)
|
||||
|
||||
@@ -978,10 +1058,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
|
||||
@@ -1004,21 +1081,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,6 +1142,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
|
||||
|
||||
@@ -1083,12 +1155,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)
|
||||
}
|
||||
|
||||
@@ -1096,22 +1163,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
|
||||
@@ -1123,13 +1192,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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1153,12 +1224,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 = []
|
||||
@@ -1314,11 +1380,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 &&
|
||||
@@ -1343,7 +1413,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
String(input._subgraphSlot.id),
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
inputName
|
||||
inputName,
|
||||
view.disambiguatingSourceNodeId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1353,20 +1424,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) {
|
||||
@@ -1468,10 +1529,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()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
// TODO: Fix these tests after migration
|
||||
/**
|
||||
* SubgraphSerialization Tests
|
||||
*
|
||||
* Tests for saving, loading, and version compatibility of subgraphs.
|
||||
* This covers serialization, deserialization, data integrity, and migration scenarios.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('SubgraphSerialization - Basic Serialization', () => {
|
||||
function createRegisteredNode(
|
||||
graph: LGraph | Subgraph,
|
||||
inputs: ISlotType[] = [],
|
||||
outputs: ISlotType[] = [],
|
||||
title?: string
|
||||
) {
|
||||
const type = JSON.stringify({ inputs, outputs })
|
||||
if (!LiteGraph.registered_node_types[type]) {
|
||||
class testnode extends LGraphNode {
|
||||
constructor(title: string) {
|
||||
super(title)
|
||||
let i = 0
|
||||
for (const input of inputs) this.addInput('input_' + i++, input)
|
||||
let o = 0
|
||||
for (const output of outputs) this.addOutput('output_' + o++, output)
|
||||
}
|
||||
}
|
||||
LiteGraph.registered_node_types[type] = testnode
|
||||
}
|
||||
const node = LiteGraph.createNode(type, title)
|
||||
if (!node) throw new Error('Failed to create node')
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphSerialization - Basic Serialization', () => {
|
||||
it('should save and load simple subgraphs', () => {
|
||||
const original = createTestSubgraph({
|
||||
name: 'Simple Test',
|
||||
@@ -122,7 +160,7 @@ describe.skip('SubgraphSerialization - Basic Serialization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphSerialization - Complex Serialization', () => {
|
||||
describe('SubgraphSerialization - Complex Serialization', () => {
|
||||
it('should serialize nested subgraphs with multiple levels', () => {
|
||||
// Create a nested structure
|
||||
const childSubgraph = createTestSubgraph({
|
||||
@@ -189,35 +227,28 @@ describe.skip('SubgraphSerialization - Complex Serialization', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should preserve custom node data', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 2 })
|
||||
|
||||
// Add custom properties to nodes (if supported)
|
||||
const nodes = subgraph.nodes
|
||||
if (nodes.length > 0) {
|
||||
const firstNode = nodes[0]
|
||||
if (firstNode.properties) {
|
||||
firstNode.properties.customValue = 42
|
||||
firstNode.properties.customString = 'test'
|
||||
}
|
||||
}
|
||||
it('should preserve I/O even when nodes are not restored', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
nodeCount: 2,
|
||||
inputs: [{ name: 'data_in', type: 'number' }],
|
||||
outputs: [{ name: 'data_out', type: 'string' }]
|
||||
})
|
||||
|
||||
const exported = subgraph.asSerialisable()
|
||||
const restored = new Subgraph(new LGraph(), exported)
|
||||
|
||||
// Test nodes may not be restored if they don't have registered types
|
||||
// This is expected behavior
|
||||
// Nodes are not restored without registered types
|
||||
expect(restored.nodes).toHaveLength(0)
|
||||
|
||||
// Custom properties preservation depends on node implementation
|
||||
// This test documents the expected behavior
|
||||
if (restored.nodes.length > 0 && restored.nodes[0].properties) {
|
||||
// Properties should be preserved if the node supports them
|
||||
expect(restored.nodes[0].properties).toBeDefined()
|
||||
}
|
||||
// I/O is still preserved
|
||||
expect(restored.inputs).toHaveLength(1)
|
||||
expect(restored.inputs[0].name).toBe('data_in')
|
||||
expect(restored.outputs).toHaveLength(1)
|
||||
expect(restored.outputs[0].name).toBe('data_out')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphSerialization - Version Compatibility', () => {
|
||||
describe('SubgraphSerialization - Version Compatibility', () => {
|
||||
it('should handle version field in exports', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 1 })
|
||||
const exported = subgraph.asSerialisable()
|
||||
@@ -323,7 +354,7 @@ describe.skip('SubgraphSerialization - Version Compatibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphSerialization - Data Integrity', () => {
|
||||
describe('SubgraphSerialization - Data Integrity', () => {
|
||||
it('should pass round-trip testing (save → load → save → compare)', () => {
|
||||
const original = createTestSubgraph({
|
||||
name: 'Round Trip Test',
|
||||
@@ -400,36 +431,48 @@ describe.skip('SubgraphSerialization - Data Integrity', () => {
|
||||
expect(instance.outputs.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve node positions and properties', () => {
|
||||
it('should not restore nodes without registered types', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 2 })
|
||||
|
||||
// Modify node positions if possible
|
||||
if (subgraph.nodes.length > 0) {
|
||||
const node = subgraph.nodes[0]
|
||||
if ('pos' in node) {
|
||||
node.pos = [100, 200]
|
||||
}
|
||||
if ('size' in node) {
|
||||
node.size = [150, 80]
|
||||
}
|
||||
}
|
||||
// Nodes exist before serialization
|
||||
expect(subgraph.nodes).toHaveLength(2)
|
||||
|
||||
const exported = subgraph.asSerialisable()
|
||||
const restored = new Subgraph(new LGraph(), exported)
|
||||
|
||||
// Test nodes may not be restored if they don't have registered types
|
||||
// This is expected behavior
|
||||
// Nodes are not restored without registered types
|
||||
expect(restored.nodes).toHaveLength(0)
|
||||
})
|
||||
|
||||
// Position/size preservation depends on node implementation
|
||||
// This test documents the expected behavior
|
||||
if (restored.nodes.length > 0) {
|
||||
const restoredNode = restored.nodes[0]
|
||||
expect(restoredNode).toBeDefined()
|
||||
it('should preserve interior link structure through serialization', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 0 })
|
||||
|
||||
// Properties should be preserved if supported
|
||||
if ('pos' in restoredNode && restoredNode.pos) {
|
||||
expect(Array.isArray(restoredNode.pos)).toBe(true)
|
||||
}
|
||||
const nodeA = createRegisteredNode(subgraph, [], ['number'], 'A')
|
||||
const nodeB = createRegisteredNode(subgraph, ['number'], ['string'], 'B')
|
||||
const nodeC = createRegisteredNode(subgraph, ['string'], [], 'C')
|
||||
|
||||
nodeA.connect(0, nodeB, 0)
|
||||
nodeB.connect(0, nodeC, 0)
|
||||
|
||||
expect(subgraph.nodes).toHaveLength(3)
|
||||
expect(subgraph.links.size).toBe(2)
|
||||
|
||||
const exported = subgraph.asSerialisable()
|
||||
const restored = new Subgraph(new LGraph(), exported)
|
||||
restored.configure(exported)
|
||||
|
||||
expect(restored.nodes).toHaveLength(3)
|
||||
expect(restored.links.size).toBe(2)
|
||||
|
||||
for (const [, link] of restored.links) {
|
||||
const originNode = restored.getNodeById(link.origin_id)
|
||||
const targetNode = restored.getNodeById(link.target_id)
|
||||
expect(originNode).toBeDefined()
|
||||
expect(targetNode).toBeDefined()
|
||||
expect(link.origin_slot).toBeGreaterThanOrEqual(0)
|
||||
expect(link.target_slot).toBeGreaterThanOrEqual(0)
|
||||
expect(originNode!.outputs[link.origin_slot]).toBeDefined()
|
||||
expect(targetNode!.inputs[link.target_slot]).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
@@ -17,11 +18,17 @@ import type {
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('Subgraph slot connections', () => {
|
||||
describe.skip('SubgraphInput connections', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('Subgraph slot connections', () => {
|
||||
describe('SubgraphInput connections', () => {
|
||||
it('should connect to compatible regular input slots', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'test_input', type: 'number' }]
|
||||
@@ -84,7 +91,7 @@ describe.skip('Subgraph slot connections', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('SubgraphOutput connections', () => {
|
||||
describe('SubgraphOutput connections', () => {
|
||||
it('should connect from compatible regular output slots', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const node = new LGraphNode('TestNode')
|
||||
@@ -116,7 +123,7 @@ describe.skip('Subgraph slot connections', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('LinkConnector dragging behavior', () => {
|
||||
describe('LinkConnector dragging behavior', () => {
|
||||
it('should drag existing link when dragging from input slot connected to subgraph input node', () => {
|
||||
// Create a subgraph with one input
|
||||
const subgraph = createTestSubgraph({
|
||||
@@ -168,7 +175,7 @@ describe.skip('Subgraph slot connections', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Type compatibility', () => {
|
||||
describe('Type compatibility', () => {
|
||||
it('should respect type compatibility for SubgraphInput connections', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
@@ -223,7 +230,7 @@ describe.skip('Subgraph slot connections', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Type guards', () => {
|
||||
describe('Type guards', () => {
|
||||
it('should correctly identify SubgraphInput', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphInput = subgraph.addInput('value', 'number')
|
||||
@@ -251,7 +258,7 @@ describe.skip('Subgraph slot connections', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Nested subgraphs', () => {
|
||||
describe('Nested subgraphs', () => {
|
||||
it('should handle dragging from SubgraphInput in nested subgraphs', () => {
|
||||
const parentSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'parent_input', type: 'number' }],
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
interface MockColorContext {
|
||||
defaultInputColor: string
|
||||
@@ -13,12 +17,15 @@ interface MockColorContext {
|
||||
getDisconnectedColor: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe.skip('SubgraphSlot visual feedback', () => {
|
||||
describe('SubgraphSlot visual feedback', () => {
|
||||
let mockCtx: CanvasRenderingContext2D
|
||||
let mockColorContext: MockColorContext
|
||||
let globalAlphaValues: number[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
|
||||
// Clear the array before each test
|
||||
globalAlphaValues = []
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
ISlotType,
|
||||
@@ -11,7 +12,8 @@ import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createEventCapture,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
// Helper to create a node with a widget
|
||||
@@ -53,8 +55,13 @@ function setupPromotedWidget(
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
describe.skip('SubgraphWidgetPromotion', () => {
|
||||
describe.skip('Widget Promotion Functionality', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphWidgetPromotion', () => {
|
||||
describe('Widget Promotion Functionality', () => {
|
||||
it('should promote widgets when connecting node to subgraph input', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
@@ -140,34 +147,6 @@ describe.skip('SubgraphWidgetPromotion', () => {
|
||||
eventCapture.cleanup()
|
||||
})
|
||||
|
||||
it('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: [
|
||||
@@ -284,7 +263,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Tooltip Promotion', () => {
|
||||
describe('Tooltip Promotion', () => {
|
||||
it('should preserve widget tooltip when promoting', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphEventMap'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
LGraph,
|
||||
Subgraph,
|
||||
SubgraphEventMap,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test as baseTest } from '../../__fixtures__/testExtensions'
|
||||
|
||||
@@ -20,14 +22,17 @@ const test = baseTest.extend({
|
||||
pinia: [
|
||||
async ({}, use) => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
await use(undefined)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
import {
|
||||
createTestRootGraph,
|
||||
createEventCapture,
|
||||
createNestedSubgraphs,
|
||||
resetSubgraphFixtureState,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './subgraphHelpers'
|
||||
@@ -133,8 +138,9 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
nodeCount: 1
|
||||
})
|
||||
|
||||
const parentGraph = new LGraph()
|
||||
const parentGraph = createTestRootGraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph,
|
||||
pos: [200, 200],
|
||||
size: [180, 80]
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
@@ -6,12 +6,19 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
createNestedSubgraphs,
|
||||
createTestSubgraph,
|
||||
resetSubgraphFixtureState,
|
||||
setupComplexPromotionFixture
|
||||
} from './subgraphHelpers'
|
||||
|
||||
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
|
||||
|
||||
describe('setupComplexPromotionFixture', () => {
|
||||
beforeEach(() => {
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
@@ -29,4 +36,53 @@ describe('setupComplexPromotionFixture', () => {
|
||||
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a promotion fixture bound to a deterministic root graph', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const { graph, subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
expect(graph.id).toBe('00000000-0000-4000-8000-000000000001')
|
||||
expect(subgraph.rootGraph).toBe(graph)
|
||||
expect(hostNode.graph).toBe(graph)
|
||||
expect(hostNode.subgraph).toBe(subgraph)
|
||||
expect(graph.getNodeById(hostNode.id)).toBe(hostNode)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subgraph fixture graph setup', () => {
|
||||
beforeEach(() => {
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
it('creates deterministic root and subgraph ids', () => {
|
||||
const first = createTestSubgraph()
|
||||
const second = createTestSubgraph()
|
||||
|
||||
expect(first.rootGraph.id).toBe('00000000-0000-4000-8000-000000000001')
|
||||
expect(first.id).toBe('00000000-0000-4000-8000-000000000002')
|
||||
expect(second.rootGraph.id).toBe('00000000-0000-4000-8000-000000000003')
|
||||
expect(second.id).toBe('00000000-0000-4000-8000-000000000004')
|
||||
})
|
||||
|
||||
it('creates nested subgraphs that share one root graph and valid parent chain', () => {
|
||||
const nested = createNestedSubgraphs({
|
||||
depth: 3,
|
||||
nodesPerLevel: 1,
|
||||
inputsPerSubgraph: 1,
|
||||
outputsPerSubgraph: 1
|
||||
})
|
||||
|
||||
expect(nested.subgraphs).toHaveLength(3)
|
||||
expect(nested.subgraphNodes).toHaveLength(3)
|
||||
expect(
|
||||
nested.subgraphs.every(
|
||||
(subgraph) => subgraph.rootGraph === nested.rootGraph
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
expect(nested.subgraphNodes[0].graph).toBe(nested.rootGraph)
|
||||
expect(nested.subgraphNodes[1].graph).toBe(nested.subgraphs[0])
|
||||
expect(nested.subgraphNodes[2].graph).toBe(nested.subgraphs[1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,24 +7,27 @@
|
||||
*/
|
||||
import { expect } from 'vitest'
|
||||
|
||||
import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExportedSubgraphInstance,
|
||||
ISlotType,
|
||||
NodeId,
|
||||
UUID
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExportedSubgraphInstance
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1'
|
||||
|
||||
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
|
||||
const FIXTURE_UUID_PREFIX = '00000000-0000-4000-8000-'
|
||||
|
||||
let fixtureUuidSequence = 1
|
||||
|
||||
class FixtureStringConcatenateNode extends LGraphNode {
|
||||
constructor() {
|
||||
@@ -43,7 +46,26 @@ export function cleanupComplexPromotionFixtureNodeType(): void {
|
||||
LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE)
|
||||
}
|
||||
|
||||
function nextFixtureUuid(): UUID {
|
||||
const suffix = fixtureUuidSequence.toString(16).padStart(12, '0')
|
||||
fixtureUuidSequence += 1
|
||||
return `${FIXTURE_UUID_PREFIX}${suffix}`
|
||||
}
|
||||
|
||||
export function resetSubgraphFixtureState(): void {
|
||||
fixtureUuidSequence = 1
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
}
|
||||
|
||||
export function createTestRootGraph(id: UUID = nextFixtureUuid()): LGraph {
|
||||
const graph = new LGraph()
|
||||
graph.id = id
|
||||
return graph
|
||||
}
|
||||
|
||||
interface TestSubgraphOptions {
|
||||
rootGraph?: LGraph
|
||||
rootGraphId?: UUID
|
||||
id?: UUID
|
||||
name?: string
|
||||
nodeCount?: number
|
||||
@@ -54,6 +76,7 @@ interface TestSubgraphOptions {
|
||||
}
|
||||
|
||||
interface TestSubgraphNodeOptions {
|
||||
parentGraph?: LGraph | Subgraph
|
||||
id?: NodeId
|
||||
pos?: [number, number]
|
||||
size?: [number, number]
|
||||
@@ -112,20 +135,27 @@ export interface EventCapture<TEventMap extends object> {
|
||||
export function createTestSubgraph(
|
||||
options: TestSubgraphOptions = {}
|
||||
): Subgraph {
|
||||
if (options.rootGraph && options.rootGraphId) {
|
||||
throw new Error(
|
||||
"Cannot specify both 'rootGraph' and 'rootGraphId'. Choose one."
|
||||
)
|
||||
}
|
||||
|
||||
// Validate options - cannot specify both inputs array and inputCount
|
||||
if (options.inputs && options.inputCount) {
|
||||
throw new Error(
|
||||
`Cannot specify both 'inputs' array and 'inputCount'. Choose one approach. Received options: ${JSON.stringify(options)}`
|
||||
`Cannot specify both 'inputs' array and 'inputCount'. Choose one approach.`
|
||||
)
|
||||
}
|
||||
|
||||
// Validate options - cannot specify both outputs array and outputCount
|
||||
if (options.outputs && options.outputCount) {
|
||||
throw new Error(
|
||||
`Cannot specify both 'outputs' array and 'outputCount'. Choose one approach. Received options: ${JSON.stringify(options)}`
|
||||
`Cannot specify both 'outputs' array and 'outputCount'. Choose one approach.`
|
||||
)
|
||||
}
|
||||
const rootGraph = new LGraph()
|
||||
const rootGraph =
|
||||
options.rootGraph ?? createTestRootGraph(options.rootGraphId)
|
||||
|
||||
const subgraphData: ExportedSubgraph = {
|
||||
version: 1,
|
||||
@@ -142,7 +172,7 @@ export function createTestSubgraph(
|
||||
config: {},
|
||||
definitions: { subgraphs: [] },
|
||||
|
||||
id: options.id || createUuidv4(),
|
||||
id: options.id ?? nextFixtureUuid(),
|
||||
name: options.name || 'Test Subgraph',
|
||||
|
||||
inputNode: {
|
||||
@@ -217,10 +247,10 @@ export function createTestSubgraphNode(
|
||||
subgraph: Subgraph,
|
||||
options: TestSubgraphNodeOptions = {}
|
||||
): SubgraphNode {
|
||||
const parentGraph = new LGraph()
|
||||
const parentGraph = options.parentGraph ?? subgraph.rootGraph
|
||||
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: options.id || 1,
|
||||
id: options.id ?? parentGraph.state.lastNodeId + 1,
|
||||
type: subgraph.id,
|
||||
pos: options.pos || [100, 100],
|
||||
size: options.size || [200, 100],
|
||||
@@ -260,7 +290,7 @@ export function setupComplexPromotionFixture(): {
|
||||
if (!hostNodeData)
|
||||
throw new Error('Expected fixture to contain subgraph instance node id 21')
|
||||
|
||||
const graph = new LGraph()
|
||||
const graph = createTestRootGraph()
|
||||
const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph)
|
||||
subgraph.configure(subgraphData as ExportedSubgraph)
|
||||
const hostNode = new SubgraphNode(
|
||||
@@ -295,7 +325,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
|
||||
outputsPerSubgraph = 1
|
||||
} = options
|
||||
|
||||
const rootGraph = new LGraph()
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraphs: Subgraph[] = []
|
||||
const subgraphNodes: SubgraphNode[] = []
|
||||
|
||||
@@ -304,6 +334,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
|
||||
for (let level = 0; level < depth; level++) {
|
||||
// Create subgraph for this level
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
name: `Level ${level} Subgraph`,
|
||||
nodeCount: nodesPerLevel,
|
||||
inputCount: inputsPerSubgraph,
|
||||
@@ -313,6 +344,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
|
||||
subgraphs.push(subgraph)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: currentParent,
|
||||
pos: [100 + level * 200, 100]
|
||||
})
|
||||
|
||||
@@ -434,7 +466,7 @@ export function createTestSubgraphData(
|
||||
config: {},
|
||||
definitions: { subgraphs: [] },
|
||||
|
||||
id: createUuidv4(),
|
||||
id: nextFixtureUuid(),
|
||||
name: 'Test Data Subgraph',
|
||||
|
||||
inputNode: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
@@ -10,11 +11,17 @@ import type { UUID } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
describe.skip('subgraphUtils', () => {
|
||||
describe.skip('getDirectSubgraphIds', () => {
|
||||
describe('subgraphUtils', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('getDirectSubgraphIds', () => {
|
||||
it('should return empty set for graph with no subgraph nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const result = getDirectSubgraphIds(graph)
|
||||
@@ -65,7 +72,7 @@ describe.skip('subgraphUtils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('findUsedSubgraphIds', () => {
|
||||
describe('findUsedSubgraphIds', () => {
|
||||
it('should handle graph with no subgraphs', () => {
|
||||
const graph = new LGraph()
|
||||
const registry = new Map<UUID, LGraph>()
|
||||
@@ -98,7 +105,7 @@ describe.skip('subgraphUtils', () => {
|
||||
expect(result.has(subgraph2.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle circular references without infinite loop', () => {
|
||||
it('throws RangeError when graph.add() creates a circular subgraph reference', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph1 = createTestSubgraph({ name: 'Subgraph 1' })
|
||||
const subgraph2 = createTestSubgraph({ name: 'Subgraph 2' })
|
||||
@@ -112,18 +119,9 @@ describe.skip('subgraphUtils', () => {
|
||||
subgraph1.add(node2)
|
||||
|
||||
// Add subgraph1 to subgraph2 (circular reference)
|
||||
// Note: add() itself throws RangeError due to recursive forEachNode
|
||||
const node3 = createTestSubgraphNode(subgraph1, { id: 3 })
|
||||
subgraph2.add(node3)
|
||||
|
||||
const registry = new Map<UUID, LGraph>([
|
||||
[subgraph1.id, subgraph1],
|
||||
[subgraph2.id, subgraph2]
|
||||
])
|
||||
|
||||
const result = findUsedSubgraphIds(rootGraph, registry)
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has(subgraph1.id)).toBe(true)
|
||||
expect(result.has(subgraph2.id)).toBe(true)
|
||||
expect(() => subgraph2.add(node3)).toThrow(RangeError)
|
||||
})
|
||||
|
||||
it('should handle missing subgraphs in registry gracefully', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
/>
|
||||
<NodeContent
|
||||
v-for="preview in promotedPreviews"
|
||||
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
|
||||
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
|
||||
:node-data="nodeData"
|
||||
:media="preview"
|
||||
/>
|
||||
@@ -257,7 +257,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)
|
||||
|
||||
@@ -1139,6 +1139,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
|
||||
)
|
||||
|
||||
@@ -98,6 +98,7 @@ vi.mock('@/stores/toastStore', () => ({
|
||||
|
||||
// Mock useDialogService
|
||||
vi.mock('@/services/dialogService')
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
|
||||
// Mock apiKeyAuthStore
|
||||
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
|
||||
@@ -185,7 +186,6 @@ describe('useFirebaseAuthStore', () => {
|
||||
describe('token refresh events', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
|
||||
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
|
||||
(_auth, callback) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -51,6 +66,14 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
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 () => {
|
||||
@@ -87,60 +110,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 () => {
|
||||
@@ -149,12 +221,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)
|
||||
|
||||
@@ -10,6 +10,8 @@ import { app } from '@/scripts/app'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { isNonNullish } 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
|
||||
@@ -29,7 +31,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()
|
||||
@@ -260,5 +263,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